feat: outbound rate limiter with circuit breaker for Anthropic API
Token bucket at 40 RPM to stay under Max 5x plan ceilings (~50 RPM). Reads retry-after header from 429 responses to pause precisely. Circuit breaker trips after 3 consecutive 429s, pausing 30s before retrying. Stats exposed in /health endpoint.
This commit is contained in:
parent
ac5e207a73
commit
5c4d4700b3
1 changed files with 103 additions and 0 deletions
|
|
@ -66,6 +66,93 @@ from mnemosyne.tags import (
|
||||||
from mnemosyne.telemetry import Telemetry
|
from mnemosyne.telemetry import Telemetry
|
||||||
|
|
||||||
import re as _re
|
import re as _re
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Outbound Rate Limiter — token bucket with circuit breaker.
|
||||||
|
# Prevents hitting Anthropic's per-minute rate limits (especially on
|
||||||
|
# Max 5x plans with ~50 RPM ceilings).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundRateLimiter:
|
||||||
|
"""Token-bucket rate limiter with 429 circuit breaker.
|
||||||
|
|
||||||
|
- **Token bucket**: allows ``max_rpm`` requests per minute with burst
|
||||||
|
tolerance. Each ``acquire()`` call blocks until a token is available.
|
||||||
|
- **Retry-After**: when the upstream returns 429 with a ``retry-after``
|
||||||
|
header, the limiter pauses all outbound requests for that duration.
|
||||||
|
- **Circuit breaker**: after ``_CB_THRESHOLD`` consecutive 429s, the
|
||||||
|
limiter enters open state and pauses for ``_CB_COOLDOWN`` seconds
|
||||||
|
before allowing any new requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CB_THRESHOLD = 3 # consecutive 429s to trip the breaker
|
||||||
|
_CB_COOLDOWN = 30.0 # seconds to wait in open state
|
||||||
|
|
||||||
|
def __init__(self, max_rpm: int = 40):
|
||||||
|
self._max_rpm = max_rpm
|
||||||
|
self._interval = 60.0 / max_rpm # seconds between tokens
|
||||||
|
self._lock = _threading.Lock()
|
||||||
|
self._next_allowed = 0.0 # monotonic timestamp
|
||||||
|
self._consecutive_429s = 0
|
||||||
|
self._circuit_open_until = 0.0 # monotonic timestamp
|
||||||
|
|
||||||
|
def acquire(self) -> None:
|
||||||
|
"""Block until a request is allowed. Thread-safe."""
|
||||||
|
while True:
|
||||||
|
with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# Circuit breaker: if open, wait for cooldown
|
||||||
|
if now < self._circuit_open_until:
|
||||||
|
wait = self._circuit_open_until - now
|
||||||
|
else:
|
||||||
|
# Token bucket: wait until next allowed slot
|
||||||
|
if now >= self._next_allowed:
|
||||||
|
self._next_allowed = now + self._interval
|
||||||
|
return # token acquired
|
||||||
|
wait = self._next_allowed - now
|
||||||
|
self._next_allowed += self._interval
|
||||||
|
|
||||||
|
# Sleep outside the lock
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
def record_response(self, status_code: int, retry_after: float | None = None) -> None:
|
||||||
|
"""Record a response status. Call after every outbound request."""
|
||||||
|
with self._lock:
|
||||||
|
if status_code == 429:
|
||||||
|
self._consecutive_429s += 1
|
||||||
|
|
||||||
|
# Honor retry-after header
|
||||||
|
if retry_after and retry_after > 0:
|
||||||
|
pause_until = time.monotonic() + retry_after
|
||||||
|
self._next_allowed = max(self._next_allowed, pause_until)
|
||||||
|
|
||||||
|
# Circuit breaker
|
||||||
|
if self._consecutive_429s >= self._CB_THRESHOLD:
|
||||||
|
self._circuit_open_until = time.monotonic() + self._CB_COOLDOWN
|
||||||
|
self._consecutive_429s = 0 # reset counter for next cycle
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[RateLimiter] CIRCUIT OPEN: {self._CB_THRESHOLD} consecutive 429s, "
|
||||||
|
f"pausing {self._CB_COOLDOWN}s",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._consecutive_429s = 0 # reset on any successful response
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"max_rpm": self._max_rpm,
|
||||||
|
"consecutive_429s": self._consecutive_429s,
|
||||||
|
"circuit_open": time.monotonic() < self._circuit_open_until,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SSE Cleanup Filter — strips <memory_cleanup> / <yuyay-response> tags from
|
# SSE Cleanup Filter — strips <memory_cleanup> / <yuyay-response> tags from
|
||||||
|
|
@ -1542,6 +1629,7 @@ def create_app(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
app.state.clients = clients
|
app.state.clients = clients
|
||||||
|
app.state.rate_limiter = OutboundRateLimiter(max_rpm=40)
|
||||||
|
|
||||||
# ── Endpoints ────────────────────────────────────────────────────
|
# ── Endpoints ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -1569,6 +1657,7 @@ def create_app(
|
||||||
"openai": openai_model_override,
|
"openai": openai_model_override,
|
||||||
},
|
},
|
||||||
"sessions": session_summaries,
|
"sessions": session_summaries,
|
||||||
|
"rate_limiter": app.state.rate_limiter.stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
|
|
@ -2464,10 +2553,17 @@ def create_app(
|
||||||
sse_buffer = bytearray()
|
sse_buffer = bytearray()
|
||||||
usage: dict[str, Any] = {}
|
usage: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
|
rate_limiter: OutboundRateLimiter = app.state.rate_limiter
|
||||||
|
rate_limiter.acquire()
|
||||||
with client.stream(
|
with client.stream(
|
||||||
"POST", upstream_path, json=outgoing_body, headers=headers
|
"POST", upstream_path, json=outgoing_body, headers=headers
|
||||||
) as resp:
|
) as resp:
|
||||||
status_code = resp.status_code
|
status_code = resp.status_code
|
||||||
|
retry_after = None
|
||||||
|
if status_code == 429:
|
||||||
|
ra = resp.headers.get("retry-after")
|
||||||
|
retry_after = float(ra) if ra else 1.0
|
||||||
|
rate_limiter.record_response(status_code, retry_after)
|
||||||
if status_code >= 400:
|
if status_code >= 400:
|
||||||
body = resp.read()
|
body = resp.read()
|
||||||
bytes_out = len(body)
|
bytes_out = len(body)
|
||||||
|
|
@ -2547,7 +2643,14 @@ def create_app(
|
||||||
|
|
||||||
# Non-streaming
|
# Non-streaming
|
||||||
try:
|
try:
|
||||||
|
rate_limiter: OutboundRateLimiter = app.state.rate_limiter
|
||||||
|
rate_limiter.acquire()
|
||||||
resp = client.post(upstream_path, json=outgoing_body, headers=headers)
|
resp = client.post(upstream_path, json=outgoing_body, headers=headers)
|
||||||
|
retry_after = None
|
||||||
|
if resp.status_code == 429:
|
||||||
|
ra = resp.headers.get("retry-after")
|
||||||
|
retry_after = float(ra) if ra else 1.0
|
||||||
|
rate_limiter.record_response(resp.status_code, retry_after)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
emit_event(
|
emit_event(
|
||||||
"provider_error",
|
"provider_error",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue