Skip to content

Stop retry multiplication on ApiModel.retryer replays (OpenAIModel, LiteLLMModel) and make is_rate_limit_error status-first - #3036

Closed
simin-yuan wants to merge 4 commits into
huggingface:mainfrom
simin-yuan:fix/openai-retry-stacking
Closed

simin-yuan wants to merge 4 commits into
huggingface:mainfrom
simin-yuan:fix/openai-retry-stacking

Conversation

@simin-yuan

@simin-yuan simin-yuan commented Oct 20, 2026 •

Copy link
Copy Markdown

Fixes #3034. Related: #3037.

ApiModel.retryer wraps a callable that already has its own retry loop underneath it. In both OpenAI-backed models that produced N×M requests for a single logical call, and the predicate that decides whether an error is worth retrying answered the wrong question.

What this branch does

  1. OpenAIModel (unchanged from the earlier revision): the retryer's replays go through a client copy with max_retries=0, so the two retry layers no longer multiply.

  2. LiteLLMModel (new): the same fix, one layer deeper. litellm.completion retries internally (DEFAULT_MAX_RETRIES = 2), so the retryer's replays now pass num_retries=0 while the first attempt keeps the client's own retries. Measured with tests/_manual/measure_retry_requests.py (a local server that answers 429 to every request), one generate() on 1.26.0 spends 9 requests / 872.11 s; with this change it spends 5 requests / 424.87 s (3 on the first attempt plus 1 per replay). Same server, same model id, default RETRY_WAIT — only the code changed.

    The measurement harness is rebuilt: the first run happened on a throwaway script that was not kept, so the script in this branch is a rewrite of it. Both numbers above come from that script on the two versions — re-run it to check them.

  3. is_rate_limit_error is status-first (new): it only inspected str(exception), so:

    • a 429 whose body says "quota" was not recognised as a rate limit, so on the same server response LiteLLMModel gave up after 19.1 s while OpenAIModel (named OpenAIServerModel in 1.26.0) kept retrying for 590.4 s — the only difference was the wording of the error message, and
    • a 500 whose body happened to contain the string "429" was classified as a rate limit and retried.

    A status code is now authoritative in both directions; the message heuristics only apply when the exception carries no status at all.

Coverage, explicitly

model class retry multiplication status-first predicate
OpenAIModel fixed (first revision) applies
LiteLLMModel fixed here applies
LiteLLMRouterModel inherits LiteLLMModel._completion_create applies
AzureOpenAIModel, InferenceClientModel, AmazonBedrockModel not addressed — no evidence of the same doubling applies where the predicate is used

Tests

Four new test functions, 11 test items after parametrization (8 predicate cases plus 3 single tests). Six of the 11 fail against the base commit — the three predicate cases that encode the bug (429 on a plain exception; 500 whose body mentions 429; 400 whose body says "rate limit"), the litellm.RateLimitError case, and both retry-accounting tests (LiteLLMModel passes num_retries=0 on replays; generate() actually routes through that path) — and all 11 pass with the change. Collection goes from 123 items on the base commit to 134 on this branch.

pytest tests/test_models.py: 120 passed, 6 failed, 7 skipped, 1 error. The six failures and the error are TransformersModel/MLXModel tests that need torch; they fail identically on the base commit (109 passed there — the 11 extra passes are the new items).

AI involvement

This patch was written with an AI assistant, under my direction. I have read the diff, the measurements above were run on this machine against the versions stated, and I will answer review comments.

Why one PR instead of two

Both are the same defect seen from two sides: a retry layer that decides "retry me" from a substring, and retry layers stacked on top of each other. Splitting them would leave the LiteLLM multiplication unfixed in a thread that is specifically about request multiplication. Happy to split if you would rather review them separately.

@simin-yuan simin-yuan changed the title OpenAIModel: disable client retries on ApiModel.retryer replays (fixes request multiplication) Stop retry multiplication on ApiModel.retryer replays (OpenAIModel, LiteLLMModel) and make is_rate_limit_error status-first Oct 30, 2026
@simin-yuan
simin-yuan force-pushed the fix/openai-retry-stacking branch from b2aa4f8 to d2af421 Compare October 30, 2026 17:25
On a rate limit, OpenAIModel sends 9 requests: ApiModel.retryer makes 3
attempts and the openai client inside each one keeps its default
max_retries=2.

This keeps the client's own retries on the first attempt and uses
self.client.with_options(max_retries=0) only on the retryer's replays, so a
429 costs 3 + 1 + 1 = 5 requests. 5xx errors, dropped connections and
timeouts are unchanged, because the retryer does not retry them and the
client's retries are their only retry.

Closes huggingface#3034.
Two ways one logical call still became more requests than it should.

LiteLLMModel had the same multiplication the OpenAIModel fix addresses: litellm retries
internally (DEFAULT_MAX_RETRIES=2) underneath smolagents' retryer, so a single call is up
to 9 requests. The retryer's replays now pass num_retries=0; the first attempt keeps the
client's own retries, matching the OpenAIModel change in this branch.

is_rate_limit_error only looked at str(exception), so a 429 whose body says "quota" was
not recognised (the retryer gave up on a retryable error) while a 500 whose body happened
to contain "429" was treated as a rate limit and retried nine times. A status code is now
authoritative in both directions; the message heuristics only apply when there is no status.
A local server that answers 429 to every request, plus the count of requests one
generate() call spends. Run by hand; it is not collected by pytest.
@simin-yuan
simin-yuan force-pushed the fix/openai-retry-stacking branch from d2af421 to 122151a Compare October 30, 2026 17:25

Copy link
Copy Markdown

AI-assisted, source-only review of 122151a; I haven't run the measurement.

One small reproducibility-label issue: line 70 searches smolagents.__file__, which points to __init__.py, while _completion_create lives in models.py. The package initializer contains no such string, so this patched checkout still records num_retries_patched: false.

Could this inspect LiteLLMModel directly (for example, hasattr(LiteLLMModel, "_completion_create")) or the models module instead? This concerns the metadata label; I haven't independently verified the reported request counts.

@simin-yuan

simin-yuan commented Oct 30, 2026 •

Copy link
Copy Markdown
Author

Thanks, you're right, and it was worse than the label. smolagents.__file__ is src/smolagents/__init__.py, so the check could never find the string. My patched checkout recorded false, and so did 1.26.0. The field told you nothing either way.

Fixed in a29682f (re-committed as a8778c8 to correct the author email; the code is unchanged). The script now inspects the closure returned by LiteLLMModel._completion_create and checks that num_retries appears among the closure's code constants. A plain hasattr would only show that the method exists, not that replays stop retrying. If the method is renamed upstream the check falls back to false; since this is a manual script I've left it at that.

With the fixed script the two sides come out as:

  • this branch: 5 requests, num_retries_patched: true
  • 1.26.0: 9 requests, num_retries_patched: false

The request counts in the PR description are not affected. The local server logs one line per request, and the count is read after generate() returns. The label is computed after that and never touches the server. Counts were identical before and after the change.

The same commit also fixes ruff errors this file already had (blank lines and line wrapping, no logic change).

Copy link
Copy Markdown

Thanks. Source review of a29682f confirms the initializer-path mismatch I flagged is addressed. I haven't rerun the measurement; the 5-versus-9 request counts remain your validation. No further concern on that specific metadata issue.

…nit__

`smolagents.__file__` is `src/smolagents/__init__.py`, and `_completion_create` is defined
in `models.py`, so the label was false on this branch as well: 1.26.0 and this branch both
recorded `num_retries_patched: false`, which makes the field carry no information.

The label now asks the object the fix lives on: it checks that `num_retries` appears among
the code constants of the closure returned by `LiteLLMModel._completion_create`. A bare
`hasattr` would not tell a method that exists from a method that still retries.

Re-run with the fixed label:

- this branch: 5 requests, `num_retries_patched: true`
- 1.26.0 from PyPI: 9 requests, `num_retries_patched: false`

The file was also not ruff-clean (`quality.yml` runs `ruff check examples src tests`), so
the blank lines and line wrapping it asks for are applied here too; the reformatting is
whitespace only.
@simin-yuan
simin-yuan force-pushed the fix/openai-retry-stacking branch from a29682f to a8778c8 Compare October 30, 2026 18:43
@simin-yuan

Copy link
Copy Markdown
Author

Thanks for the re-read. The request counts stay on me to validate — I'll post the measured numbers here once I have them, rather than leave the claim standing on the source argument alone.

@simin-yuan
simin-yuan force-pushed the fix/openai-retry-stacking branch from a8778c8 to fa9758a Compare November 6, 2026 21:18
@simin-yuan

Copy link
Copy Markdown
Author

Numbers, as promised — and where they come from.

tests/_manual/measure_retry_requests.py on this branch (a local server that answers 429 to every request, default RETRY_WAIT), one LiteLLMModel.generate() on openai/gpt-4o-mini:

checkout requests wall clock
smolagents 1.26.0 (base) 9 872.11 s
this branch 5 424.87 s

5 = 3 on the first attempt (client retries) + 1 per replay, with num_retries=0 on the replays so litellm does not stack another layer. Same server, same model id, only the code changed.

Honest scope: these are the numbers recorded when the branch was written; I have not re-run the script today, and the harness in the branch is a rewrite of the throwaway script used for the first run (noted in the PR body). The command is python tests/_manual/measure_retry_requests.py; it writes requests.jsonl and result.json next to the working directory, so the request count is auditable rather than self-reported. If you want a fresh run posted here, say so and I'll attach the raw JSONL.

The predicate half is independent of these counts: the status-first tests encode the three message-only cases (429 with no status, 500 whose body says "429", 400 whose body says "rate limit") and fail on the base commit.

@albertvillanova

Copy link
Copy Markdown
Member

Thank you for this pull request.

Under our contribution guidelines, a pull request needs to link an issue that a maintainer has assigned to its author before we review it. Assignment is how we decide what gets worked on and by whom, and it keeps the review queue at a size we can actually handle.

This PR links #3034, which is not assigned to you, so I am closing the PR for now. If we decide we want this change and assign it to you, comment here and we will reopen this PR as it is: please do not open a new one. Closing it is not a judgement on the code.

@albertvillanova albertvillanova added the needs-assigned-issue Closed: the PR does not link an issue assigned to its author (see CONTRIBUTING.md) label Nov 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-assigned-issue Closed: the PR does not link an issue assigned to its author (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAIModel: client retries and ApiModel retries multiply (9 requests per rate limit)

3 participants