Skip to content

BackendSpanExporter drops the whole batch on HTTP 429 instead of retrying with Retry-After #5023

Description

@coderdailyone

Please read this first

Describe the bug

BackendSpanExporter._export_with_deadline() treats every 4xx response as final:

# If the response is a client error (4xx), we won't retry
if 400 <= response.status_code < 500:
    logger.error("[non-fatal] Tracing client error %s: %s", ...)
    break

That includes 429 Too Many Requests. A single rate-limited response from /v1/traces/ingest discards the whole batch, up to max_batch_size (128) traces and spans, with no retry and without looking at Retry-After. The run itself succeeds, so the only symptom is a gap in the dashboard and one [non-fatal] log line. The same applies to 408 and 409, which the OpenAI Python client (_base_client._should_retry) retries together with 429 and 5xx; the exporter only mirrors the 5xx half of that policy.

The export path already has everything needed to retry: max_retries, base_delay, max_delay, the backoff loop, and agents.models._retry_runtime.parse_retry_after_value / parse_retry_after_ms for the header.

Debug information

  • Agents SDK version: main at fbf59a40 (also 0.22.0)
  • Python version: 3.10.12
  • Operating system: Linux
  • Model and model provider: none needed; httpx2.Client is patched
  • Does the issue reproduce with the latest Agents SDK release? Yes
  • Does the issue occur consistently or intermittently? Consistently for any 429
ERROR:openai.agents:[non-fatal] Tracing client error 429. Response data is redacted.
post calls: 1

Repro steps

import logging
from unittest.mock import MagicMock, patch

from agents.tracing.processors import BackendSpanExporter
from agents.tracing.spans import SpanImpl
from agents.tracing.span_data import CustomSpanData

logging.basicConfig(level=logging.WARNING)

with patch("httpx2.Client") as client:
    limited = MagicMock(status_code=429, headers={"retry-after": "1"})
    limited.text = '{"error": {"message": "Rate limit reached"}}'
    ok = MagicMock(status_code=200)
    client.return_value.post.side_effect = [limited, ok]

    exporter = BackendSpanExporter(api_key="sk-test", max_retries=3, base_delay=0.1)
    span = SpanImpl(
        trace_id="trace_1",
        span_id="span_1",
        parent_id=None,
        processor=MagicMock(),
        span_data=CustomSpanData(name="probe", data={}),
        tracing_api_key=None,
    )
    exporter.export([span])
    print("post calls:", client.return_value.post.call_count)  # 1: the batch is gone

Expected behavior

408, 409 and 429 are retried with the existing backoff up to max_retries, waiting at least the advertised Retry-After / retry-after-ms (bounded by max_delay), the way the OpenAI client and the SDK's own model retry helpers already treat these statuses. Other 4xx responses stay non-retried. In the script above the second call succeeds and post calls is 2.

A fix with regression tests is ready and I will attach it right away.

Activity

  1. coderdailyone commented on Sep 14, 2026

    @coderdailyone
    ContributorAuthor

    Fix with regression tests is ready on my fork: coderdailyone@2b265051 (branch fix/tracing-retry-rate-limit, based on fbf59a40).

    • 408, 409 and 429 now go through the existing backoff loop up to max_retries, mirroring the OpenAI client's _should_retry; the wait honours Retry-After / retry-after-ms via the shared _retry_runtime parsers (lazy import, no new import-time dependency for tracing), bounded by max_delay. Other 4xx responses are unchanged.
    • Nine new tests in tests/test_trace_processor.py (three statuses, recovery on the second attempt, Retry-After in seconds / milliseconds / above max_delay / unparsable / negative); all fail on main, pass with the fix. tests/test_trace_processor.py + tests/tracing (130 passed), mypy, ruff clean.

    GitHub refuses createPullRequest for my account on this repository at the moment (same as #5006 reports), so I cannot open the PR myself; I will as soon as that clears, or a maintainer is welcome to pull the branch.

  2. L4XB commented on Sep 15, 2026

    @L4XB
    Contributor

    Independent reproduction on main @ fbf59a40, plus one piece of in-repo evidence that I think strengthens the case for the classification proposed here.

    The repro is accurate. Running the script from the issue against fbf59a40:

    ERROR:openai.agents:[non-fatal] Tracing client error 429. Response data is redacted.
    post calls: 1
    

    The 429 response is consumed by the 400 <= response.status_code < 500 branch in _export_with_deadline, which breaks before the backoff loop can run, so the batch is discarded on the first response.

    There is already a precedent for this classification inside the SDK, not just in the openai client. The issue cites openai._base_client._should_retry, which is correct — on openai==3.0.0 that method retries 408, 409, 429 and >= 500. But the SDK also carries its own copy of that policy: get_openai_retry_advice in src/agents/models/_openai_retry.py classifies

    if normalized.status_code in {408, 409, 429} or (
        isinstance(normalized.status_code, int) and normalized.status_code >= 500
    ):

    as retry-suggested, and reaches for Retry-After through the same _retry_runtime helpers the issue points at. That makes BackendSpanExporter the one place in the SDK that applies a different retry policy to the same set of statuses against the same backend, which seems more like drift than an intentional divergence for the tracing path.

    One point maintainers may want to rule on. Both _should_retry and get_openai_retry_advice consult the x-should-retry header before looking at the status code, and both treat an over-large Retry-After as a reason not to retry (MAX_RETRY_AFTER_DELAY) rather than as a value to clamp. A tracing exporter that already has a deadline and a shutdown event arguably wants the clamping behaviour instead, and dropping x-should-retry keeps the change small — but it is a deliberate difference from the two existing policies rather than an exact mirror, so it is worth an explicit decision either way.

    On the patch linked above. Applied to fbf59a40 it behaves as the issue's "Expected behavior" section describes — the same repro then reports post calls: 2 — and the checks come out clean:

    $ pytest tests/test_trace_processor.py tests/tracing -q
    130 passed in 12.81s
    
    $ make lint
    All checks passed!
    
    $ make mypy
    Success: no issues found in 312 source files
    

    The nine added tests are discriminating: with the patch's test file in place but src/agents/tracing/processors.py reverted to fbf59a40, all nine fail (9 failed, 56 deselected) — three for the 408/409/429 parametrisation, one for recovery on the second attempt, five for the Retry-After seconds/ms/over-max_delay/unparsable/negative cases.

    I have not opened a competing pull request and do not intend to; this is only confirmation plus the _openai_retry.py reference, in case it helps triage.

    File and line references are against main @ fbf59a40e9da.

  3. coderdailyone commented on Sep 15, 2026

    @coderdailyone
    ContributorAuthor

    Thanks for the independent run and the get_openai_retry_advice pointer; that is the better in-repo precedent and I have cited it in the PR text.

    On the two divergences: I added x-should-retry: false as a veto in cbb3a561 (test test_backend_span_exporter_rate_limit_obeys_x_should_retry_false), since honouring it costs one header read and keeps the classification identical to the model path. For an oversized Retry-After I kept clamping to max_delay rather than refusing the retry: the model path gives up because a request that waits minutes blocks the caller, whereas the exporter runs on the background thread with its own deadline and shutdown event, so waiting the bounded max_delay and retrying keeps the batch alive at no cost to the run. Happy to switch to the refusal semantics if the maintainers prefer an exact mirror.

  4. added a commit that references this issue on Sep 16, 2026
    6b51bde
  5. added this to the 0.22.x milestone on Sep 20, 2026
  6. added a commit that references this issue on Sep 20, 2026
    06298d5
  7. added a commit that references this issue on Oct 26, 2026
    fdb9a7b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions