Skip to content

fix(retry): honor gateway-advertised reset window; respect api_mode on fallback entries - #76225

Open
yusharwz wants to merge 2 commits into
NousResearch:mainfrom
yusharwz:fix/gateway-429-recovery
Open

fix(retry): honor gateway-advertised reset window; respect api_mode on fallback entries#76225
yusharwz wants to merge 2 commits into
NousResearch:mainfrom
yusharwz:fix/gateway-429-recovery

Conversation

@yusharwz

@yusharwz yusharwz commented Aug 1, 2026

Copy link
Copy Markdown

Summary

Two independent fixes for provider failover, both found while running Hermes against a self-hosted multi-provider gateway (an OpenAI/Anthropic-compatible router that fans out to several upstream accounts).

1. Honor the reset window a gateway states in its 429 body

Gateways that proxy several upstreams synthesize their 429 after an internal failover walk, so there is no upstream Retry-After header to forward. They state the window in the error body instead:

[antigravity/claude-sonnet-4-6] [429]: {...} (reset after 4s)
[claude/claude-opus-5] [429]: {...} (reset after 2m 13s)

run_conversation() only read the Retry-After header, so this was discarded and the wait fell back to jittered_backoff(). In practice the advertised windows are mostly 2–30s, and the generic backoff frequently retried inside the still-closed window, failed again, and abandoned an otherwise healthy provider for a fallback that was seconds away from being usable.

parse_reset_after_seconds() (in agent/retry_utils.py) parses the 4s / 2m 13s / 1h 5m shapes. The retry path prefers it only when no header is present:

  • a real Retry-After header still wins — existing behavior unchanged;
  • windows above 600s are ignored, so a genuine long outage falls through to normal failover instead of a long sleep;
  • 500ms is not misread as 500 minutes.

This also adds agent/downstream_rate_guard.py. The same gateways multiplex accounts, so concurrent sessions (chat-platform DMs, subagents, background review) can each land on the same upstream moments apart and independently compute their own short wait. Publishing the cooldown keyed by the upstream tag found in the error lets them converge on one wait instead of retrying back into the same wall. It only ever extends a wait the session computed itself, never shortens it and never blocks a request, so a stale or misparsed tag costs at most one slightly longer sleep. It fails open if the state file is unreadable.

2. Respect an explicit api_mode on fallback_model entries

try_activate_fallback() recomputed fb_api_mode from scratch and never read the api_mode field of the fallback entry, so setting it had no effect.

For a custom gateway speaking the Anthropic Messages API this defaulted to chat_completions and POSTed to /chat/completions, which 404s. The only way to force anthropic_messages was provider: anthropic, which ignores base_url and sends the request to api.anthropic.com instead of the configured gateway — surfacing as a confusing 404 on a model name the real API has never heard of, against whatever credentials happen to be configured.

An explicit api_mode now wins over the heuristics. Everything else is unchanged when the field is absent.

Testing

Three new test files (33 tests), using verbatim error strings captured from a real gateway:

  • tests/agent/test_reset_after_hint.py — parser: real gateway shapes, the 600s cap, the 500ms guard, zero/absent windows.
  • tests/agent/test_reset_after_wiring.py — precedence: header beats body hint, body hint beats backoff, overlong window falls back to backoff.
  • tests/agent/test_downstream_rate_guard.py — record/read/expire/clear, key isolation, filesystem-unsafe keys, cap.
pytest tests/agent/test_reset_after_hint.py tests/agent/test_reset_after_wiring.py \
       tests/agent/test_downstream_rate_guard.py tests/test_retry_utils.py \
       tests/run_agent/test_provider_fallback.py tests/agent/test_failover_identity.py
69 passed

Existing retry/failover suites pass unchanged.

Notes

The reset-window path only runs when agent.api_max_retries is ≥ 2 — with the value at 1 the loop gives up before reaching the wait. That is pre-existing behavior and this PR does not change the default.

yusharwz and others added 2 commits August 1, 2026 21:24
Aggregator gateways that proxy several upstream providers synthesize their
429 after an internal failover walk, so they have no Retry-After header to
forward and state the window in the error body instead:

    [antigravity/claude-sonnet-4-6] [429]: {...} (reset after 4s)
    [claude/claude-opus-5] [429]: {...} (reset after 2m 13s)

The retry loop only read the Retry-After header, so this was discarded and
the wait fell back to a generic exponential backoff. Observed windows are
mostly 2-30s, and the backoff frequently retried inside the still-closed
window, failed again, and abandoned an otherwise healthy provider for a
fallback that was seconds away from being usable.

parse_reset_after_seconds() reads the hint and the retry path prefers it
when no header is present. A real Retry-After header still wins, and
windows over 600s are ignored so a long outage falls through to the normal
failover instead of a long sleep.

Also adds a cross-session guard: those same gateways multiplex accounts, so
concurrent sessions (chat platform DMs, subagents, background review) can
each land on the same upstream and independently compute a short wait.
Publishing the cooldown per upstream tag lets them converge on one wait
instead of retrying back into the same wall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
try_activate_fallback() recomputed fb_api_mode from scratch and never read
the api_mode field of the fallback entry, so setting it had no effect.

For a custom gateway speaking the Anthropic Messages API this defaulted to
chat_completions and POSTed to /chat/completions, which 404s. The only way
to force anthropic_messages was provider: anthropic, which ignores base_url
and sends the request to api.anthropic.com instead of the configured
gateway -- surfacing as a confusing 404 on a model name the real API has
never heard of, against whatever credentials happen to be configured.

An explicit api_mode now wins over the heuristics; everything else is
unchanged when the field is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 1, 2026 14:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves provider failover reliability when running Hermes against aggregator-style gateways by (1) honoring rate-limit reset windows advertised in 429 error bodies (when no Retry-After header exists), and (2) fixing fallback activation to respect an explicitly configured api_mode per fallback entry.

Changes:

  • Add parse_reset_after_seconds() and wire it into the rate-limit retry path when no Retry-After header is present.
  • Introduce a cross-session downstream rate-limit guard keyed by upstream tags embedded in gateway error text, and merge waits across sessions.
  • Update fallback activation to prefer an explicit api_mode field over heuristics; add tests covering parser, wiring, and guard behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
agent/retry_utils.py Adds parsing for (reset after …) hints in 429 bodies.
agent/downstream_rate_guard.py New module to persist/read per-upstream cooldowns across sessions.
agent/conversation_loop.py Wires body-hint parsing and downstream cooldown merging into retry logic.
agent/chat_completion_helpers.py Ensures fallback entries can force api_mode.
tests/agent/test_reset_after_hint.py Unit tests for reset-window parsing from gateway error strings.
tests/agent/test_reset_after_wiring.py Tests precedence of header vs body hint vs backoff for wait computation.
tests/agent/test_downstream_rate_guard.py Tests persistence, expiry, isolation, and key sanitization for the new guard.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +75 to +80
try:
from hermes_constants import get_hermes_home
base = get_hermes_home()
except ImportError:
base = os.path.join(os.path.expanduser("~"), ".hermes")
return os.path.join(base, _STATE_SUBDIR, f"downstream_{safe_key}.json")
Comment on lines +136 to +137
except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError):
return None
Comment on lines +5438 to +5455
if is_rate_limited:
from agent.downstream_rate_guard import (
downstream_rate_limit_remaining,
extract_upstream_tag,
record_downstream_rate_limit,
)
_upstream_tag = extract_upstream_tag(agent._summarize_api_error(api_error))
if _upstream_tag:
_shared_rl_key = f"{getattr(agent, 'provider', 'unknown')}:{_upstream_tag}"
_shared_remaining = downstream_rate_limit_remaining(_shared_rl_key)
if _shared_remaining and _shared_remaining > wait_time:
logger.info(
"Extending retry wait for %s from %.1fs to %.1fs — another "
"session already recorded this upstream as rate-limited",
_shared_rl_key, wait_time, _shared_remaining,
)
wait_time = _shared_remaining
record_downstream_rate_limit(_shared_rl_key, seconds=wait_time)
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/config Config system, migrations, profiles duplicate This issue or pull request already exists labels Aug 1, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Duplicate of #16346 for the explicit fallback api_mode hunk: both make fallback-entry api_mode override transport heuristics. This PR also carries separate reset-window retry work.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming two real current-main gaps: agent/conversation_loop.py:5387-5412 only consumes a Retry-After header, and agent/chat_completion_helpers.py:1823-1867 ignores fallback-entry api_mode.

Problems

  • agent/downstream_rate_guard.py:136-137 promises fail-open behavior but omits OSError/PermissionError; the new call in agent/conversation_loop.py is unguarded, so an unreadable state file can break retry recovery. The existing review correctly called this out.
  • agent/downstream_rate_guard.py:75-80 retains a hardcoded ~/.hermes fallback, contrary to profile-safe path handling.
  • The new raw api_mode override bypasses the canonical validator in hermes_cli/runtime_provider.py:394-400, unlike primary initialization at agent/agent_init.py:615-616. Open PR #16346 already carries the broader validated fallback implementation.
  • tests/agent/test_reset_after_wiring.py:27-38 mirrors production logic locally rather than executing agent/conversation_loop.py; it cannot prove the integration.

Suggested changes

  • Reuse the canonical parser and reconcile this hunk with #16346.
  • Make the guard fully fail-open and profile-safe.
  • Test a real headerless-429 retry path, following tests/run_agent/test_run_agent.py:1674-1716.

Automated hermes-sweeper review.

base = get_hermes_home()
except ImportError:
base = os.path.join(os.path.expanduser("~"), ".hermes")
return os.path.join(base, _STATE_SUBDIR, f"downstream_{safe_key}.json")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this fallback and always resolve state through get_hermes_home(). A hardcoded ~/.hermes path is not profile-safe and can write a profile's cooldown state into the default home.

pass
return None
except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError, ValueError):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function promises to fail open for an unreadable state file, but PermissionError/other OSError values escape this handler and then escape the unguarded caller in the retry loop. Treat filesystem errors and malformed non-dict JSON as no active cooldown.

# ``api_mode`` on the fallback entry always wins over the heuristics
# below — without this, a fallback pointed at a custom gateway
# (provider: custom, base_url without a path suffix the heuristics
# recognize) silently defaulted to chat_completions and 404'd, and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate and normalize this override with hermes_cli.runtime_provider._parse_api_mode rather than accepting every non-empty string. Primary initialization only permits the canonical API-mode set (agent/agent_init.py:615-616), so an invalid fallback value currently creates an unsupported runtime state.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint duplicate This issue or pull request already exists P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants