Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions sdk/python/agentfield/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import asyncio
import hashlib
import math
import os
import random
import time
from typing import Any, Optional
from agentfield.logger import log_debug


_MAX_RETRY_AFTER_SECONDS = 300.0


class RateLimitError(Exception):
"""Custom exception for rate limit errors"""

Expand Down Expand Up @@ -138,9 +142,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:
base_delay = retry_after
# 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 = 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)
Expand All @@ -154,8 +161,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"
Expand Down
11 changes: 7 additions & 4 deletions sdk/python/agentfield/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down Expand Up @@ -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, up to 5 minutes or this value if larger."
),
)
rate_limit_jitter_factor: float = Field(
default=0.25,
Expand Down
92 changes: 92 additions & 0 deletions sdk/python/tests/test_rate_limiter_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,98 @@ 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(
"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")])
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):
Expand Down
Loading