Retry backoff, rate-limit handling, and local endpoint timeouts

NousResearch/hermes-agent · cluster 1826 · agent

Data as of

11 open items. Fix merged in #3809. Check it covers your case.

The short answer

6open issues
5open PRs
33items grouped
Known?
Yes. 33 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #3809. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #6368, #9248. That is separate from the fix event.
Still open
6 issues and 5 PRs.

These items address HTTP 429/503/529 retry logic with configurable backoff and user-friendly messaging, Discord chunk-drop bugs on rate limits, and stale-stream timeout behavior for local/Docker/mDNS provider endpoints.

Open issues6

  • Issue#5570feat: configurable max API retries + stream retries with smarter backoff
  • Issue#20346[Bug]: using DNS for local provider is not addressed in recent fix
  • Issue#44468[Bug]: `hermes send` to Discord drops remaining chunks on a 429 — no retry in the standalone send path (Telegram has one)
  • Issue#49031[Feature Request] Configurable retry backoff for 429 rate-limit errors
  • Issue#54875Configurable retry backoff parameters (base_delay, max_delay) for HTTP 429
  • Issue#55540[Feature]: Longer/configurable backoff for HTTP 503/529 provider overload (parity with Z.AI path)

Open PRs5

  • PR#8486feat: configurable max API retries + stream retries with smarter backoff
  • PR#13519Amindadgar PR#8486 retry ci fix
  • PR#20359fix(agent): treat mDNS/.lan/unqualified hostnames as local endpoints (#20346)
  • PR#102551feat(agent): configurable rate-limit retry policy for 429s
  • PR#109329fix(discord): retry standalone sends after rate limits

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3809 fix(agent): user-friendly 429 rate limit messages with Retry-After support

    merged to main

    files: run_agent.py

    Show the diff
    • run_agent.py
      @@ -7229,7 +7229,10 @@ def _stop_spinner():
                                   retry_count = 0
                                   continue
                               _final_summary = self._summarize_api_error(api_error)
      -                        self._vprint(f"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.", force=True)
      +                        if is_rate_limited:
      +                            self._vprint(f"{self.log_prefix}❌ Rate limit persisted after {max_retries} retries. Please try again later.", force=True)
      +                        else:
      +                            self._vprint(f"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.", force=True)
                               self._vprint(f"{self.log_prefix}   💀 Final error: {_final_summary}", force=True)
       
                               # Detect SSE stream-drop pattern (e.g. "Network
      @@ -7289,8 +7292,22 @@ def _stop_spinner():
                                   "error": _final_summary,
                               }
       
      -                    wait_time = min(2 ** retry_count, 60)  # Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s, 60s
      -                    self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...")
      +                    # For rate limits, respect the Retry-After header if present
      +                    _retry_after = None
      +                    if is_rate_limited:
      +                        _resp_headers = getattr(getattr(api_error, "response", None), "headers", None)
      +                        if _resp_headers and hasattr(_resp_headers, "get"):
      +                            _ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
      +                            if _ra_raw:
      +                                try:
      +                                    _retry_after = min(int(_ra_raw), 120)  # Cap at 2 minutes
      +                                except (TypeError, ValueError):
      +                                    pass
      +                    wait_time = _retry_after if _retry_after else min(2 ** retry_count, 60)
      +                    if is_rate_limited:
      +                        self._emit_status(f"⏱️ Rate limit reached. Waiting {wait_time}s before retry (attempt {retry_count + 1}/{max_retries})...")
      +                    else:
      +                        self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...")
                           logger.warning(
                               "Retrying API call in %ss (attempt %s/%s) %s error=%s",
                               wait_time,
  • #6368 fix(agent): disable stale stream timeout for local providers

    merged to main

    files: run_agent.py

  • #7950 fix: is_local_endpoint misses Docker/Podman DNS names

    merged to main

    files: agent/model_metadata.py, tests/agent/test_local_stream_timeout.py, tests/tools/test_tool_result_storage.py, tools/tool_result_storage.py

    Show the diff
    • agent/model_metadata.py
      @@ -179,6 +179,12 @@ def _strip_provider_prefix(model: str) -> str:
       
       # Local server hostnames / address patterns
       _LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
      +# Docker / Podman / Lima DNS names that resolve to the host machine
      +_CONTAINER_LOCAL_SUFFIXES = (
      +    ".docker.internal",
      +    ".containers.internal",
      +    ".lima.internal",
      +)
       
       
       def _normalize_base_url(base_url: str) -> str:
      @@ -254,6 +260,9 @@ def is_local_endpoint(base_url: str) -> bool:
               return False
           if host in _LOCAL_HOSTS:
               return True
      +    # Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
      +    if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
      +        return True
           # RFC-1918 private ranges and link-local
           import ipaddress
           try:
    • tests/agent/test_local_stream_timeout.py
      @@ -22,6 +22,9 @@ class TestLocalStreamReadTimeout:
               "http://0.0.0.0:5000",
               "http://192.168.1.100:8000",
               "http://10.0.0.5:1234",
      +        "http://host.docker.internal:11434",
      +        "http://host.containers.internal:11434",
      +        "http://host.lima.internal:11434",
           ])
           def test_local_endpoint_bumps_read_timeout(self, base_url):
               """Local endpoint + default timeout -> bumps to base_timeout."""
      @@ -68,3 +71,38 @@ def test_empty_base_url_keeps_default(self):
                   if _stream_read_timeout == 120.0 and base_url and is_local_endpoint(base_url):
                       _stream_read_timeout = _base_timeout
                   assert _stream_read_timeout == 120.0
      +
      +
      +class TestIsLocalEndpoint:
      +    """Direct unit tests for is_local_endpoint."""
      +
      +    @pytest.mark.parametrize("url", [
      +        "http://localhost:11434",
      +        "http://127.0.0.1:8080",
      +        "http://0.0.0.0:5000",
      +        "http://[::1]:11434",
      +        "http://192.168.1.100:8000",
      +        "http://10.0.0.5:1234",
      +        "http://172.17.0.1:11434",
      +    ])
      +    def test_classic_local_addresses(self, url):
      +        assert is_local_endpoint(url) is True
      +
      +    @pytest.mark.parametrize("url", [
      +        "http://host.docker.internal:11434",
      +        "http://host.docker.internal:8080/v1",
      +        "http://gateway.docker.internal:11434",
      +        "http://host.containers.internal:11434",
      +        "http://host.lima.internal:11434",
      +    ])
      +    def test_container_dns_names(self, url):
      +        assert is_local_endpoint(url) is True
      +
      +    @pytest.mark.parametrize("url", [
      +        "https://api.openai.com",
      +        "https://openrouter.ai/api",
      +        "https://api.anthropic.com",
      +        "https://evil.docker.internal.example.com",
      +    ])
      +    def test_remote_endpoints(self, url):
      +        assert is_local_endpoint(url) is False
    • tests/tools/test_tool_result_storage.py
      @@ -124,6 +124,34 @@ def test_uses_parent_dir_of_remote_path(self):
               cmd = env.execute.call_args[0][0]
               assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
       
      +    def test_path_with_spaces_is_quoted(self):
      +        env = MagicMock()
      +        env.execute.return_value = {"output": "", "returncode": 0}
      +        remote_path = "/tmp/hermes results/abc file.txt"
      +        _write_to_sandbox("content", remote_path, env)
      +        cmd = env.execute.call_args[0][0]
      +        assert "'/tmp/hermes results'" in cmd
      +        assert "'/tmp/hermes results/abc file.txt'" in cmd
      +
      +    def test_shell_metacharacters_neutralized(self):
      +        """Paths with shell metacharacters must be quoted to prevent injection."""
      +        env = MagicMock()
      +        env.execute.return_value = {"output": "", "returncode": 0}
      +        malicious_path = "/tmp/hermes-results/$(whoami).txt"
      +        _write_to_sandbox("content", malicious_path, env)
      +        cmd = env.execute.call_args[0][0]
      +        # The $() must not appear unquoted — shlex.quote wraps it
      +        assert "'/tmp/hermes-results/$(whoami).txt'" in cmd
      +
      +    def test_semicolon_injection_neutralized(self):
      +        env = MagicMock()
      +        env.execute.return_value = {"output": "", "returncode": 0}
      +        malicious_path = "/tmp/x; rm -rf /; echo .txt"
      +        _write_to_sandbox("content", malicious_path, env)
      +        cmd = env.execute.call_args[0][0]
      +        # The semicolons must be inside quotes, not acting as command separators
      +        assert "'/tmp/x; rm -rf /; echo .txt'" in cmd
      +
       
       class TestResolveStorageDir:
           def test_defaults_to_storage_dir_without_env(self):
    • tools/tool_result_storage.py
      @@ -24,6 +24,7 @@
       
       import logging
       import os
      +import shlex
       import uuid
       
       from tools.budget_config import (
      @@ -79,7 +80,7 @@ def _write_to_sandbox(content: str, remote_path: str, env) -> bool:
           marker = _heredoc_marker(content)
           storage_dir = os.path.dirname(remote_path)
           cmd = (
      -        f"mkdir -p {storage_dir} && cat > {remote_path} << '{marker}'\n"
      +        f"mkdir -p {shlex.quote(storage_dir)} && cat > {shlex.quote(remote_path)} << '{marker}'\n"
               f"{content}\n"
               f"{marker}"
           )
  • #9248 fix(local): recognize unqualified hostnames as local endpoints

    merged to main

    files: agent/model_metadata.py, tests/agent/test_local_stream_timeout.py

  • #77607 fix(agent): exponential backoff for rate-limit fallback cooldown (#30223 salvage)

    merged to main

    files: agent/agent_runtime_helpers.py, agent/chat_completion_helpers.py, tests/run_agent/test_24996_fallback_exhaustion_cooldown.py

  • #116353 Transient provider outages no longer end the turn: bounded auto-recovery ladder after retries and fallback, visible on CLI/TUI/gateway/API/cron (#85426, #107307)

    merged to main

    files: agent/AGENTS.md, agent/agent_init.py, agent/turn_api_error.py, agent/turn_recovery_autorecover.py, agent/turn_retry_state.py, cli-config.yaml.example, gateway/platforms/api_server.py, gateway/platforms/api_server_openai_routes.py, hermes_cli/config_defaults.py, tests/agent/test_turn_recovery_autorecover.py, tests/agent/test_turn_retry_state.py, tests/gateway/test_api_server_status_stream.py, website/docs/user-guide/configuration.md

10 PRs closed without merging

From the close comments: superseded by another PR 4 · done elsewhere 2 · duplicate 1 · merged via another change 1 · other 2

  • #1835 fix(agent): user-friendly 429 rate limit messages with countdown and Retry-After support · merged via another change → #3809
  • #6123 fix(run_agent): disable stale timeout for local providers (#5889) · superseded by another PR → #6368, #5889
  • #7431 fix(agent): add exponential backoff to inner streaming retry loop · done elsewhere → 985e19c
  • #7906 fix: is_local_endpoint misses Docker/Podman DNS names · superseded by another PR → #7950, #9248
  • #8021 fix(agent): detect Docker/Podman DNS names in is_local_endpoint · other → #7906
Show 5 more
  • #30223 fix: exponential backoff for rate-limit fallback cooldown · superseded by another PR → #77607
  • #44488 fix(discord): retry 429s in the standalone send path, honoring retry_after · other → #79564
  • #44542 fix(discord): retry standalone send on 429 rate-limit instead of dropping chunks · duplicate → #44488
  • #44558 fix(tools): retry Discord send on 429 rate-limit (#44468) · superseded by another PR → #44488, #44468
  • #56037 feat(agent): make overload (503/529) backoff configurable · done elsewhere → 059b3d4, #116353
Closed issues · 6
  • Closed#1826[UX] Rate limit errors (429) should show user-friendly message with configurable retry instead of traceback
  • Closed#4657[Feature]: Configurable server retry interval
  • Closed#5889[Bug]:Hermes reconnects after 180s of provider silence even though oMLX is still processing
  • Closed#7069[Bug]: Infinite retry loop caused by stream stale timeout during local LLM prefill phase
  • Closed#7905is_local_endpoint misses Docker/Podman DNS names — stale timeout fires on local LLM proxies
  • Closed#29702Feature Request: Configurable model provider retry interval / backoff
How this cluster was grouped
  • Link evidence (partly structural links): 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.05: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#3809, #6368, #7950, #9248, #77607, #116353
Closed issues
6
Attempts
10 PRs closed without merging
Fix link checked
19 Sep 2026

For your agent

The same facts, typed.