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

46 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 _normalise_forwarded_allow_ips(raw: str) -> str: 

107 """The allow-list string uvicorn is handed for ``raw``. 

108 

109 TWIN of ``web/dependencies/rate_limit.py``'s 

110 ``_normalise_forwarded_allow_ips`` -- duplicated rather than 

111 imported because importing that module here would run its 

112 import-time startup guard and freeze its view of the allow-list 

113 before uvicorn has started, and would pull slowapi/limits and the 

114 server config into this launcher. The two are pinned together by 

115 tests/security/test_middleware_and_proxy_trust_fastapi.py:: 

116 TestTrustProxyHeadersUvicornWiring:: 

117 test_app_and_rate_limit_normalise_identically. 

118 """ 

119 return ( 

120 ",".join(item.strip() for item in raw.split(",") if item.strip()) or "*" 

121 ) 

122 

123 

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

125 """Launch the FastAPI app via uvicorn. 

126 

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

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

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

130 teardown are all handled inside the FastAPI lifespan. 

131 """ 

132 import os 

133 

134 import uvicorn 

135 

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

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

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

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

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

141 "true", 

142 "1", 

143 "yes", 

144 ) 

145 

146 # Which direct peers uvicorn's ProxyHeadersMiddleware will trust forwarded 

147 # headers from -- passed straight through to uvicorn's own 

148 # `--forwarded-allow-ips`. Defaults to "*" (any peer) for backward 

149 # compatibility with every deployment that has only ever set 

150 # TRUST_PROXY_HEADERS=true. 

151 # 

152 # This default is exactly what makes TRUST_PROXY_HEADERS=true UNSAFE to 

153 # combine with `web/dependencies/rate_limit.py`'s 

154 # LDR_SERVER_TRUSTED_PROXY_COUNT hop-count mode: with "*", the middleware 

155 # rewrites `scope["client"]` to the client-forgeable left-most 

156 # X-Forwarded-For entry for ANY direct connection, not just ones from the 

157 # real proxy -- poisoning the hop-count logic's fallback-to-direct-peer 

158 # safety net before it ever runs (see rate_limit.py's import-time check). 

159 # Restricting this to the actual reverse-proxy IP(s) closes that hole: 

160 # uvicorn then only rewrites `scope["client"]` for connections whose 

161 # DIRECT peer is one of these addresses, so an attacker connecting 

162 # straight to LDR keeps their real socket peer and the rate limiter's 

163 # trust boundary stays intact. See "Combining TRUST_PROXY_HEADERS with 

164 # LDR_SERVER_TRUSTED_PROXY_COUNT" in docs/deployment/reverse-proxy.md 

165 # for the recommended value here. 

166 # 

167 # Normalised per entry, not as one string: `web/dependencies/ 

168 # rate_limit.py` classifies the SAME value and names it in the 

169 # RuntimeError, the startup warning and the info line it logs, so a 

170 # value the two read differently would make every one of those 

171 # messages describe an allow-list uvicorn was never handed. `","` 

172 # used to arrive here as the literal `","` (one entry matching 

173 # nothing, every client in one bucket) while rate_limit.py read it 

174 # as unset. 

175 forwarded_allow_ips = _normalise_forwarded_allow_ips( 

176 os.environ.get("LDR_SERVER_FORWARDED_ALLOW_IPS", "") 

177 ) 

178 

179 uvicorn.run( 

180 "local_deep_research.web.fastapi_app:app", 

181 host=host, 

182 port=port, 

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

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

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

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

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

188 access_log=False, 

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

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

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

192 server_header=False, 

193 timeout_keep_alive=5, 

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

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

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

197 timeout_graceful_shutdown=10, 

198 proxy_headers=trust_proxy, 

199 forwarded_allow_ips=forwarded_allow_ips if trust_proxy else None, 

200 ) 

201 

202 

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

204 main()