# NousResearch/hermes-agent · Misclassification of 'overloaded' errors as rate_limit

## Open now

- Nothing open on GitHub.

Multiple issues and fix attempts address a bug where 429 responses with 'overloaded' message bodies are incorrectly classified as rate_limit errors instead of server overload, triggering the wrong recovery path and exhausting credential pools.

## TL;DR

- 8 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #52890. Verify it addresses your case.
- **Recommended:** read PR #52890 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-53578`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.23`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #14038: "overloaded" server errors classified as rate_limit, exhausting credential pool
- #15297: Error classifier: 429 'temporarily overloaded' misclassified as rate_limit — triggers wrong recovery path

## Prior & competing PRs

- **#14788: fix(agent): classify overloaded errors as server-side overload, not rate_limit**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `38e7bd8a08a9df450b7d8661778ecc47f25323c2`, `#14038`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#52890: fix(agent): classify message-only 'overloaded' as server overload (salvage of #14261 by @ms-alan)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/error_classifier.py`, `tests/agent/test_curator.py`, `tests/agent/test_error_classifier.py`
  - fix hunks:
  - `agent/error_classifier.py`

    ```diff
    @@ -1214,6 +1214,20 @@ def _classify_by_message(
                 should_fallback=True,
             )
     
    +    # Overloaded / server-busy patterns — must come BEFORE the rate_limit and
    +    # billing checks so that a message-only "overloaded" (no 503/529 status,
    +    # e.g. some Anthropic-compatible proxies) classifies as a transient
    +    # overload (backoff + retry) instead of falling through to `unknown` or
    +    # incorrectly triggering credential rotation.
    +    if any(p in error_msg for p in (
    +        "overloaded", "temporarily overloaded",
    +        "service is temporarily overloaded",
    +    )):
    +        return result_fn(
    +            FailoverReason.overloaded,
    +            retryable=True,
    +        )
    +
         # Billing patterns
         if any(p in error_msg for p in _BILLING_PATTERNS):
             return result_fn(
    ```
  - `tests/agent/test_curator.py`

    ```diff
    @@ -7,6 +7,7 @@
     from __future__ import annotations
     
     import importlib
    +import threading
     from datetime import datetime, timedelta, timezone
     from pathlib import Path
     
    ```

    ```diff
    @@ -37,7 +38,21 @@ def curator_env(tmp_path, monkeypatch):
         # directly). Tests opt in with _enable_prune_builtins(...).
         monkeypatch.setattr(usage, "_prune_builtins_enabled", lambda: False)
     
    -    return {"home": home, "curator": curator, "usage": usage}
    +    yield {"home": home, "curator": curator, "usage": usage}
    +
    +    # Teardown: a curator review launched with synchronous=False spawns a
    +    # daemon "curator-review" thread that calls save_state() when it finishes.
    +    # save_state() resolves the state path from HERMES_HOME at write time, so a
    +    # straggler thread that outlives this test would write into whatever home
    +    # the *next* test has configured (or the default ~/.hermes once monkeypatch
    +    # restores the env) — corrupting an unrelated test's state file. This race
    +    # is invisible on a fast machine but flakes under CI load. Join any such
    +    # thread here, while HERMES_HOME is still pinned to this test's tmp home
    +    # (curator_env depends on monkeypatch, so this teardown runs before the
    +    # monkeypatch env is restored). See the salvage of #14261 CI flake.
    +    for t in threading.enumerate():
    +        if t.name == "curator-review" and t.is_alive():
    +            t.join(timeout=10.0)
     
     
     def _write_skill(skills_dir: Path, name: str):
    ```
  - `tests/agent/test_error_classifier.py`

    ```diff
    @@ -347,6 +347,18 @@ def test_529_anthropic_overloaded(self):
             result = classify_api_error(e)
             assert result.reason == FailoverReason.overloaded
     
    +    def test_message_only_overloaded_without_status_is_overloaded(self):
    +        """Some Anthropic-compatible proxies surface 'overloaded' in the
    +        message with no 503/529 status_code. It must classify as overloaded
    +        (transient backoff+retry), not unknown / credential rotation. (#14261)"""
    +        e = MockAPIError(
    +            "Anthropic API error: Overloaded - the service is temporarily overloaded"
    +        )  # no status_code
    +        result = classify_api_error(e, provider="anthropic")
    +        assert result.reason == FailoverReason.overloaded
    +        assert result.retryable is True
    +        assert result.should_rotate_credential is False
    +
         # ── 5xx that are actually request-validation errors ──
         # Some OpenAI-compatible gateways (e.g. codex.nekos.me) return
         # request-validation failures with a 5xx status. These are
    ```
- **#53578: fix(agent): classify 429 'overloaded' bodies as overloaded, not rate_limit (#14038)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/error_classifier.py`, `tests/agent/test_error_classifier.py`
  - fix hunks:
  - `agent/error_classifier.py`

    ```diff
    @@ -133,6 +133,31 @@ def is_auth(self) -> bool:
         "servicequotaexceededexception",
     ]
     
    +# Patterns that indicate provider-side overload, NOT a per-credential rate
    +# limit or billing problem.  The credential is valid — the server is just
    +# busy — so the correct recovery is "back off and retry the same key", never
    +# "rotate the credential" (rotating exhausts the pool while the endpoint is
    +# still busy; a single-key user has nothing to rotate to).  Some providers
    +# (notably Z.AI / Zhipu) reuse HTTP 429 for server-wide overload, so the 429
    +# status path matches the body against this list before falling through to
    +# the rate_limit default.  Phrases are kept narrow and overload-flavoured so a
    +# normal rate-limit message ("you have been rate-limited") doesn't hit this
    +# bucket. (#14038, #15297)
    +_OVERLOADED_PATTERNS = [
    +    "overloaded",
    +    "temporarily overloaded",
    +    "service is temporarily overloaded",
    +    "service may be temporarily overloaded",
    +    "server is overloaded",
    +    "server overloaded",
    +    "service overloaded",
    +    "service is overloaded",
    +    "upstream overloaded",
    +    "currently overloaded",
    +    "at capacity",
    +    "over capacity",
    +]
    +
     # Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
     _USAGE_LIMIT_PATTERNS = [
         "usage limit",
    ```

    ```diff
    @@ -863,7 +888,19 @@ def _classify_by_status(
             )
     
         if status_code == 429:
    -        # Already checked long_context_tier above; this is a normal rate limit
    +        # Already checked long_context_tier above. Some providers (notably
    +        # Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status
    +        # code as a true per-credential rate limit, but the credential is
    +        # valid and the correct recovery is "back off and retry the same key",
    +        # NOT "rotate the credential" (which exhausts the pool while the
    +        # endpoint is still busy, and does nothing for a single-key user).
    +        # Disambiguate on the error body so an overload 429 takes the
    +        # transient-overload path instead of burning the pool. (#14038)
    +        if any(p in error_msg for p in _OVERLOADED_PATTERNS):
    +            return result_fn(
    +                FailoverReason.overloaded,
    +                retryable=True,
    +            )
             return result_fn(
                 FailoverReason.rate_limit,
                 retryable=True,
    ```

    ```diff
    @@ -1219,10 +1256,7 @@ def _classify_by_message(
         # e.g. some Anthropic-compatible proxies) classifies as a transient
         # overload (backoff + retry) instead of falling through to `unknown` or
         # incorrectly triggering credential rotation.
    -    if any(p in error_msg for p in (
    -        "overloaded", "temporarily overloaded",
    -        "service is temporarily overloaded",
    -    )):
    +    if any(p in error_msg for p in _OVERLOADED_PATTERNS):
             return result_fn(
                 FailoverReason.overloaded,
                 retryable=True,
    ```
  - `tests/agent/test_error_classifier.py`

    ```diff
    @@ -359,6 +359,30 @@ def test_message_only_overloaded_without_status_is_overloaded(self):
             assert result.retryable is True
             assert result.should_rotate_credential is False
     
    +    def test_429_with_overloaded_body_is_overloaded_not_rate_limit(self):
    +        """Z.AI / Zhipu reuse HTTP 429 for server-wide overload. The credential
    +        is valid — the server is just busy — so it must classify as overloaded
    +        (back off + retry the same key), NOT rate_limit (which would rotate and
    +        exhaust the pool, doing nothing for a single-key user). (#14038)"""
    +        e = MockAPIError(
    +            "The service may be temporarily overloaded, please try again later",
    +            status_code=429,
    +        )
    +        result = classify_api_error(e, provider="zai")
    +        assert result.reason == FailoverReason.overloaded
    +        assert result.retryable is True
    +        assert result.should_rotate_credential is False
    +
    +    def test_429_normal_rate_limit_still_rotates(self):
    +        """Guard: a genuine 429 rate limit (no overload language) must still
    +        classify as rate_limit and rotate the credential. (#14038)"""
    +        e = MockAPIError(
    +            "Rate limit exceeded: too many requests", status_code=429
    +        )
    +        result = classify_api_error(e, provider="zai")
    +        assert result.reason == FailoverReason.rate_limit
    +        assert result.should_rotate_credential is True
    +
         # ── 5xx that are actually request-validation errors ──
         # Some OpenAI-compatible gateways (e.g. codex.nekos.me) return
         # request-validation failures with a 5xx status. These are
    ```
- **#14055: fix(error_classifier): classify 'overloaded' message as FailoverReason.overloaded**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52890`, `#14038`, `#53578`
- **#14261: fix(agent): classify 'overloaded' messages as server overload, not rate_limit**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#52890`, `#14038`, `#53578`
- **#15414: fix(error-classifier): route 429 'overloaded' messages to FailoverReason.overloaded (#15297)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`

## Fix status

- Merged to main in PR #52890 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-27`.
- Subsystem: `agent`
