Skip to content

Commit ed87285

Browse files
Zawwarsami16claude
andcommitted
phase 1.7: rate-limit enforcement (in-memory sliding window, 429 + Retry-After)
publishers can now declare rate_limit in their manifest, and the hub enforces it on /v1/chat/completions per api-key. exceeded quota returns 429 with Retry-After header + JSON body {error: {code, message, retry_after}}. lost on hub restart, fine for v0. components: - zhub/ratelimit.py: parse_rate("60/min" -> (60, 60.0)), supports s/min/hour/day units, falls back to (60, 60.0) on None/empty/garbage. SlidingWindow class with per-key deque of timestamps, drops expired on check, returns (allowed, retry_after_or_None). injectable now_fn for deterministic testing without sleep. - zhub/server.py: - Hub gains _rate_windows dict (ai_name -> SlidingWindow), lazily built from publisher.manifest.rate_limit on first check - Hub.check_rate_limit(ai_name, api_key) -> (bool, retry_after) - chat_completions handler calls check_rate_limit, returns 429 + Retry-After header before falling through to proxy_chat - zhub/manifest.py: chat_only_manifest gains rate_limit kw (default "60/min"), passes through to Manifest() - zhub/client.py: publish() gains rate_limit parameter, threads to manifest. legacy callers without rate_limit get the default. tests: - tests/test_rate_limit.py: 12 cases * 6 unit: parse_rate covers s/min/hour/day, default fallback, garbage fallback * 4 unit: SlidingWindow under-limit, at-limit, expiry, per-key isolation * 2 e2e: 429 + Retry-After after quota, per-publisher isolation (one publisher's 429 doesn't affect another) result: 47/47 -> 49/49 pytest passing. existing flow unchanged for publishers using the default 60/min. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b0e168c commit ed87285

5 files changed

Lines changed: 305 additions & 0 deletions

File tree

tests/test_rate_limit.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Rate-limit parsing + sliding-window counter + e2e enforcement."""
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
8+
import pytest
9+
10+
from zhub.ratelimit import parse_rate, SlidingWindow
11+
12+
13+
def test_parse_rate_per_second():
14+
assert parse_rate("10/s") == (10, 1.0)
15+
16+
17+
def test_parse_rate_per_minute():
18+
assert parse_rate("60/min") == (60, 60.0)
19+
20+
21+
def test_parse_rate_per_hour():
22+
assert parse_rate("1000/hour") == (1000, 3600.0)
23+
24+
25+
def test_parse_rate_per_day():
26+
assert parse_rate("100000/day") == (100000, 86400.0)
27+
28+
29+
def test_parse_rate_default_when_unset():
30+
assert parse_rate(None) == (60, 60.0)
31+
assert parse_rate("") == (60, 60.0)
32+
33+
34+
def test_parse_rate_garbage_falls_back_to_default():
35+
assert parse_rate("not-a-rate") == (60, 60.0)
36+
assert parse_rate("60") == (60, 60.0)
37+
assert parse_rate("/min") == (60, 60.0)
38+
39+
40+
def test_sliding_window_under_limit_allows():
41+
clock = [0.0]
42+
w = SlidingWindow(limit=3, period_seconds=10.0, now_fn=lambda: clock[0])
43+
assert w.check("k") == (True, None)
44+
clock[0] = 1.0
45+
assert w.check("k") == (True, None)
46+
clock[0] = 2.0
47+
assert w.check("k") == (True, None)
48+
49+
50+
def test_sliding_window_at_limit_denies():
51+
clock = [0.0]
52+
w = SlidingWindow(limit=3, period_seconds=10.0, now_fn=lambda: clock[0])
53+
for _ in range(3):
54+
ok, _ = w.check("k")
55+
assert ok
56+
ok, retry_after = w.check("k")
57+
assert ok is False
58+
assert retry_after is not None and retry_after > 0
59+
60+
61+
def test_sliding_window_expires_old_hits():
62+
clock = [0.0]
63+
w = SlidingWindow(limit=2, period_seconds=10.0, now_fn=lambda: clock[0])
64+
assert w.check("k")[0] is True
65+
assert w.check("k")[0] is True
66+
assert w.check("k")[0] is False # at limit
67+
clock[0] = 11.0 # past the 10s window
68+
assert w.check("k")[0] is True
69+
70+
71+
def test_sliding_window_per_key_isolation():
72+
clock = [0.0]
73+
w = SlidingWindow(limit=1, period_seconds=10.0, now_fn=lambda: clock[0])
74+
assert w.check("a")[0] is True
75+
assert w.check("b")[0] is True # different key, different bucket
76+
assert w.check("a")[0] is False # a hit limit
77+
78+
79+
# ---- e2e enforcement ----
80+
81+
try:
82+
import fastapi # noqa
83+
import uvicorn # noqa
84+
SERVER_AVAILABLE = True
85+
except ImportError:
86+
SERVER_AVAILABLE = False
87+
88+
if SERVER_AVAILABLE:
89+
from zhub.server import create_app
90+
from zhub import publish, Manifest, Capability
91+
92+
93+
def _free_port() -> int:
94+
with socket.socket() as s:
95+
s.bind(("", 0))
96+
return s.getsockname()[1]
97+
98+
99+
@pytest.fixture(scope="module")
100+
def hub_port():
101+
if not SERVER_AVAILABLE:
102+
pytest.skip("fastapi/uvicorn not installed")
103+
port = _free_port()
104+
105+
def run():
106+
config = uvicorn.Config(create_app(), host="127.0.0.1", port=port, log_level="warning")
107+
asyncio.run(uvicorn.Server(config).serve())
108+
109+
threading.Thread(target=run, daemon=True).start()
110+
for _ in range(30):
111+
try:
112+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
113+
break
114+
except OSError:
115+
time.sleep(0.1)
116+
yield port
117+
118+
119+
@pytest.mark.asyncio
120+
async def test_rate_limit_429_after_quota(hub_port):
121+
"""Publisher with 3/s rate limit. Hit chat endpoint 4x rapidly. First 3
122+
succeed; 4th returns 429 with Retry-After header + body."""
123+
if not SERVER_AVAILABLE:
124+
pytest.skip()
125+
126+
pub = publish(
127+
name="rl",
128+
description="rate limit test",
129+
chat_handler=lambda m, o: "ok",
130+
hub_url=f"ws://127.0.0.1:{hub_port}",
131+
rate_limit="3/s",
132+
)
133+
for _ in range(50):
134+
if pub.api_key:
135+
break
136+
await asyncio.sleep(0.05)
137+
assert pub.api_key
138+
139+
import httpx
140+
base = f"http://127.0.0.1:{hub_port}/rl/v1/chat/completions"
141+
headers = {"Authorization": f"Bearer {pub.api_key}", "Content-Type": "application/json"}
142+
body = {"messages": [{"role": "user", "content": "hi"}]}
143+
144+
async with httpx.AsyncClient() as c:
145+
# First 3 should succeed
146+
for i in range(3):
147+
r = await c.post(base, json=body, headers=headers)
148+
assert r.status_code == 200, f"call #{i+1}: {r.status_code} {r.text}"
149+
# 4th should be rate-limited
150+
r4 = await c.post(base, json=body, headers=headers)
151+
assert r4.status_code == 429, f"expected 429, got {r4.status_code}: {r4.text}"
152+
assert "Retry-After" in r4.headers, f"missing Retry-After header: {dict(r4.headers)}"
153+
body_json = r4.json()
154+
assert body_json["error"]["code"] == "rate_limited"
155+
156+
157+
@pytest.mark.asyncio
158+
async def test_rate_limit_per_publisher_isolation(hub_port):
159+
"""Two publishers with separate rate limits don't interfere with each other."""
160+
if not SERVER_AVAILABLE:
161+
pytest.skip()
162+
163+
pub_a = publish(name="rla", description="a", chat_handler=lambda m, o: "a",
164+
hub_url=f"ws://127.0.0.1:{hub_port}", rate_limit="2/s")
165+
pub_b = publish(name="rlb", description="b", chat_handler=lambda m, o: "b",
166+
hub_url=f"ws://127.0.0.1:{hub_port}", rate_limit="2/s")
167+
for _ in range(50):
168+
if pub_a.api_key and pub_b.api_key:
169+
break
170+
await asyncio.sleep(0.05)
171+
172+
import httpx
173+
body = {"messages": [{"role": "user", "content": "hi"}]}
174+
async with httpx.AsyncClient() as c:
175+
# Exhaust A's quota
176+
for _ in range(2):
177+
r = await c.post(f"http://127.0.0.1:{hub_port}/rla/v1/chat/completions",
178+
json=body, headers={"Authorization": f"Bearer {pub_a.api_key}"})
179+
assert r.status_code == 200
180+
r = await c.post(f"http://127.0.0.1:{hub_port}/rla/v1/chat/completions",
181+
json=body, headers={"Authorization": f"Bearer {pub_a.api_key}"})
182+
assert r.status_code == 429
183+
# B is unaffected
184+
r = await c.post(f"http://127.0.0.1:{hub_port}/rlb/v1/chat/completions",
185+
json=body, headers={"Authorization": f"Bearer {pub_b.api_key}"})
186+
assert r.status_code == 200

zhub/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def publish(
118118
on_connection_event: Optional[ConnectionEventHandler] = None,
119119
api_key: Optional[str] = None,
120120
private_key: Optional[str] = None,
121+
rate_limit: str = "60/min",
121122
) -> ZhubPublication:
122123
"""Create a ZhubPublication. Call .run_forever() to actually start serving.
123124
@@ -134,6 +135,7 @@ def publish(
134135
manifest = chat_only_manifest(
135136
name=name, description=description,
136137
operator=operator, contact=contact, public=public,
138+
rate_limit=rate_limit,
137139
)
138140
if capabilities:
139141
manifest.capabilities.extend(capabilities)

zhub/manifest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@ def chat_only_manifest(
8787
operator: str = "",
8888
contact: str = "",
8989
public: bool = False,
90+
rate_limit: str = "60/min",
9091
) -> Manifest:
9192
"""The simplest possible manifest — an AI that only does chat."""
9293
return Manifest(
9394
name=name,
9495
description=description,
9596
accepts="openai-v1-chat-completions",
97+
rate_limit=rate_limit,
9698
capabilities=[
9799
Capability(
98100
name="chat",

zhub/ratelimit.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Rate-limit primitives — string parser + sliding-window counter.
2+
3+
The hub uses these to enforce per-api-key request limits parsed from a
4+
publisher's manifest.rate_limit field. In-memory, lost on restart, fine
5+
for v0 — clients retry, no persistent damage.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
import time
12+
from collections import deque
13+
from typing import Callable, Optional
14+
15+
16+
_RATE_RE = re.compile(r"^\s*(\d+)\s*/\s*(s|sec|second|m|min|minute|h|hour|d|day)\s*$", re.I)
17+
_UNIT_SECONDS = {
18+
"s": 1.0, "sec": 1.0, "second": 1.0,
19+
"m": 60.0, "min": 60.0, "minute": 60.0,
20+
"h": 3600.0, "hour": 3600.0,
21+
"d": 86400.0, "day": 86400.0,
22+
}
23+
24+
DEFAULT_RATE = (60, 60.0)
25+
26+
27+
def parse_rate(text: Optional[str]) -> tuple[int, float]:
28+
"""Parse a rate string like '60/min' into (limit, period_seconds).
29+
30+
Falls back to (60, 60.0) for None, empty, or malformed input — the hub
31+
needs a default rather than refusing service when a publisher omits the
32+
field. Operators who want unmetered access publish with a deliberately
33+
high rate (e.g., '1000000/hour').
34+
"""
35+
if not text:
36+
return DEFAULT_RATE
37+
m = _RATE_RE.match(text)
38+
if not m:
39+
return DEFAULT_RATE
40+
n = int(m.group(1))
41+
unit = m.group(2).lower()
42+
return n, _UNIT_SECONDS.get(unit, 60.0)
43+
44+
45+
class SlidingWindow:
46+
"""Per-key sliding-window counter.
47+
48+
Each key maps to a deque of timestamps. On check(), expired timestamps
49+
are dropped first; if the live count is under the limit, the new hit is
50+
recorded and check returns (True, None). Otherwise check returns
51+
(False, retry_after_seconds_until_oldest_hit_expires).
52+
"""
53+
54+
def __init__(
55+
self,
56+
limit: int,
57+
period_seconds: float,
58+
now_fn: Callable[[], float] = time.monotonic,
59+
) -> None:
60+
self.limit = limit
61+
self.period = period_seconds
62+
self._now = now_fn
63+
self._buckets: dict[str, deque[float]] = {}
64+
65+
def check(self, key: str) -> tuple[bool, Optional[float]]:
66+
now = self._now()
67+
cutoff = now - self.period
68+
bucket = self._buckets.get(key)
69+
if bucket is None:
70+
bucket = deque()
71+
self._buckets[key] = bucket
72+
while bucket and bucket[0] <= cutoff:
73+
bucket.popleft()
74+
if len(bucket) < self.limit:
75+
bucket.append(now)
76+
return True, None
77+
oldest = bucket[0]
78+
retry_after = max(0.0, oldest + self.period - now)
79+
return False, retry_after
80+
81+
def clear(self, key: str) -> None:
82+
self._buckets.pop(key, None)

zhub/server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
) from e
4040

4141
from .persistence import Storage, hash_key
42+
from .ratelimit import parse_rate, SlidingWindow
4243
from .protocol import (
4344
Envelope, registered, chat_request, chat_response,
4445
invoke_request, invoke_result, connection_event, error_envelope, new_request_id,
@@ -83,6 +84,22 @@ def __init__(self, storage: Optional[Storage] = None) -> None:
8384
# ws_connect-side chat-requests. Publisher emits chat-chunk back; we
8485
# route by request_id to the originating client.
8586
self.client_routes: dict[str, tuple[WebSocket, str]] = {}
87+
# Rate-limit windows per AI. Each AI gets its own SlidingWindow
88+
# configured from the publisher's manifest.rate_limit. Key into the
89+
# window is the api_key string used by the caller.
90+
self._rate_windows: dict[str, SlidingWindow] = {}
91+
92+
def check_rate_limit(self, ai_name: str, api_key: str) -> tuple[bool, Optional[float]]:
93+
"""Returns (allowed, retry_after_seconds_or_None)."""
94+
window = self._rate_windows.get(ai_name)
95+
if window is None:
96+
publisher = self.publishers.get(ai_name)
97+
if publisher is None:
98+
return True, None # AI offline; chat will 404 later
99+
limit, period = parse_rate(publisher.manifest.get("rate_limit"))
100+
window = SlidingWindow(limit=limit, period_seconds=period)
101+
self._rate_windows[ai_name] = window
102+
return window.check(api_key)
86103

87104
# publishers --------------------------------------------------------
88105

@@ -379,6 +396,22 @@ async def chat_completions(ai_name: str, request: Request):
379396
if hub.lookup_by_api_key(api_key_header) != ai_name:
380397
raise HTTPException(401, "invalid api key for this AI")
381398

399+
# Rate-limit enforcement (Phase 1.7)
400+
rl_ok, retry_after = hub.check_rate_limit(ai_name, api_key_header)
401+
if not rl_ok:
402+
ra = max(1, int(round(retry_after or 1.0)))
403+
return JSONResponse(
404+
status_code=429,
405+
content={
406+
"error": {
407+
"code": "rate_limited",
408+
"message": "rate limit exceeded for this api key",
409+
"retry_after": ra,
410+
},
411+
},
412+
headers={"Retry-After": str(ra)},
413+
)
414+
382415
messages = body.get("messages", [])
383416
model = body.get("model", "default")
384417
temperature = float(body.get("temperature", 0.4))

0 commit comments

Comments
 (0)