Coverage for server / utilities / error_detail.py: 100%
5 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""One place that decides what an unexpected server error tells the caller.
3THE PROBLEM
4-----------
5102 broad handlers across the service layer did this::
7 except Exception as e:
8 raise HTTPException(status_code=500, detail=str(e))
10`except Exception` catches anything — a Mongo driver error, a connection
11failure, a KeyError deep in a helper — and `str(e)` puts it in the response body.
12Found live on the student assignment-start path (EI-1195 follow-up, PR #359),
13where a concurrent-start race produced::
15 500 {"detail": "An unexpected error occurred: E11000 duplicate key error
16 collection: teacher_student_db.submission_collection index:
17 uniq_submission_student_assignment dup key: { student_id: ..., ... }"}
19The database name, the collection, the index name and the key values, handed to
20whoever made the request. That is CWE-209 (information exposure through an error
21message) and OWASP API8:2023 (security misconfiguration).
23WHAT THIS DOES NOT TOUCH
24------------------------
25The *narrow* handlers beside those broad ones::
27 except errors.InvalidId as e:
28 raise HTTPException(status_code=400, detail=str(e))
29 except InvalidStudentRequest as e:
30 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e))
32Those catch an exception the code raised on purpose, carrying a message written
33for the user ("You are already in this class"), and they answer 4xx. Redacting
34them would destroy a deliberate message and tell the user nothing. The split is
35the point: a broad catch at 5xx never had a message of its own to lose.
37DEBUGGABILITY IS NOT LOST
38-------------------------
39The real exception — type, message, and the operation that failed — is printed
40server-side, which is where an operator can read it and a caller cannot. The
41response keeps the caller's own static message, or a neutral one where there was
42never a message at all.
44Developer: Allan Ninal
45Date: 2026-09-24
46"""
48from typing import Optional
50# What a caller sees when the handler had no message of its own. Deliberately
51# says nothing about the cause: the cause is in the log.
52GENERIC_ERROR_DETAIL = "An unexpected error occurred. Please try again."
55def safe_detail(error: BaseException, message: Optional[str] = None) -> str:
56 """Log the real error server-side; return only what is safe to send back.
58 Args:
59 error: the caught exception. Its type and text go to the log, never to
60 the response.
61 message: the handler's own static message, e.g. "Failed to update theme".
62 When omitted the caller gets GENERIC_ERROR_DETAIL — the case where
63 the handler used a bare `detail=str(e)` and had no message at all.
65 Returns:
66 The string to use as the HTTPException detail.
67 """
68 print(f"-- ERROR [{message or 'unhandled'}] {type(error).__name__}: {error}")
69 return message or GENERIC_ERROR_DETAIL