Add request-ID middleware that propagates X-Request-ID via ContextVar#45
Add request-ID middleware that propagates X-Request-ID via ContextVar#45mvanhorn wants to merge 1 commit into
Conversation
femi23
left a comment
There was a problem hiding this comment.
Thanks @mvanhorn — this is a clean, well-documented PR that closes #44 the right way: ContextVar + filter so non-FastAPI code (inference pipeline, helpers) sees the same ID. Tests cover header-present, header-absent, log injection, and the leak case. Almost ready.
One small change requested and then we can merge:
Blocker (small)
RequestIDMiddleware is not actually outermost. Your comment in main.py says:
# Register the RequestIDMiddleware LAST so it sits OUTERMOSTbut in the current diff it's registered before CORSMiddleware:
app.add_middleware(AuditLogMiddleware)
app.add_middleware(RequestIDMiddleware) # <- this PR
app.add_middleware(CORSMiddleware, ...)
app.add_middleware(SecurityMiddleware)Starlette wraps middleware in reverse add_middleware order, so SecurityMiddleware ends up outermost and RequestIDMiddleware runs after CORS and Security. That means CORS preflight responses and any SecurityMiddleware early-return won't carry X-Request-ID, and any logging those middlewares do happens before the ContextVar is set.
Please move the app.add_middleware(RequestIDMiddleware) line to after the CORSMiddleware and SecurityMiddleware registrations so it really is the outermost wrapper. The comment will then match the code.
Nits (non-blocking, fix in same push if you like)
- In
setup_logging,root.addFilter(request_id_filter)is redundant — aFilterattached to aLoggerdoes not get re-evaluated when the record propagates to handlers, so the handler-level loop below is what actually makes%(request_id)sshow up. Safe to drop the logger-level line and just keep the per-handler loop. Up to you. request_id_var: ContextVar[str | None]— since you always storerequest.headers.get(...) or str(uuid.uuid4()), the value is neverNoneinside a request. Tightening the type toContextVar[str]withdefault="-"(matching the filter's fallback) would let downstream consumers skip theor "-"dance. Pure ergonomics.
Once the middleware-order fix lands, I'll approve and merge. Solid contribution.
|
📢 Heads-up: repo history was rewritten today (2026-05-18) We force-pushed a cleaned history across all branches to remove an internal directory from past commits. Your code and this PR are unaffected — only the commit SHAs underneath have shifted. GitHub will re-render the diff against the new base automatically. If you have a local clone, please bring it back in sync before pushing anything else: # Option A (simplest): fresh start
git clone https://github.com/Climate-Vision/ClimateVision.git
# Option B: rebase the existing PR branch in your fork
git fetch origin
git checkout <your-branch>
git rebase origin/main # likely no conflicts
git push --force-with-leaseDo not Apologies for the interruption — really appreciate your patience here. If anything looks off after rebasing, leave a comment and I'll help unblock right away. Thanks for contributing 🙏 |
Closes Climate-Vision#44. Adds a `RequestIDMiddleware` (in `src/climatevision/api/middleware.py`) that reads the inbound `X-Request-ID` header, falls back to a fresh UUID4 when absent, stores the value on a `contextvars.ContextVar` (`request_id_var`), and echoes it back on the response. Pairs the middleware with a `RequestIDLogFilter` and wires it into `setup_logging` so every log record emitted during the request lifecycle -- including from `inference/pipeline.py` and helper modules that don't hold the FastAPI `Request` object -- carries `%(request_id)s` in the JSON log format. The middleware is registered last in `create_app` so it sits outermost in the stack, ensuring the ContextVar is set before any other middleware or route handler runs. Tests in `tests/test_request_id_middleware.py` cover the three cases the issue called out: - request without `X-Request-ID` -> response carries a UUID-shaped value - request with explicit `X-Request-ID` -> response echoes it back - log record emitted inside the handler exposes `record.request_id` Plus a leak test asserting the ContextVar resets after the response.
6dd365f to
cac30bc
Compare
|
Thanks for the careful read @femi23, and @Goldokpa thanks for the heads-up on the history rewrite. Pushed
Ready for re-review. |
Closes #44.
Why
AuditLogMiddlewarealready attaches a UUID torequest.state.request_id, but anything that runs in the request lifecycle without access to the FastAPIRequestobject - notablyinference/pipeline.pyand the helper modules below it - emits log lines that aren't correlated to the inbound HTTP request.What
Added in
src/climatevision/api/middleware.py:request_id_var: ContextVar[str | None]so non-FastAPI code can read the active request ID.RequestIDMiddleware(BaseHTTPMiddleware) that readsX-Request-ID(UUID4 fallback), stores it on the ContextVar, mirrors it ontorequest.state.request_idso the existingRequestLoggingMiddleware/AuditLogMiddlewarekeep working, and echoes the value back on the response. ContextVar isreset()in afinallyblock so requests don't leak.RequestIDLogFilter(logging.Filter)that exposes the ContextVar asrecord.request_idfor log formatters.setup_loggingnow installs the filter on the root logger and attaches it to existing handlers, and the JSON log format gains"request_id":"%(request_id)s".create_appregistersRequestIDMiddlewarelast inadd_middleware, which means starlette runs it OUTERMOST -- the ContextVar is set before any other middleware or route handler runs.Tests
tests/test_request_id_middleware.pycovers the three cases in the issue plus a leak check:X-Request-ID-> response has a UUID-shapedX-Request-IDand the route reads the same valueX-Request-ID: <id>-> response echoes the same value backrecord.request_idmatching the inbound id (verified via the filter on aStreamHandlerand pytest'scaplog)request_id_var.get()is back toNoneLocally:
reports 4 passed in 0.10s. (The repo's full conftest pulls the inference pipeline which needs GDAL/fiona; the new test file builds a minimal FastAPI app of its own and stays independent.)