Fix Groq circuit breaker: don't treat per-minute 429 as daily quota exhaustion
Browse filesThe circuit breaker was arming on any retry-after > 15s, but Groq sends
retry-after: 30-60s for normal per-minute rate limits. This caused
immediate "quota exhausted" on first use with many agents.
Now only circuit-breaks on retry-after > 120s or body containing
"daily"/"limit" keywords. Per-minute throttles wait and retry normally.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/soci/engine/llm.py +17 -13
src/soci/engine/llm.py
CHANGED
|
@@ -456,11 +456,11 @@ class GroqClient:
|
|
| 456 |
import time
|
| 457 |
return time.monotonic() < self._rate_limited_until
|
| 458 |
|
| 459 |
-
def _handle_429(self, retry_after_str: str, attempt: int) -> float:
|
| 460 |
"""Parse retry-after and update circuit breaker. Returns seconds to sleep.
|
| 461 |
|
| 462 |
-
Short waits (β€
|
| 463 |
-
Long waits (>
|
| 464 |
so the caller gives up immediately instead of blocking for minutes.
|
| 465 |
"""
|
| 466 |
import time
|
|
@@ -469,14 +469,20 @@ class GroqClient:
|
|
| 469 |
except (ValueError, TypeError):
|
| 470 |
retry_after = max(3.0, 2 ** attempt + 1)
|
| 471 |
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
| 473 |
self._rate_limited_until = time.monotonic() + retry_after
|
| 474 |
logger.warning(
|
| 475 |
-
f"Groq quota exhausted β skipping LLM calls for {retry_after:.0f}s "
|
| 476 |
f"(until quota resets). Simulation continues without LLM."
|
| 477 |
)
|
| 478 |
return 0.0 # caller should give up immediately
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
async def _wait_for_rate_limit(self) -> None:
|
| 482 |
"""Wait if needed to stay under the RPM limit."""
|
|
@@ -536,12 +542,11 @@ class GroqClient:
|
|
| 536 |
if e.response.status_code == 429:
|
| 537 |
body = e.response.text[:200] if e.response.text else ""
|
| 538 |
sleep_for = self._handle_429(
|
| 539 |
-
e.response.headers.get("retry-after", ""), attempt
|
| 540 |
)
|
| 541 |
-
logger.warning(f"Groq 429: {body[:120]}")
|
| 542 |
if sleep_for == 0:
|
| 543 |
-
self._last_error = f"429 quota exhausted: {body[:120]}"
|
| 544 |
-
return "" # quota exhausted β skip immediately
|
| 545 |
await asyncio.sleep(sleep_for)
|
| 546 |
elif e.response.status_code == 401:
|
| 547 |
raise ValueError("Invalid GROQ_API_KEY")
|
|
@@ -611,11 +616,10 @@ class GroqClient:
|
|
| 611 |
if e.response.status_code == 429:
|
| 612 |
body = e.response.text[:200] if e.response.text else ""
|
| 613 |
sleep_for = self._handle_429(
|
| 614 |
-
e.response.headers.get("retry-after", ""), attempt
|
| 615 |
)
|
| 616 |
-
logger.warning(f"Groq 429 (json): {body[:120]}")
|
| 617 |
if sleep_for == 0:
|
| 618 |
-
return {} # quota exhausted β skip immediately
|
| 619 |
await asyncio.sleep(sleep_for)
|
| 620 |
else:
|
| 621 |
logger.error(f"Groq JSON error: {e.response.status_code}")
|
|
|
|
| 456 |
import time
|
| 457 |
return time.monotonic() < self._rate_limited_until
|
| 458 |
|
| 459 |
+
def _handle_429(self, retry_after_str: str, attempt: int, body: str = "") -> float:
|
| 460 |
"""Parse retry-after and update circuit breaker. Returns seconds to sleep.
|
| 461 |
|
| 462 |
+
Short waits (β€120s, per-minute limit) β return the wait so caller retries.
|
| 463 |
+
Long waits (>120s, daily quota) β arm the circuit breaker and return 0
|
| 464 |
so the caller gives up immediately instead of blocking for minutes.
|
| 465 |
"""
|
| 466 |
import time
|
|
|
|
| 469 |
except (ValueError, TypeError):
|
| 470 |
retry_after = max(3.0, 2 ** attempt + 1)
|
| 471 |
|
| 472 |
+
# Only circuit-break on genuinely long waits (daily quota) or explicit quota messages.
|
| 473 |
+
# Groq can send retry-after: 30-60 for per-minute limits β those should just wait & retry.
|
| 474 |
+
is_daily_quota = retry_after > 120 or "daily" in body.lower() or "limit" in body.lower()
|
| 475 |
+
if is_daily_quota:
|
| 476 |
self._rate_limited_until = time.monotonic() + retry_after
|
| 477 |
logger.warning(
|
| 478 |
+
f"Groq daily quota exhausted β skipping LLM calls for {retry_after:.0f}s "
|
| 479 |
f"(until quota resets). Simulation continues without LLM."
|
| 480 |
)
|
| 481 |
return 0.0 # caller should give up immediately
|
| 482 |
+
# Per-minute throttle β wait and retry (cap at 60s to avoid blocking too long)
|
| 483 |
+
wait = min(retry_after, 60.0)
|
| 484 |
+
logger.info(f"Groq per-minute rate limit β waiting {wait:.0f}s before retry")
|
| 485 |
+
return wait
|
| 486 |
|
| 487 |
async def _wait_for_rate_limit(self) -> None:
|
| 488 |
"""Wait if needed to stay under the RPM limit."""
|
|
|
|
| 542 |
if e.response.status_code == 429:
|
| 543 |
body = e.response.text[:200] if e.response.text else ""
|
| 544 |
sleep_for = self._handle_429(
|
| 545 |
+
e.response.headers.get("retry-after", ""), attempt, body
|
| 546 |
)
|
|
|
|
| 547 |
if sleep_for == 0:
|
| 548 |
+
self._last_error = f"429 daily quota exhausted: {body[:120]}"
|
| 549 |
+
return "" # daily quota exhausted β skip immediately
|
| 550 |
await asyncio.sleep(sleep_for)
|
| 551 |
elif e.response.status_code == 401:
|
| 552 |
raise ValueError("Invalid GROQ_API_KEY")
|
|
|
|
| 616 |
if e.response.status_code == 429:
|
| 617 |
body = e.response.text[:200] if e.response.text else ""
|
| 618 |
sleep_for = self._handle_429(
|
| 619 |
+
e.response.headers.get("retry-after", ""), attempt, body
|
| 620 |
)
|
|
|
|
| 621 |
if sleep_for == 0:
|
| 622 |
+
return {} # daily quota exhausted β skip immediately
|
| 623 |
await asyncio.sleep(sleep_for)
|
| 624 |
else:
|
| 625 |
logger.error(f"Groq JSON error: {e.response.status_code}")
|