From 7aa4c7ab766cd85930b1db3b42a97175b3353200 Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:12:41 +0800 Subject: [PATCH 1/3] fix(sdk/python): honor numeric Retry-After waits --- sdk/python/agentfield/rate_limiter.py | 12 +++-- sdk/python/agentfield/types.py | 5 ++- sdk/python/tests/test_rate_limiter_core.py | 51 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/sdk/python/agentfield/rate_limiter.py b/sdk/python/agentfield/rate_limiter.py index 5171c7239..fb52c74b5 100644 --- a/sdk/python/agentfield/rate_limiter.py +++ b/sdk/python/agentfield/rate_limiter.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import math import os import random import time @@ -138,9 +139,12 @@ def _calculate_backoff_delay( Returns: float: Delay in seconds """ - # Use server-suggested delay if available and reasonable - if retry_after and retry_after <= self.max_delay: + # A server cooldown is a minimum, even when it exceeds the local + # exponential-backoff cap. Invalid hints use the normal backoff. + minimum_delay = 0.0 + if retry_after is not None and math.isfinite(retry_after) and retry_after > 0: base_delay = retry_after + minimum_delay = retry_after else: # Exponential backoff: base_delay * (2 ^ attempt) base_delay = min(self.base_delay * (2**attempt), self.max_delay) @@ -154,8 +158,8 @@ def _calculate_backoff_delay( jitter_range = base_delay * self.jitter_factor jitter = rng.uniform(-jitter_range, jitter_range) - # Ensure minimum delay and apply jitter - delay = max(0.1, base_delay + jitter) + # Negative jitter must not end the server's cooldown early. + delay = max(0.1, minimum_delay, base_delay + jitter) log_debug( f"Rate limit backoff: attempt={attempt}, base_delay={base_delay:.2f}s, jitter={jitter:.2f}s, total_delay={delay:.2f}s" diff --git a/sdk/python/agentfield/types.py b/sdk/python/agentfield/types.py index 1a22bfb51..7daa2bccd 100644 --- a/sdk/python/agentfield/types.py +++ b/sdk/python/agentfield/types.py @@ -483,7 +483,10 @@ def image_model(self) -> str: ) rate_limit_max_delay: float = Field( default=30.0, - description="Maximum delay for rate limit backoff in seconds.", + description=( + "Maximum base exponential backoff delay in seconds. A valid Retry-After " + "can require a longer wait." + ), ) rate_limit_jitter_factor: float = Field( default=0.25, diff --git a/sdk/python/tests/test_rate_limiter_core.py b/sdk/python/tests/test_rate_limiter_core.py index 38553e978..64e76c790 100644 --- a/sdk/python/tests/test_rate_limiter_core.py +++ b/sdk/python/tests/test_rate_limiter_core.py @@ -65,6 +65,57 @@ async def flaky_call(): assert attempts["sleeps"][0] == pytest.approx(0.2) +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("retry_after", [10.0, 60.0]) +async def test_execute_with_retry_respects_server_wait(monkeypatch, retry_after): + limiter = StatelessRateLimiter(max_retries=1, max_delay=30.0, jitter_factor=0.25) + elapsed = 0.0 + attempts = [] + + async def fake_sleep(delay): + nonlocal elapsed + elapsed += delay + + async def rate_limited_call(): + attempts.append(elapsed) + if len(attempts) == 1: + raise DummyHTTPError(retry_after=retry_after) + return "ok" + + monkeypatch.setattr("agentfield.rate_limiter.asyncio.sleep", fake_sleep) + # Exercise the earliest retry the configured jitter can produce. + monkeypatch.setattr(random.Random, "uniform", lambda self, low, high: low) + + assert await limiter.execute_with_retry(rate_limited_call) == "ok" + assert len(attempts) == 2 + assert attempts[1] >= retry_after + + +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize("retry_after", [-1.0, float("nan"), float("inf")]) +async def test_execute_with_retry_ignores_invalid_server_wait(monkeypatch, retry_after): + limiter = StatelessRateLimiter(max_retries=1, base_delay=0.5, jitter_factor=0.0) + attempts = 0 + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + async def rate_limited_call(): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise DummyHTTPError(retry_after=retry_after) + return "ok" + + monkeypatch.setattr("agentfield.rate_limiter.asyncio.sleep", fake_sleep) + + assert await limiter.execute_with_retry(rate_limited_call) == "ok" + assert sleeps == [0.5] + + @pytest.mark.unit @pytest.mark.asyncio async def test_execute_with_retry_gives_up(monkeypatch): From 8824947604bbbb5387994a646ec7e963b9d3367f Mon Sep 17 00:00:00 2001 From: Steven Huang <134844631+remote-controlled-man@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:07:39 +0800 Subject: [PATCH 2/3] style(sdk/python): normalize config field wrapping --- sdk/python/agentfield/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/python/agentfield/types.py b/sdk/python/agentfield/types.py index 7daa2bccd..c10b88efc 100644 --- a/sdk/python/agentfield/types.py +++ b/sdk/python/agentfield/types.py @@ -349,10 +349,10 @@ class HarnessConfig(BaseModel): pi_bin: str = Field(default="pi", description="Path to Pi binary.") omp_bin: str = Field(default="omp", description="Path to OMP binary.") aforge_bin: str = Field(default="aforge", description="Path to aforge binary.") - codeaf_bin: Optional[str] = Field(default=None, description="Path to CodeAF binary.") - grok_bin: str = Field( - default="grok", description="Path to Grok Build CLI binary." + codeaf_bin: Optional[str] = Field( + default=None, description="Path to CodeAF binary." ) + grok_bin: str = Field(default="grok", description="Path to Grok Build CLI binary.") schema_mode: str = Field( default="single", description=( From 9234835c91d551aa05310d0ba48327324d9ae23b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 09:36:20 -0400 Subject: [PATCH 3/3] fix(sdk/python): cap honoured Retry-After waits at five minutes Treating every finite Retry-After as a minimum let a provider suspend each retry indefinitely: a 86400s hint made the default five retries sleep five days, with no outer deadline on direct Agent.ai calls. Honour hints up to max(rate_limit_max_delay, 300s) and clamp anything larger to that ceiling, so jitter still never retries before the (capped) cooldown. Co-Authored-By: Claude Opus 5.5 --- sdk/python/agentfield/rate_limiter.py | 9 +++-- sdk/python/agentfield/types.py | 2 +- sdk/python/tests/test_rate_limiter_core.py | 41 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/sdk/python/agentfield/rate_limiter.py b/sdk/python/agentfield/rate_limiter.py index fb52c74b5..b6648dda9 100644 --- a/sdk/python/agentfield/rate_limiter.py +++ b/sdk/python/agentfield/rate_limiter.py @@ -8,6 +8,9 @@ from agentfield.logger import log_debug +_MAX_RETRY_AFTER_SECONDS = 300.0 + + class RateLimitError(Exception): """Custom exception for rate limit errors""" @@ -139,12 +142,12 @@ def _calculate_backoff_delay( Returns: float: Delay in seconds """ - # A server cooldown is a minimum, even when it exceeds the local + # Honour server cooldowns up to the ceiling, even above the local # exponential-backoff cap. Invalid hints use the normal backoff. minimum_delay = 0.0 if retry_after is not None and math.isfinite(retry_after) and retry_after > 0: - base_delay = retry_after - minimum_delay = retry_after + base_delay = min(retry_after, max(self.max_delay, _MAX_RETRY_AFTER_SECONDS)) + minimum_delay = base_delay else: # Exponential backoff: base_delay * (2 ^ attempt) base_delay = min(self.base_delay * (2**attempt), self.max_delay) diff --git a/sdk/python/agentfield/types.py b/sdk/python/agentfield/types.py index c10b88efc..cfa767727 100644 --- a/sdk/python/agentfield/types.py +++ b/sdk/python/agentfield/types.py @@ -485,7 +485,7 @@ def image_model(self) -> str: default=30.0, description=( "Maximum base exponential backoff delay in seconds. A valid Retry-After " - "can require a longer wait." + "can require a longer wait, up to 5 minutes or this value if larger." ), ) rate_limit_jitter_factor: float = Field( diff --git a/sdk/python/tests/test_rate_limiter_core.py b/sdk/python/tests/test_rate_limiter_core.py index 64e76c790..d9c28ad3f 100644 --- a/sdk/python/tests/test_rate_limiter_core.py +++ b/sdk/python/tests/test_rate_limiter_core.py @@ -92,6 +92,47 @@ async def rate_limited_call(): assert attempts[1] >= retry_after +@pytest.mark.unit +@pytest.mark.asyncio +@pytest.mark.parametrize( + "max_delay, retry_after, jitter_end, expected_wait", + [ + (30.0, 3600.0, "low", 300.0), + (30.0, 3600.0, "high", 375.0), + (600.0, 500.0, "low", 500.0), + (600.0, 3600.0, "low", 600.0), + ], +) +async def test_execute_with_retry_bounds_server_wait( + monkeypatch, max_delay, retry_after, jitter_end, expected_wait +): + limiter = StatelessRateLimiter( + max_retries=1, max_delay=max_delay, jitter_factor=0.25 + ) + attempts = 0 + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + async def rate_limited_call(): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise DummyHTTPError(retry_after=retry_after) + return "ok" + + monkeypatch.setattr("agentfield.rate_limiter.asyncio.sleep", fake_sleep) + monkeypatch.setattr( + random.Random, + "uniform", + lambda self, low, high: low if jitter_end == "low" else high, + ) + + assert await limiter.execute_with_retry(rate_limited_call) == "ok" + assert sleeps == [pytest.approx(expected_wait)] + + @pytest.mark.unit @pytest.mark.asyncio @pytest.mark.parametrize("retry_after", [-1.0, float("nan"), float("inf")])