[Python] Four defects in the generated retry path
fern-python-sdk 4.31.0 · fern-api 5.90.1 · Python 3.12 · httpx 0.28.1
All four are in core/http_client.py, which is emitted unchanged into every
generated Python SDK. All four are reproduced by execution in the attached
script; it exits non-zero while they are present.
Related: #17337 reports a fifth defect in the same file — max_retries is off by
two, so max_retries=2 performs no retries at all. Filing these separately since
they are independent, but the retry path may be worth one review pass.
Net effect together: of the three ways a server can ask a client to back off —
Retry-After in seconds, retry-after-ms, and simply failing to connect — only
a Retry-After of 30 seconds or less works.
1. retry-after-ms is never honoured
retry_after_ms = response_headers.get("retry-after-ms")
if retry_after_ms is not None:
try:
return int(retry_after_ms) / 1000 if retry_after_ms > 0 else 0
except Exception:
pass
httpx.Headers.get returns str. The conditional retry_after_ms > 0 is
evaluated before int(...), so it is str > int, which raises TypeError —
swallowed by the bare except. The header can never take effect.
retry-after-ms: 250 -> 0.42s (exponential backoff; expected 0.25s)
Fix: convert first — value = int(retry_after_ms); return value / 1000 if value > 0 else 0.
2. A Retry-After above 30s is replaced by a shorter delay
MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30
MAX_RETRY_DELAY_SECONDS = 10
retry_after = _parse_retry_after(response.headers)
if retry_after is not None and retry_after <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER:
return retry_after
retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS)
When the header exceeds 30s it is discarded and the fallback applies — capped at
10s. So the cap makes the client wait less the more the server asks for.
Retry-After: 29s -> 29.0s
Retry-After: 31s -> 0.48s <-- ignored
Retry-After: 60s -> 0.41s <-- ignored
ClickUp sends Retry-After: 60 on a 429, so its rate-limit instruction is
ignored entirely and the client retries roughly 150× sooner than asked.
Fix: clamp rather than discard — return min(retry_after, MAX_RETRY_DELAY_SECONDS_FROM_HEADER).
Whatever the ceiling, exceeding it should never yield a shorter wait than obeying it.
3. Connection errors and timeouts are never retried
_should_retry(response: httpx.Response) decides eligibility purely from a
response, and there is no try/except around the httpx call in request(). A
ConnectError, ReadTimeout or RemoteProtocolError propagates on the first
attempt regardless of max_retries.
These are the failures a retry policy most exists to survive, and the ones a
caller setting max_retries is most likely to have in mind.
Fix: catch httpx.TransportError around the request and route it through the
same attempt counter as a retryable status.
4. A retried request loses its form body and its multipart flag
def request(self, ..., data=None, ..., force_multipart=None, ...):
...
return self.request(
..., # json=, files=, headers=, params= are forwarded
retries=retries + 1, # data= and force_multipart= are NOT
)
Introspection of the recursive call against the signature:
in signature but NOT forwarded: ['data', 'force_multipart']
A form-encoded or multipart request that gets retried is re-sent without its
body. Silent: the retry succeeds at the transport level and the server sees an
empty request. Live for any SDK with a file-upload or form endpoint.
Fix: forward data=data and force_multipart=force_multipart in both the
sync and async request().
Reproduction
npx fern-api@5.90.1 generate --local --group local
uv run --with httpx --with pydantic --with typing_extensions python repro.py
repro.py — self-contained, no network
"""Four defects in the generated retry path. No network: httpx.MockTransport throughout.
npx fern-api@5.90.1 generate --local --group local
uv run --with httpx --with pydantic --with typing_extensions python repro.py
Everything below reads `sdk/core/http_client.py`, which fern-python-sdk emits
unchanged into every generated Python SDK.
"""
import inspect
import re
import sys
import httpx
sys.path.insert(0, "sdk")
from sdk.core import http_client as hc # noqa: E402
FAIL = 0
def check(name, got, want, note=""):
global FAIL
ok = got == want
FAIL += 0 if ok else 1
print(f" [{'ok ' if ok else 'BUG'}] {name}\n got {got!r}, expected {want!r}{' — ' + note if note else ''}")
def delay(headers):
response = httpx.Response(429, headers=headers, request=httpx.Request("GET", "http://x"))
return round(hc._retry_timeout(response=response, retries=0), 3)
print(f"MAX_RETRY_DELAY_SECONDS_FROM_HEADER = {hc.MAX_RETRY_DELAY_SECONDS_FROM_HEADER}")
print(f"MAX_RETRY_DELAY_SECONDS = {hc.MAX_RETRY_DELAY_SECONDS}\n")
print("1. retry-after-ms is never honoured")
print(" _parse_retry_after does `retry_after_ms > 0` before int(), on a str from")
print(" httpx.Headers.get, inside a bare `except Exception: pass`.")
got = delay({"retry-after-ms": "250"})
check("retry-after-ms: 250", got, 0.25, "returned exponential backoff instead")
print("\n2. A Retry-After above 30s is discarded for a SHORTER delay")
print(" The header is used only when <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER (30),")
print(" and the fallback is capped at MAX_RETRY_DELAY_SECONDS (10) — so asking for")
print(" more than 30s gets you at most 10s. The cap makes the wait shorter, not longer.")
for header in ("5", "29", "31", "60"):
got = delay({"Retry-After": header})
print(f" Retry-After: {header:>3}s -> {got}s" + ("" if got == float(header) else " <-- ignored"))
check("a 60s Retry-After produces a wait of at least 30s", delay({"Retry-After": "60"}) >= 30, True,
"the server's own backoff instruction is replaced by a shorter delay")
print("\n3. Connection errors and timeouts are never retried")
src = inspect.getsource(hc.HttpClient.request)
check("request() wraps the httpx call in try/except", "except httpx" in src, True,
"_should_retry takes an httpx.Response, so a raised ConnectError cannot reach it")
print("\n4. A retried request loses its form body and its multipart flag")
call = src[src.index("return self.request("):]
forwarded = set(re.findall(r"(\w+)=", call[: call.index(")")]))
missing = sorted(set(inspect.signature(hc.HttpClient.request).parameters) - {"self"} - forwarded)
check("every request() parameter is forwarded on retry", missing, [],
"a retried multipart or form request arrives without its body")
print(f"\n{FAIL} of 4 reproduced" if FAIL else "\nnothing reproduced — already fixed?")
sys.exit(1 if FAIL else 0)
openapi.json — one endpoint, no auth
{
"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
[Python] Four defects in the generated retry path
fern-python-sdk4.31.0 ·fern-api5.90.1 · Python 3.12 · httpx 0.28.1All four are in
core/http_client.py, which is emitted unchanged into everygenerated Python SDK. All four are reproduced by execution in the attached
script; it exits non-zero while they are present.
Related: #17337 reports a fifth defect in the same file —
max_retriesis off bytwo, so
max_retries=2performs no retries at all. Filing these separately sincethey are independent, but the retry path may be worth one review pass.
Net effect together: of the three ways a server can ask a client to back off —
Retry-Afterin seconds,retry-after-ms, and simply failing to connect — onlya
Retry-Afterof 30 seconds or less works.1.
retry-after-msis never honouredhttpx.Headers.getreturnsstr. The conditionalretry_after_ms > 0isevaluated before
int(...), so it isstr > int, which raisesTypeError—swallowed by the bare
except. The header can never take effect.Fix: convert first —
value = int(retry_after_ms); return value / 1000 if value > 0 else 0.2. A
Retry-Afterabove 30s is replaced by a shorter delayWhen the header exceeds 30s it is discarded and the fallback applies — capped at
10s. So the cap makes the client wait less the more the server asks for.
ClickUp sends
Retry-After: 60on a 429, so its rate-limit instruction isignored entirely and the client retries roughly 150× sooner than asked.
Fix: clamp rather than discard —
return min(retry_after, MAX_RETRY_DELAY_SECONDS_FROM_HEADER).Whatever the ceiling, exceeding it should never yield a shorter wait than obeying it.
3. Connection errors and timeouts are never retried
_should_retry(response: httpx.Response)decides eligibility purely from aresponse, and there is no
try/exceptaround the httpx call inrequest(). AConnectError,ReadTimeoutorRemoteProtocolErrorpropagates on the firstattempt regardless of
max_retries.These are the failures a retry policy most exists to survive, and the ones a
caller setting
max_retriesis most likely to have in mind.Fix: catch
httpx.TransportErroraround the request and route it through thesame attempt counter as a retryable status.
4. A retried request loses its form body and its multipart flag
Introspection of the recursive call against the signature:
A form-encoded or multipart request that gets retried is re-sent without its
body. Silent: the retry succeeds at the transport level and the server sees an
empty request. Live for any SDK with a file-upload or form endpoint.
Fix: forward
data=dataandforce_multipart=force_multipartin both thesync and async
request().Reproduction
npx fern-api@5.90.1 generate --local --group local uv run --with httpx --with pydantic --with typing_extensions python repro.pyrepro.py— self-contained, no networkopenapi.json— one endpoint, no auth{ "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