Skip to content

[Python] max_retries is off by two — max_retries=2 performs no retries #17337

Description

@ckoning

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.

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