Coverage for src/local_deep_research/exceptions.py: 100%

10 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Project-wide exception classes.""" 

2 

3 

4class ResearchTerminatedException(BaseException): 

5 """Raised when a user cancels an in-progress research process. 

6 

7 Inherits from BaseException (not Exception) so that ``except Exception`` 

8 blocks throughout the strategy code naturally let it propagate -- the same 

9 pattern Python's stdlib uses for asyncio.CancelledError (since 3.9), 

10 KeyboardInterrupt, and SystemExit. 

11 """ 

12 

13 pass 

14 

15 

16class NoUserDatabaseError(RuntimeError): 

17 """Raised when a resolved user has no database to open. 

18 

19 Subclasses ``RuntimeError`` deliberately: this condition was signalled by a 

20 bare ``RuntimeError`` before, and callers (plus 

21 ``tests/utilities/test_db_utils.py``) still catch and match on that. The 

22 subclass adds the ability to tell this case apart *without* changing what 

23 anything already catches. 

24 

25 That distinction is the whole point. ``get_settings_manager`` used to catch 

26 every ``RuntimeError`` from ``get_db_session`` and fall back to anonymous 

27 defaults, which conflates three unrelated situations: 

28 

29 1. This one — a known user with no database. Defaults are the only answer 

30 available, so the fallback is correct. 

31 2. A true background thread with no request context. That is a programming 

32 error the codebase already warns about in several places (#3453), and 

33 answering it with defaults means a worker silently reads *someone 

34 else's* configuration instead of the user's. 

35 3. Anything deeper failing inside ``db_manager.get_session`` — a corrupt or 

36 unopenable encrypted database, a keyring failure, or a rekey sidecar 

37 or epoch fault on the cached engine (raised as the typed 

38 ``RekeyOperationalError``, not answered with ``None``). Serving 

39 defaults here is the most dangerous of the three: a setting that is 

40 off by default silently reads as off, even though the user turned it 

41 on, and a stored ``strict`` egress scope reads as the default. 

42 

43 Only case 1 should be swallowed. The other two must reach the caller. 

44 """ 

45 

46 pass 

47 

48 

49class SystemAtCapacityError(Exception): 

50 """Raised when ``start_research_process`` cannot acquire the global 

51 concurrency semaphore. 

52 

53 Previously the semaphore was acquired *inside* the worker thread, so a 

54 full system would silently park the thread after the HTTP route had 

55 already returned 200 — the user saw a thinking spinner that never 

56 advanced, and the partial unique in-progress index blocked retries on 

57 the same chat session. 

58 

59 Acquiring synchronously in the caller and surfacing this exception lets 

60 routes return HTTP 429 (or queue/retry, depending on caller) before any 

61 ``ResearchHistory`` row is committed. 

62 """ 

63 

64 pass 

65 

66 

67class DuplicateResearchError(Exception): 

68 """Raised when a research should not be (re-)spawned. 

69 

70 Two triggering cases, both handled identically by callers: 

71 

72 1. A live thread already exists in the active-research dict for this 

73 ``research_id`` — typically a retry after a prior attempt's 

74 post-spawn ``UserActiveResearch`` commit failed. 

75 2. The ``ResearchHistory.status`` is non-QUEUED (``IN_PROGRESS`` from 

76 a prior attempt's pre-spawn commit that succeeded; terminal 

77 ``COMPLETED`` / ``FAILED`` / ``SUSPENDED`` from a thread that 

78 already finished and cleaned itself out of ``_active_research``). 

79 Re-spawning would either contradict the live thread or re-run a 

80 finished research. 

81 

82 Callers that wrap the spawn in ``except Exception`` to clean up orphan 

83 state on spawn failure MUST catch ``DuplicateResearchError`` separately 

84 *before* that generic branch and re-raise / return without mutating the 

85 research's status or deleting the ``UserActiveResearch`` row — those 

86 rows belong to the live thread, and marking them FAILED terminates a 

87 running thread from the user's perspective while it keeps executing. 

88 """ 

89 

90 pass 

91 

92 

93class InvalidQueuedResearchOverridesError(Exception): 

94 """Raised when persisted queued search overrides fail validation.""" 

95 

96 pass