Skip to content

Survive a rate limit instead of losing the task to it - #92

Merged
tarekziade merged 2 commits into
mainfrom
fix/rate-limit-throttling
Aug 18, 2026
Merged

Survive a rate limit instead of losing the task to it#92
tarekziade merged 2 commits into
mainfrom
fix/rate-limit-throttling

Conversation

@tarekziade

Copy link
Copy Markdown
Collaborator

Why

One 429 ended the deepseek_vl task on 2026-08-18 after it had already spent 1.13M input tokens (transformers#48050):

LLM endpoint returned 429 Too Many Requests: {"error":"Rate limit exceeded"}

Two causes, both fixed here.

A 429 shared the error budget. attempts = 3 covered 5xx, dropped connections and rate limits, with 2s/4s backoff — about six seconds of patience before throwing away half an hour of finished work. But they are not alike: a server error may never clear, while a rate limit clears by waiting. Rate limits now get their own, larger budget; a persistent 5xx still fails fast at 3.

Retrying the rejected call isn't enough. The loop that tripped the limit is about to issue the same burst of tool turns, so the next call gets refused too. The client now paces itself.

The numbers come from the server, not from me

  • the retry waits what Retry-After says; failing that, what the x-ratelimit-reset family says; exponential backoff is only the last resort for an endpoint that says nothing;
  • that same value sets the spacing for the calls that follow;
  • on a successful response carrying x-ratelimit-remaining-requests, the spacing spreads the remaining allowance over the window it resets in — so the loop stays under the limit instead of discovering it by being refused. Only headroom the server reports as tight (<10 requests left) slows anything down.

Reset values are parsed in the forms providers actually send: plain seconds, 6m0s / 300ms durations, and absolute epochs (a deadline, not a 1.7-billion-second wait).

Guard rails

  • Spacing decays on success, so a loop that hit one transient 429 returns to full speed rather than limping forever.
  • An endpoint that never rate-limits us is never paced — asserted by a test.
  • Every wait stays bounded by the existing per-wait ceiling: a server is allowed to be wrong or hostile, and no single wait should pin a worker.
  • Pacing is per-client, i.e. per task pod. Cross-pod coordination would need shared state; this fixes the burst within a loop, which is the shape that failed.

675 tests pass (11 new), make format clean.

🤖 Generated with Claude Code

tarekziade and others added 2 commits August 18, 2026 13:52
A dispatcher often knows who should look at a fix and had no way to say
so. The integration-failure triage is the case in point: when CI's bisect
pins a regression to a commit it knows that commit's author, but the fix
PR opened with no reviewer request, so the one person who knows what the
change was meant to do had to notice it by chance.

Accept an optional `reviewers` list of GitHub logins on POST /tasks and
request their review after the PR goes draft->ready. serge stays generic:
it forwards the request and holds no opinion about who is relevant.

Details that matter:

- Requested AFTER the draft->ready transition, so it ADDS to whatever the
  repo's own routing (transformers' assign-reviewers.yml) does rather
  than racing it.
- New PRs only. An existing_pr follow-up pushes onto a branch whose
  review is already requested, and re-requesting resets a review the
  author may already have given.
- Fail-soft: GitHub 422s the whole call for reasons that are none of the
  PR's business (the login is not a collaborator, it is the PR's own
  author, it no longer exists). The PR is published by then, so
  request_reviewers logs and swallows everything, including transport
  errors, and returns the logins it actually requested.
- Malformed entries are dropped rather than raising, same contract as
  test_links: a review request is a courtesy on top of the fix and must
  never cost a PR. Logins are validated against GitHub's charset, which
  also drops bots (`dependabot[bot]`) — a bot cannot review, and one bad
  login 422s the whole request, taking the valid ones with it. Deduped
  case-insensitively and capped at 10 (GitHub's own limit is 15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One 429 ended the `deepseek_vl` task on 2026-08-18 after it had already
spent 1.13M input tokens (transformers#48050). Two reasons, both fixed
here.

A 429 shared the 3-attempt budget with 5xx and dropped connections, so
three quick tries — about six seconds of patience — threw away half an
hour of finished work. But the two failures are not alike: a server error
may never clear, while a rate limit clears BY WAITING. Rate limits now
get their own, larger budget; a persistent 5xx still fails fast at 3.

Retrying the rejected call was also not enough on its own: the loop that
tripped the limit is about to issue the same burst of tool turns. So the
client now paces itself, and takes the numbers from the server rather
than inventing them:

- the retry waits what `Retry-After` says, else what the
  `x-ratelimit-reset` family says, and only falls back to exponential
  backoff when the endpoint says nothing;
- the same value sets the spacing for the calls that FOLLOW;
- on a successful response carrying `x-ratelimit-remaining-requests`,
  the spacing spreads the remaining allowance over the window it resets
  in — which keeps the loop under the limit instead of discovering it by
  being refused. Only headroom the server reports as tight (<10 left)
  slows anything down.

Spacing decays on success, so a loop that hit one transient 429 returns
to full speed rather than limping. An endpoint that never rate-limits us
is never paced, and every wait stays bounded by the existing per-wait
ceiling — a server is allowed to be wrong or hostile.

Reset values are parsed in the forms providers actually send: plain
seconds, `6m0s`/`300ms` durations, and absolute epochs (a deadline, not a
1.7-billion-second wait).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tarekziade
tarekziade merged commit 274c838 into main Aug 18, 2026
3 checks passed
@tarekziade
tarekziade deleted the fix/rate-limit-throttling branch August 18, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant