Failover to fallback_providers on plain server_error (500/502) - #58355
Failover to fallback_providers on plain server_error (500/502)#58355rrschott-ai wants to merge 3 commits into
Conversation
|
Thanks for isolating the missing eager-fallback classification. Problems
Suggested changes
Automated hermes-sweeper review. |
2f1c0e0 to
1eefdd1
Compare
|
Addressed — thanks for the correction, you're right and I've updated the PR description accordingly (pushed as commit 1eefdd1): Confirmed at the max-retry-exhaustion handler: Also extracted the reason-set check into |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Two PRs address the 500/502 failover timing gap: both add FailoverReason.server_error to the retry_count >= 2 eager-fallback path, causing earlier fallback rather than enabling a chain that was previously unreachable, because the max-retry handler already attempts fallback for every error reason. #58355 is the focused implementation, while #71336 mixes the same core change with unrelated Kanban ownership and process-wait heartbeat changes.
Related pull requests
- #58355
related— (+103/-9) — canonical candidate, changes requested: The diff correctly adds server_error to the eager-fallback predicate and gives it an accurate status message, so 500/502 responses switch providers after two failed primary attempts rather than at max-retry exhaustion. The keep_open review on #58355 remains unresolved: helper-level assertions and inspect.getsource wiring do not provide the requested full-loop regression demonstrating two primary failures followed by success on a configured fallback. - #71336
duplicate— (+277/-6) — duplicate core fix, split required: Its conversation-loop change is materially the same server_error addition as #58355, but its source-parsing tests do not exercise actual fallback activation, and the diff also includes unrelated Kanban handoff/ownership and process-registry heartbeat changes. The contributor review explicitly requires separating those unrelated changes before selecting a canonical failover patch.
Duplicates
#58355 and #71336 implement essentially the same eager-fallback classification for HTTP 500/502; #71336 additionally contains unrelated changes that should be moved to separate PRs.
Suggested consolidation
Merge #58355 after adding the contributor-requested full-loop regression that proves fallback activation after the second failed primary 500/502 request and successful continuation on the configured fallback. Then close #71336 as a duplicate of #58355 for the failover portion; preserve its unrelated Kanban and process-registry work only through separately scoped PRs.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup58355 ["PRs duplicating each other"]
P58355["PR #58355 (open)"]
P71336["PR #71336 (open)"]
end
class P58355 open
class P71336 open
class P58355 target
click P58355 "https://github.com/NousResearch/hermes-agent/pull/58355"
click P71336 "https://github.com/NousResearch/hermes-agent/pull/71336"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 25 kB of PR diffs, 4 kB of issue/PR text, 2 kB of discussion (3 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
…/502)
The eager-fallback gate in the main retry loop only treats
FailoverReason.timeout and .overloaded (503/529) as transport failures
eligible to fail over to fallback_providers after 2 retries. Plain
server_error (500/502) wasn't in that set, so a primary provider that's
erroring with 500s retries to api_max_retries and gives up entirely
with a configured fallback chain sitting completely unused.
Add server_error to the eager-fallback set and give it its own status
message ("Provider server error...") instead of reusing the transport
"Provider unreachable" text, since a 500 means the provider responded,
just with an error - it isn't unreachable.
Per hermes-sweeper's automated review on PR NousResearch#58355: - Corrected the framing. Confirmed at agent/conversation_loop.py's max-retry-exhaustion handler: it already calls agent._try_activate_fallback() unconditionally once retry_count >= max_retries, for ANY error reason - including server_error, before this PR. The original PR description overclaimed ("a fallback chain sitting unused"); the actual, real improvement is that server_error now joins timeout/overloaded in the *eager* fallback gate, activating after 2 retries instead of waiting for the full retry budget to exhaust. Updated the inline comment to describe this accurately. - Extracted the eager-fallback reason set into _is_eager_fallback_transport_reason(reason), a plain module-level function, matching the codebase's own established pattern for testing logic embedded in run_conversation (see test_gemini_fast_fallback.py's treatment of _pool_may_recover_from_rate_limit) rather than driving the full conversation loop end-to-end. - Added tests/agent/test_server_error_fallback.py: direct unit coverage of the extracted helper (server_error/overloaded/timeout eligible; rate_limit/billing/unknown not), a reproduction of the loop's _should_fallback expression confirming eager fallback activates at retry_count >= 2 and not before, and an inspect.getsource() regression (mirroring test_gemini_fast_fallback.py's own convention) confirming run_conversation actually calls the extracted helper rather than a drift-prone inline literal set. Ran with the project's own venv (global python was missing concurrent_log_handler, unrelated to this change): 212 passed across this new file plus test_gemini_fast_fallback.py, test_error_classifier.py, and test_credential_pool_routing.py.
teknium1's review on NousResearch#58355 asked for a full-loop test proving fallback_providers actually activates after two 500/502s (retry_count >= 2), not just helper-level assertions on _is_eager_fallback_transport_reason(). The 2026-07-29 hermes-sweeper cross-PR triage (GottZ) confirmed that request was still outstanding — tests/agent/test_server_error_fallback.py only had unit tests on the extracted helper and an inspect.getsource() wiring check, no test that drove run_conversation() end-to-end. Adds test_run_conversation_activates_fallback_after_two_server_errors, modeled on the established full-loop pattern in tests/run_agent/test_32646_fallback_429_after_timeout.py: two plain HTTP 500s on the primary provider, then a successful response from the configured fallback_providers entry, asserting the eager-fallback gate at agent/conversation_loop.py:4285-4289 fires on the second failure rather than waiting for max-retry exhaustion.
1eefdd1 to
ff1f226
Compare
|
Addressed the outstanding item from the 2026-07-29 cross-PR triage (and the underlying request in the original review): a full-loop regression test, not just helper-level coverage. Rebased onto latest Added Verified: Re: the triage's note that #71336 is a duplicate — that's not my PR, so no action needed here; leaving that consolidation to whoever owns it. |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Delta since our previous triage comment
Since our previous comment, @rrschott-ai reports that #58355 was rebased and extended with the contributor-requested full-loop regression: two primary HTTP 500 failures followed by success through the configured fallback after the second failure. This directly responds to the visible keep_open review, but the current evidence includes only the author’s report and test result, not an available diff independently confirming the new test implementation.
Changed pull requests
- #58355
related— (+214/-9) — updated evidence, verification pending: Despite the keep_open review on #58355 requesting a full-loop regression, @rrschott-ai now reports adding exactly that scenario and obtaining 9 passing tests; because the updated diff is unavailable here, the review concern cannot yet be considered independently verified as resolved.
Duplicates
The prior duplicate designation is withdrawn: #58355 and #71336 target the same 500/502 eager-fallback gap, but the available evidence does not establish a maintainer-selected canonical implementation or support closing either solely as a duplicate.
Suggested consolidation
Recommendation revised: inspect #58355’s current diff to verify the reported full-loop test, require #71336’s unrelated Kanban/process-registry changes to be split, and only then select between the overlapping failover patches.
Complex graph unchanged since our previous triage comment.
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Diffs were read for 1 of 2 PRs (rest unavailable); Assessment working set: 18 kB of PR diffs, 4 kB of issue/PR text, 4 kB of discussion (4 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
|
Re: the follow-up triage note that the diff wasn't available to independently verify — here's a direct pointer:
That test constructs a real |
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Delta since our previous triage comment
Since the previous comment, @rrschott-ai provided the updated #58355 diff, which now independently confirms the requested full-loop regression: two primary HTTP 500 failures are followed by successful activation of the configured fallback after the second failure. @teknium1’s new review of #71336 confirms that its source-parsing tests remain non-behavioral and prohibited by AGENTS.md, while its unrelated Kanban/process-registry changes still require separation.
Changed pull requests
- #58355
related— (+214/-9) — canonical candidate, review concern addressed in the diff: The new behavioral test executes run_conversation(), produces two primary 500 failures, and verifies successful continuation on the configured fallback after the existing retry_count >= 2 gate, directly addressing the visible keep_open review on #58355; the reported 9-test pass remains author-reported. - #71336
duplicate— (+277/-6) — duplicate core fix, split required: Despite the new keep_open review on #71336, its current diff still tests the failover change by parsing production source rather than exercising fallback behavior and still bundles unrelated Kanban and process-registry changes; @teknium1 explicitly requests a behavioral test and separation before selection.
Suggested consolidation
Recommendation unchanged: merge the focused #58355 after normal maintainer verification, then close #71336 as a duplicate for the failover portion while preserving its unrelated work only in separate PRs.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup58355 ["PRs duplicating each other"]
P58355["PR #58355 (open)"]
P71336["PR #71336 (open)"]
end
class P58355 open
class P71336 open
class P58355 target
click P58355 "https://github.com/NousResearch/hermes-agent/pull/58355"
click P71336 "https://github.com/NousResearch/hermes-agent/pull/71336"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).
Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 30 kB of PR diffs, 4 kB of issue/PR text, 5 kB of discussion (6 comments), 0 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
…n error quirks
Adds a plugin seam at the top of agent/error_classifier.classify_api_error()
(step 0, before the built-in pipeline) so model-provider plugins can classify
their provider's error quirks without patching core:
- New "classify_api_error" entry in VALID_HOOKS. Callbacks receive the parsed
error context (provider, model, status_code, error_type, error_code,
error_message, error_body, error, approx_tokens, context_length,
num_messages), self-scope on `provider`, and return None to pass or a dict
{"reason": "<FailoverReason name>", ...optional recovery-hint overrides}.
- get_plugin_error_classification() helper mirrors
get_pre_tool_call_block_message(): first valid result wins, invalid dicts
and unknown reasons are skipped, callback exceptions are isolated — a
broken plugin can never break classification. Zero behavior change when no
plugin claims the error (all 179 existing classifier tests pass untouched).
- Bundled reference plugin `openrouter-tool-use-404` (opt-in, like all
bundled standalone plugins) re-implements PR NousResearch#58451: OpenRouter's
"No endpoints found that support tool use" 404 carries no
_MODEL_NOT_FOUND_PATTERNS signal, so it classifies as unknown/retryable
and the retry loop burns 3-5 attempts on a deterministic rejection.
The plugin classifies it as model_not_found (retryable=False,
should_fallback=True) so the fast-fallback path fires immediately —
demonstrating a waiting core PR converted to a publishable plugin.
Motivation: ~10 open PRs are single-provider error-classification patches
(NousResearch#58451, NousResearch#58355, NousResearch#58502, NousResearch#58474, NousResearch#58366, ...). This hook turns that whole
class of contribution into plugin territory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWMcB7RPSYUpsXDfBgwjzM
…n error quirks
Adds a plugin seam at the top of agent/error_classifier.classify_api_error()
(step 0, before the built-in pipeline) so model-provider plugins can classify
their provider's error quirks without patching core:
- New "classify_api_error" entry in VALID_HOOKS. Callbacks receive the parsed
error context (provider, model, status_code, error_type, error_code,
error_message, error_body, error, approx_tokens, context_length,
num_messages), self-scope on `provider`, and return None to pass or a dict
{"reason": "<FailoverReason name>", ...optional recovery-hint overrides}.
- get_plugin_error_classification() helper mirrors
get_pre_tool_call_block_message(): first valid result wins, invalid dicts
and unknown reasons are skipped, callback exceptions are isolated — a
broken plugin can never break classification. Zero behavior change when no
plugin claims the error (all 179 existing classifier tests pass untouched).
- Bundled reference plugin `openrouter-tool-use-404` (opt-in, like all
bundled standalone plugins) re-implements PR #58451: OpenRouter's
"No endpoints found that support tool use" 404 carries no
_MODEL_NOT_FOUND_PATTERNS signal, so it classifies as unknown/retryable
and the retry loop burns 3-5 attempts on a deterministic rejection.
The plugin classifies it as model_not_found (retryable=False,
should_fallback=True) so the fast-fallback path fires immediately —
demonstrating a waiting core PR converted to a publishable plugin.
Motivation: ~10 open PRs are single-provider error-classification patches
(#58451, #58355, #58502, #58474, #58366, ...). This hook turns that whole
class of contribution into plugin territory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWMcB7RPSYUpsXDfBgwjzM
Summary
conversation_loop.py) only treatsFailoverReason.timeoutand.overloaded(503/529) as transport failures eligible to fail over tofallback_providersafter 2 retries.server_error(500/502) wasn't in that set.run_conversation's max-retry-exhaustion handler already callsagent._try_activate_fallback()unconditionally onceretry_count >= max_retries, for any error reason. The actual improvement here is timing:server_errornow joinstimeout/overloadedin the eager gate, so failover happens after 2 retries instead of only once the full retry budget (api_max_retries, default 3) is exhausted — less time spent retrying a primary that's already failing with a configured fallback available.server_errorits own status message ("Provider server error...") instead of reusing the transport "Provider unreachable" text, since a 500 means the provider responded, just with an error.Test plan
ast.parse)_is_eager_fallback_transport_reason(), a plain testable function, matching the codebase's existing pattern for logic embedded inrun_conversation(seetest_gemini_fast_fallback.py's treatment of_pool_may_recover_from_rate_limit)tests/agent/test_server_error_fallback.py: unit coverage of the extracted helper (server_error/overloaded/timeout eligible; rate_limit/billing/unknown not), a reproduction of_should_fallbackconfirming eager fallback activates atretry_count >= 2and not before, and aninspect.getsource()regression confirmingrun_conversationactually calls the extracted helper rather than a drift-prone inline literaltest_gemini_fast_fallback.py,test_error_classifier.py,test_credential_pool_routing.py)