Summary
HttpClient.request uses retries as its recursion counter but defaults it to 2, then guards the retry with max_retries > retries. The counter therefore starts two ahead of zero, so RequestOptions(max_retries=N) performs N - 2 retries instead of N.
max_retries=1 and max_retries=2 perform no retries at all, silently.
Versions
fern-api 5.90.1
fernapi/fern-python-sdk 4.31.0
- Python 3.12, httpx 0.28.1
Observed
max_retries |
attempts |
expected |
| 0 |
1 |
1 |
| 1 |
1 |
2 |
| 2 |
1 |
3 |
| 3 |
2 |
4 |
| 4 |
3 |
5 |
| 5 |
4 |
6 |
429 is retryable — _should_retry returns True for it, confirmed by instrumenting the function — so the responses are eligible; the guard is what rejects them.
Cause
core/http_client.py, in both HttpClient.request and AsyncHttpClient.request:
def request(
self,
...
retries: int = 2, # <-- recursion counter, but starts at 2
...
):
...
max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0
if _should_retry(response=response):
if max_retries > retries: # with retries=2, needs max_retries >= 3
time.sleep(_retry_timeout(response=response, retries=retries))
return self.request(
...
retries=retries + 1,
...
)
retries is incremented on each recursion, so it is a depth counter and its base case should be 0. At 2, the first comparison is max_retries > 2.
Secondary effect: _retry_timeout(response, retries) computes INITIAL_RETRY_DELAY_SECONDS * 2**retries, so the first backoff is also calculated as though two retries had already happened — 0.5 * 2**2 = 2.0s rather than 0.5s.
Suggested fix
- retries: int = 2,
+ retries: int = 0,
in both the sync and async request methods. That makes max_retries=N perform N retries and starts the backoff at INITIAL_RETRY_DELAY_SECONDS.
Reproduction
Self-contained — one endpoint, no auth, no network. An httpx.MockTransport counts attempts and always answers 429.
openapi.json
{
"openapi": "3.0.0",
"info": { "title": "Retry Repro", "version": "1.0.0" },
"servers": [{ "url": "https://api.example.com" }],
"paths": {
"/ping": {
"get": {
"operationId": "ping",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } }
}
}
}
}
}
}
}
}
fern/generators.yml
api:
specs:
- openapi: ../openapi.json
default-group: local
groups:
local:
generators:
- name: fernapi/fern-python-sdk
version: 4.31.0
output:
location: local-file-system
path: ../sdk
repro.py
import sys
import httpx
sys.path.insert(0, ".")
from sdk.client import RetryReproApi
from sdk.core.api_error import ApiError
def attempts_for(max_retries: int) -> int:
calls = {"n": 0}
def always_429(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
# Retry-After: 0 so the run is instant.
return httpx.Response(429, json={"err": "rate limited"}, headers={"Retry-After": "0"})
client = RetryReproApi(
base_url="https://api.example.com",
httpx_client=httpx.Client(transport=httpx.MockTransport(always_429)),
)
try:
client.ping(request_options={"max_retries": max_retries})
except ApiError:
pass
return calls["n"]
print(f"{'max_retries':>12} | {'attempts':>8} | {'expected':>8}")
print("-" * 36)
failures = 0
for max_retries in range(6):
actual = attempts_for(max_retries)
expected = max_retries + 1
if actual != expected:
failures += 1
flag = "" if actual == expected else " <-- wrong"
print(f"{max_retries:>12} | {actual:>8} | {expected:>8}{flag}")
raise SystemExit(1 if failures else 0)
Run
npx fern-api@5.90.1 init --openapi openapi.json --organization retry-repro
# replace fern/generators.yml with the block above
npx fern-api@5.90.1 generate --local --group local
uv run --with httpx --with pydantic --with typing_extensions python repro.py
Exits non-zero while the bug is present, so it doubles as a regression check.
Why it is easy to miss
The failure is silent and in the safe direction — fewer requests, not more — so it does not surface as an error. A caller who sets max_retries=2 and never counts attempts will believe retries are enabled while every transient failure propagates on the first try.
Workaround
Add 2 to whatever you mean:
def retry_options(attempts: int) -> RequestOptions:
return RequestOptions(max_retries=attempts + 2 if attempts else 0)
Searched existing issues before filing: #13741 is Python + max_retries but a different symptom (empty {} body on GETs), and #8458 is a Rust feature request. Happy to open a PR for the one-line change if that is useful.
Summary
HttpClient.requestusesretriesas its recursion counter but defaults it to2, then guards the retry withmax_retries > retries. The counter therefore starts two ahead of zero, soRequestOptions(max_retries=N)performsN - 2retries instead ofN.max_retries=1andmax_retries=2perform no retries at all, silently.Versions
fern-api5.90.1fernapi/fern-python-sdk4.31.0Observed
max_retries429 is retryable —
_should_retryreturnsTruefor it, confirmed by instrumenting the function — so the responses are eligible; the guard is what rejects them.Cause
core/http_client.py, in bothHttpClient.requestandAsyncHttpClient.request:retriesis incremented on each recursion, so it is a depth counter and its base case should be0. At2, the first comparison ismax_retries > 2.Secondary effect:
_retry_timeout(response, retries)computesINITIAL_RETRY_DELAY_SECONDS * 2**retries, so the first backoff is also calculated as though two retries had already happened —0.5 * 2**2 = 2.0srather than0.5s.Suggested fix
in both the sync and async
requestmethods. That makesmax_retries=NperformNretries and starts the backoff atINITIAL_RETRY_DELAY_SECONDS.Reproduction
Self-contained — one endpoint, no auth, no network. An
httpx.MockTransportcounts attempts and always answers 429.openapi.json{ "openapi": "3.0.0", "info": { "title": "Retry Repro", "version": "1.0.0" }, "servers": [{ "url": "https://api.example.com" }], "paths": { "/ping": { "get": { "operationId": "ping", "responses": { "200": { "description": "ok", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } } } } } } }fern/generators.ymlrepro.pyRun
Exits non-zero while the bug is present, so it doubles as a regression check.
Why it is easy to miss
The failure is silent and in the safe direction — fewer requests, not more — so it does not surface as an error. A caller who sets
max_retries=2and never counts attempts will believe retries are enabled while every transient failure propagates on the first try.Workaround
Add 2 to whatever you mean:
Searched existing issues before filing: #13741 is Python +
max_retriesbut a different symptom (empty{}body on GETs), and #8458 is a Rust feature request. Happy to open a PR for the one-line change if that is useful.