Skip to content

[Python] Four defects in the generated retry path: retry-after-ms, Retry-After > 30s, transport errors, and a lost form body on retry #17358

Description

@ckoning

[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
4 of 4 reproduced
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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions