RayMelius Claude Opus 4.6 commited on
Commit
b709098
Β·
1 Parent(s): 78090d6

Fix Groq circuit breaker: don't treat per-minute 429 as daily quota exhaustion

Browse files

The 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>

Files changed (1) hide show
  1. 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 (≀15s, per-minute limit) β†’ return the wait so caller retries.
463
- Long waits (>15s, 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,14 +469,20 @@ class GroqClient:
469
  except (ValueError, TypeError):
470
  retry_after = max(3.0, 2 ** attempt + 1)
471
 
472
- if retry_after > 15:
 
 
 
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
- return retry_after # short per-minute limit β€” wait and retry
 
 
 
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}")