Don't fail the request with a bare 500 on transient JWT refresh errors - #70057
Don't fail the request with a bare 500 on transient JWT refresh errors#70057kjh0623 wants to merge 4 commits into
Conversation
JWTRefreshMiddleware runs outside the FastAPI exception handlers and only caught HTTPException / AuthManagerRefreshTokenExpiredException. Any other exception raised while resolving or refreshing the user - notably an OperationalError when the metadata DB connection is recycled underneath the API server - propagated to the ASGI layer and surfaced as a bare HTTP 500, even for requests that did not need the refresh at all. Treat such errors as "could not refresh this time": log a warning, proceed without injecting the user (the route's own auth dependency revalidates the token), and leave the cookie untouched so a later request refreshes once the backend recovers. HTTPException / token-expiry semantics are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SameerMesiah97
left a comment
There was a problem hiding this comment.
I think there is a disconnect between the stated motivation behind this PR and the actual code. You appear to want to hande transient infrastructure failures (e.g. dropped DB connections), but the imeplementation and tests appear to broaden this to any exception from _refresh_user(). Is that intentional?
|
Good question — the broad catch is intentional, but you're right that the observability trade-off deserves a callout. Why Why failing open is safe regardless of the exception type: the refresh is a best-effort, proactive step, not the authoritative auth check. If it doesn't inject a user, the route's own auth dependency revalidates the token exactly as it does today when the middleware chooses not to refresh. So a failure here is never a security downgrade — it just means "no proactive refresh this time," identical to the existing no-refresh path. On the part I think is fair — masking programming errors: they aren't silently swallowed (the traceback is logged with Would that address the concern, or would you rather I narrow the catch despite the coupling/coverage downsides above? |
Failing open on an unexpected error keeps the request alive, but it must not make a genuine bug in the auth manager quieter than the bare 500 it replaces. WARNING is easy to miss and a monitor keyed on 5xx would go silent on a real regression, so switch the unexpected arm to log.exception() - ERROR level with the traceback - while transient blips stay non-fatal (review feedback from @SameerMesiah97). Signed-off-by: kjh0623 <8412070+kjh0623@users.noreply.github.com>
Signed-off-by: kjh0623 <8412070+kjh0623@users.noreply.github.com>
Problem
JWTRefreshMiddleware.dispatchruns outside the FastAPI/Starlette exception handlers, and its try/except only catchesHTTPExceptionandAuthManagerRefreshTokenExpiredException. Any other exception raised while resolving/refreshing the user propagates to the ASGI layer and surfaces as a bare HTTP 500 — for a request that may not have needed the refresh at all.The concrete way we hit this in production: PgBouncer recycles a backend connection underneath the API server, and the next request's
_refresh_user()→resolve_user_from_token()→ user lookup raisessqlalchemy.exc.OperationalError("server closed the connection unexpectedly"). The UI shows "Internal Server Error"; a manual refresh usually succeeds. Related reports of the same pain point: #57859, discussion #59487. (#62153 fixed the poisoned-session cascade after such a drop; this PR addresses the request that hits the error itself.)Fix
Add a final
except Exceptionarm around the refresh block that treats the failure as "could not refresh this time":HTTPException/ token-expiry semantics are unchanged — those still clear the cookie via the existing arm.Testing
New unit test:
_refresh_userraising a non-HTTP exception → request completes with the downstream response, noset-cookieheader, warning logged. Existing middleware tests unchanged and passing.We have been running the equivalent of this change in production (as a patch on 3.1.8) on a deployment serving a ~3,600-DAG instance; the recurring bare 500s on the auth path disappeared.
Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code following the guidelines
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.