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

1"""One place that decides what an unexpected server error tells the caller. 

2 

3THE PROBLEM 

4----------- 

5102 broad handlers across the service layer did this:: 

6 

7 except Exception as e: 

8 raise HTTPException(status_code=500, detail=str(e)) 

9 

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:: 

14 

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: ..., ... }"} 

18 

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). 

22 

23WHAT THIS DOES NOT TOUCH 

24------------------------ 

25The *narrow* handlers beside those broad ones:: 

26 

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)) 

31 

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. 

36 

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. 

43 

44Developer: Allan Ninal 

45Date: 2026-09-24 

46""" 

47 

48from typing import Optional 

49 

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." 

53 

54 

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. 

57 

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. 

64 

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