fix(chat): honor provider Retry-After headers on rate limits - #1705
Conversation
The header branch in RetryAfterMiddleware gated on isinstance(headers, dict), but litellm rebuilds every error's response as an httpx.Response, whose .headers is httpx.Headers -- a Mapping that is not a dict subclass. The branch never ran, so the middleware fell through to its message regex and slept its own exponential backoff while the provider had already said how long to wait. Also cap the header-derived delay at max_delay. The retry loop runs inside the live chat turn, so an unvalidated retry-after-ms would hold the SSE stream, the thread's busy lock and the DB session open for its full duration.
|
@Yigtwxx is attempting to deploy a commit to the Rohan Verma's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Reading the red checks, since none of them come from this diff.
Green: Unit Tests, Integration Tests, Test Gate, Backend Quality, File Quality, Security Scan, CodeRabbit. Update — both reds now have fixes rather than just explanations.
Neither is a dependency of this PR; merging them first is just what turns this board green. |
RetryAfterMiddlewarewas written to obey a provider'sRetry-Afterheader instead of guessing with exponential backoff. The branch that reads the header is gated onisinstance(headers, dict), but the header object litellm actually produces ishttpx.Headers— aMappingthat is not adictsubclass — so that branch has never executed in production.Description
Two changes in
app/agents/chat/shared/middleware/retry_after.py:_extract_retry_after_secondsnow testsisinstance(headers, Mapping)instead ofisinstance(headers, dict)._delay_for_attemptcaps the resulting delay atmax_delay, which previously bounded only the exponential backoff.Symptom
A provider returns
429withRetry-After: 45. SurfSense ignores it, sleeps its own1s / 2s / 4sbackoff, burns all three retries inside roughly seven seconds, and fails the turn — the exact behaviour the module docstring says it was written to replace.Root cause
_extract_retry_after_secondsreadsexc.response.headersand then gates onisinstance(headers, dict).litellm.exceptions.RateLimitError.__init__rebuilds the error's response unconditionally:so
.headersis alwayshttpx.Headers, which subclassesMutableMapping, notdict. Measured against the versions this repo pins:The models are driven through
ChatLiteLLM(app/agents/chat/runtime/llm_config.py,app/services/llm_service.py), so this is the only shape the middleware ever sees. With the branch skipped, the function falls through to its message regex, which does not match litellm's message ("litellm.RateLimitError: ..."), and returnsNone.The existing tests did not catch this because their fixture builds the headers as a plain
dict[str, str](tests/unit/agents/new_chat/test_retry_after.py), so they exercise a branch production never reaches.The second defect, and why it belongs in the same PR
_delay_for_attemptdidreturn max(backoff, header).max_delayis documented as "Cap on per-attempt delay in seconds" but only ever constrainedbackoff. That was harmless whileheaderwas permanently0.0; fixing the first defect makes it reachable. Aretry-after-ms: 3600000from a misconfigured gateway would becomeawait asyncio.sleep(3600), and this loop runs inside the live chat turn — it holds the SSE stream, the thread's busy-mutex lock and the DB session for the whole sleep. The delay is nowmin(max(backoff, header), self.max_delay).Motivation and Context
No linked issue — found while auditing the retry path. The module's entire reason to exist is header-aware retry, and that path was dead.
Screenshots
Not applicable — no UI change.
API Changes
Change Type
Testing Performed
Three tests were added to
tests/unit/agents/new_chat/test_retry_after.py. Two build a reallitellm.exceptions.RateLimitErrorrather than a hand-rolled fake, so they cover the shape production raises; the third pins the cap.Run against the current
devcode, before the fix:After:
No existing test changed.
test_takes_max_of_backoff_and_headeruses a 10s header under the default 60s cap, so the new clamp does not alter its result.Full unit suite, before and after the change:
11 failed, 2997 passed, 1 skipped. The eleven are pre-existing ondev(git-tree and knowledge-store tests plustest_pat_fail_closed_static) and reproduce on a cleandevcheckout on this machine — they are unrelated to this diff.ruff checkandruff format --checkare clean on both changed files.What does not change
_NON_RETRYABLE_CATEGORIES,_is_non_retryableand theretry_onhook are untouched.max_delaydefault of 60s.surfsense.retryingcustom event and its payload._extract_retry_after_secondsstill returnsNoneand the backoff is used unchanged.Remaining risk
Turns can now take longer, because a hint that was previously discarded is obeyed. That is the intended behaviour, and
max_delayis its ceiling. I did not change the default cap — 60s is the existing value and picking a different one is a product call.Checklist
High-level PR Summary
This PR fixes a bug in the retry middleware that prevented it from honoring provider
Retry-Afterheaders. TheRetryAfterMiddlewarewas designed to respect rate limit hints from LLM providers but has never worked in production because it checked fordictheaders whenlitellmactually returnshttpx.Headers(aMappingthat is not adictsubclass). The fix changes the type check fromisinstance(headers, dict)toisinstance(headers, Mapping)so the header-reading branch can execute. Additionally, themax_delaycap now applies to provider-supplied delays as well as exponential backoff to prevent extremely long sleep periods from blocking the chat turn, SSE stream, and database session.⏱️ Estimated Review Time: 5-15 minutes
💡 Review Order Suggestion
surfsense_backend/app/agents/chat/shared/middleware/retry_after.pysurfsense_backend/tests/unit/agents/new_chat/test_retry_after.py