Coverage for src/local_deep_research/web/app.py: 96%

43 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1import threading 

2import traceback 

3 

4from loguru import logger 

5 

6from ..__version__ import __version__ 

7from ..utilities.log_utils import config_logger 

8from .server_config import load_server_config 

9 

10 

11def _install_thread_excepthook() -> None: 

12 """Install a global hook that loudly logs uncaught exceptions on any 

13 thread — including daemon threads — so silent crashes in the queue 

14 processor, APScheduler jobs, or the post-login background thread 

15 surface in logs instead of leaving the app wedged with no signal. 

16 

17 Respects a previously-installed hook if any (chains to it). 

18 """ 

19 previous = threading.excepthook 

20 

21 def _hook(args: threading.ExceptHookArgs) -> None: 

22 # Don't try to log for SystemExit-in-thread; that is intentional. 

23 if issubclass(args.exc_type, SystemExit): 

24 return 

25 try: 

26 tb = "".join( 

27 traceback.format_exception( 

28 args.exc_type, args.exc_value, args.exc_traceback 

29 ) 

30 ) 

31 thread_name = ( 

32 args.thread.name if args.thread is not None else "unknown" 

33 ) 

34 logger.error( 

35 f"Uncaught exception on thread {thread_name!r}: " 

36 f"{args.exc_type.__name__}: {args.exc_value}\n{tb}" 

37 ) 

38 except Exception: 

39 pass # noqa: silent-exception — last-ditch; the excepthook itself must never crash the interpreter 

40 finally: 

41 # Chain to the previous hook (usually threading's default). 

42 try: 

43 previous(args) 

44 except Exception: 

45 pass # noqa: silent-exception — previous hook failing must not turn our hook into a crash vector 

46 

47 threading.excepthook = _hook 

48 

49 

50# reraise=True is load-bearing: without it a fatal startup error is logged and 

51# then SWALLOWED, main() returns None, and the console script exits 0 — so 

52# systemd `Restart=on-failure` and Kubernetes `restartPolicy: OnFailure` read a 

53# dead server as a clean shutdown and never restart it. uvicorn loads the app 

54# eagerly on the workers==1 path, so app-import failures land inside main() 

55# rather than before it. This PR widened the exposure: _load_secret_key() now 

56# hard-raises where the Flask app fell back to an ephemeral key. SystemExit 

57# already propagated (a port conflict correctly exits 3); this makes real 

58# exceptions behave the same way while keeping loguru's formatted traceback. 

59@logger.catch(reraise=True) 

60def main(): 

61 """ 

62 Entry point for the web application (ldr-web command). 

63 

64 Launches uvicorn with the FastAPI app. 

65 """ 

66 # Install the excepthook before any other threads are spawned so 

67 # uncaught exceptions in daemon threads (queue processor, APScheduler 

68 # jobs, post-login background thread) surface in logs instead of 

69 # dying silently. 

70 _install_thread_excepthook() 

71 

72 config = load_server_config() 

73 config_logger("ldr_web", debug=config["debug"]) 

74 logger.info(f"Starting Local Deep Research v{__version__}") 

75 

76 # One-time removal of plaintext legacy RAG docstores (phase 1 of the 

77 # vector-store cutover -- see vector_stores/legacy_cleanup.py). Pure 

78 # filesystem, no DB/login required, so it belongs here at the real 

79 # server-start entrypoint (not in the app factory / lifespan, which the 

80 # test suite exercises without LDR_DATA_DIR isolation -- wiring it there 

81 # would delete a developer's real .pkl files on every test run). Wrapped 

82 # so a cleanup issue never blocks boot; the function logs its own errors 

83 # and is idempotent. Ports #5143. 

84 try: 

85 from ..vector_stores.legacy_cleanup import migrate_legacy_docstores 

86 

87 migrate_legacy_docstores() 

88 except Exception: 

89 logger.exception("Legacy RAG docstore migration failed at startup") 

90 

91 # Ported from main: `web.use_https` never actually served TLS on either 

92 # framework -- main only logged that it is unsupported and told the 

93 # operator to front the app with a reverse proxy. Without this, someone 

94 # who sets LDR_WEB_USE_HTTPS=true gets total silence and reasonably 

95 # assumes TLS is on. Say so plainly instead. 

96 if config.get("use_https"): 

97 logger.warning( 

98 "web.use_https is set, but HTTPS is not served directly. " 

99 "Terminate TLS at a reverse proxy (nginx, Caddy, Traefik) in " 

100 "front of this server; it is listening on plain HTTP." 

101 ) 

102 

103 _run_with_uvicorn(config["host"], config["port"], config["debug"]) 

104 

105 

106def _run_with_uvicorn(host: str, port: int, debug: bool) -> None: 

107 """Launch the FastAPI app via uvicorn. 

108 

109 Lifespan-managed startup/shutdown lives in `fastapi_app.py`; this 

110 function only deals with launching the ASGI server. Logging, 

111 log-queue processor lifecycle, scheduler shutdown, and DB 

112 teardown are all handled inside the FastAPI lifespan. 

113 """ 

114 import os 

115 

116 import uvicorn 

117 

118 # When TRUST_PROXY_HEADERS is set (operator is behind nginx/caddy/traefik), 

119 # honor X-Forwarded-Proto / X-Forwarded-For so request.url.scheme reflects 

120 # TLS termination and HSTS + Secure-cookie logic work correctly. Without 

121 # this, the app always sees http:// behind a TLS proxy. 

122 trust_proxy = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ( 

123 "true", 

124 "1", 

125 "yes", 

126 ) 

127 

128 uvicorn.run( 

129 "local_deep_research.web.fastapi_app:app", 

130 host=host, 

131 port=port, 

132 workers=1, # Required for Socket.IO without Redis message queue 

133 log_level="warning", # uvicorn's own log level; app uses loguru 

134 # No per-request access log. main justified this with an app-level 

135 # request-logging middleware; nothing in the FastAPI app logs a 

136 # request line today, so only explicit app events reach loguru. 

137 access_log=False, 

138 # Don't advertise the server stack. Flask/werkzeug's Server header 

139 # was suppressed on main; uvicorn sends "server: uvicorn" by default, 

140 # which re-introduces the fingerprinting main deliberately removed. 

141 server_header=False, 

142 timeout_keep_alive=5, 

143 # Bound how long we wait for in-flight requests to drain on 

144 # SIGTERM/SIGINT. Without this, uvicorn waits forever for 

145 # long-running streams (research SSE) and the process never exits. 

146 timeout_graceful_shutdown=10, 

147 proxy_headers=trust_proxy, 

148 forwarded_allow_ips="*" if trust_proxy else None, 

149 ) 

150 

151 

152if __name__ == "__main__": 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 main()