Skip to content

Tool-path rate limits: retry at the bridge, then fail the run fast - #492

Open
rejojer wants to merge 1 commit into
mainfrom
fix/tool-429-fail-fast
Open

Tool-path rate limits: retry at the bridge, then fail the run fast#492
rejojer wants to merge 1 commit into
mainfrom
fix/tool-429-fail-fast

Conversation

@rejojer

@rejojer rejojer commented Sep 8, 2026

Copy link
Copy Markdown
Member

A PageIndex cloud 429 (or 5xx) on a tool call used to reach the model as an INTERNAL_ERROR envelope saying "try again": the model re-called once with no wait, then wrote the failure into its answer, and chat() returned normally with no status anywhere. The same 429 before the loop (the doc_id targeting lookup) already propagated raw.

Changes

  • McpBridge mounts a urllib3 Retry: 429/502/503 and connection failures, three attempts, 0/2/4 s apart or as Retry-After says. Read timeouts are never replayed (240 s each, and the server may have acted). A Retry-After past a minute is a quota, not a blip: the backoff runs instead of sleeping it out. Exhausted, the last response falls through to the existing >= 400 branch, so status_code survives.
  • _bridge_invoker re-raises 429/5xx alongside 401/403. The frameworks turn a raised tool exception back into model-visible text, so each chat() door gets its own escape:
    • openai-agents: the in-process MCPServer passes a failure_error_function that lets a PageIndex-caused failure propagate, and _translate_run_error unwraps it from the framework's wrapper (this also un-flattens the mid-session 401 case).
    • Messages lane: each turn's tools run through the runner's public generate_tool_call_response(), and a recorded failure raises before the next model call.
  • _model_backend_error keeps the provider's status_code.

Claude Agent SDK tools cannot fail fast: the SDK MCP server converts handler exceptions into JSON-RPC errors for Claude Code by design.

Behaviour change on the public tool surfaces

as_openai_tools() users running their own Runner.run now get an exception for a post-retry 429/5xx (the 401/403 re-raise always intended this; the framework absorbed it). as_anthropic_tools() users' runners absorb it into an is_error result and log a traceback.

Verification

  • 10 new tests (nine red before the change, one regression guard for model-side slips) plus the rewritten invoker test: bridge retry schedule and Retry-After handling against a local HTTP stub, exhausted-retry status, read-timeout non-replay, quota-length Retry-After, invoker re-raise matrix, openai-agents escape, run-error unwrap, provider status_code, end-to-end fail-fast on the chat and Messages doors.
  • Full suite green; the without-frameworks leg green; both agent test modules green on the openai-agents 0.18.1 floor; anthropic 0.108.0 already has generate_tool_call_response.
  • Live against the cloud with real models: a forced post-retry 429 raises PageIndexAPIError(status_code=429) on chat() default, streamed, and protocol="messages", after one tool call.

https://claude.ai/code/session_014S88dcSz7jykegAWyWZk8E

A PageIndex cloud 429 (or 5xx) on a tool call used to reach the model as
an INTERNAL_ERROR envelope saying "try again": the model re-called once
with no wait, then wrote the failure into its answer, and chat() returned
normally with no status anywhere. The same 429 before the loop (the
doc_id targeting lookup) already propagated raw.

- McpBridge mounts a urllib3 Retry: 429/502/503 and connection failures,
  three attempts, 0/2/4 s apart or as Retry-After says; read timeouts
  are never replayed (240 s each, and the server may have acted); a
  Retry-After past a minute is a quota, not a blip, so the backoff runs
  instead of sleeping it out. Exhausted, the last response falls through
  to the existing >= 400 branch, so the status_code survives.
- _bridge_invoker re-raises 429/5xx alongside 401/403. The frameworks
  turn a raised tool exception back into model-visible text, so each
  chat() door gets its own escape: the in-process MCPServer's
  failure_error_function lets a PageIndex-caused failure propagate and
  _translate_run_error unwraps it from the framework's wrapper (which
  also un-flattens the 401 case); the Messages lane runs each turn's
  tools through the runner's public generate_tool_call_response() and
  raises before the next model call.
- _model_backend_error keeps the provider's status_code.

Claude Agent SDK tools cannot fail fast: the SDK MCP server converts
handler exceptions into JSON-RPC errors for Claude Code by design.

Claude-Session: https://claude.ai/code/session_014S88dcSz7jykegAWyWZk8E
Comment thread tests/test_agent_tools.py
def log_message(self, *args):
pass

def do_POST(self):
Comment thread tests/test_local_chat.py
assert self.model_calls == 1, "a second model turn ran"
return FakeTurn()

def generate_tool_call_response(self):
Comment thread tests/test_agent_tools.py
def test_bridge_read_timeout_is_not_retried(mcp_stub, monkeypatch):
"""A read timeout is a full wait the server may have acted on:
surfaced once, never replayed."""
import pageindex.mcp_bridge as mcp_bridge
Comment thread tests/test_agent_tools.py
escapes the run (a model-side slip staying model-visible is covered end
to end in test_local_chat)."""
pytest.importorskip("agents")
import pageindex.mcp_bridge as mcp_bridge
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant