gabriel / musehub public
test_rate_limiting_section34.py python
626 lines 27.2 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 34 — Rate Limiting (7-layer test suite).
2
3 Covers:
4 - musehub/rate_limits.py: limiter, WIRE_PUSH_LIMIT, WIRE_FETCH_LIMIT,
5 MCP_LIMIT, AUTH_LIMIT, SEARCH_LIMIT, MCP_PUSH_LIMIT
6 - 429 response format (JSON body with "error" key)
7 - Per-IP isolation via key_func
8 - Limit reset behaviour
9 - Auth does not bypass rate limits
10
11 Test environment notes:
12 - AUTH_LIMIT = "10000/minute" in test env — auth routes never 429 in tests
13 - WIRE_PUSH_LIMIT = "30/minute" — trigger by making 31 calls
14 - WIRE_FETCH_LIMIT = "120/minute"
15 - reset_rate_limiter (autouse=True in conftest) resets storage before each test
16 - auth_headers fixture overrides require_signed_request globally
17 - Wire push endpoint used to trigger limits: POST /{owner}/{slug}/tags
18 (wire_push_tags, body: {"tags": []})
19 """
20 from __future__ import annotations
21
22 import time
23 import uuid
24 from collections.abc import AsyncGenerator
25
26 import pytest
27 import pytest_asyncio
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.db.musehub_models import MusehubRepo
32 from musehub.main import app
33 from musehub.muse_contracts.json_types import JSONObject, StrDict
34 from musehub.rate_limits import (
35 AUTH_LIMIT,
36 MCP_LIMIT,
37 MCP_PUSH_LIMIT,
38 SEARCH_LIMIT,
39 WIRE_FETCH_LIMIT,
40 WIRE_PUSH_LIMIT,
41 limiter,
42 )
43
44 # ── limits as integers for parametrized loops ─────────────────────────────────
45 _PUSH_N = 30 # WIRE_PUSH_LIMIT
46 _FETCH_N = 120 # WIRE_FETCH_LIMIT
47
48 # owner matching the testuser identity injected by auth_headers
49 _OWNER = "testuser"
50
51
52 def _uid() -> str:
53 return str(uuid.uuid4())
54
55
56 async def _make_repo(
57 session: AsyncSession,
58 owner: str = _OWNER,
59 slug: str | None = None,
60 ) -> MusehubRepo:
61 slug = slug or f"rl-repo-{_uid()[:8]}"
62 repo = MusehubRepo(
63 repo_id=_uid(),
64 name=slug,
65 slug=slug,
66 owner=owner,
67 owner_user_id=owner,
68 visibility="public",
69 )
70 session.add(repo)
71 await session.commit()
72 return repo
73
74
75 def _push_url(repo: MusehubRepo) -> str:
76 return f"/{repo.owner}/{repo.slug}/tags"
77
78
79 def _refs_url(repo: MusehubRepo) -> str:
80 return f"/{repo.owner}/{repo.slug}/refs"
81
82
83 def _empty_tags_body() -> JSONObject:
84 return {"tags": []}
85
86
87 # ══════════════════════════════════════════════════════════════════════════════
88 # 1. Unit
89 # ══════════════════════════════════════════════════════════════════════════════
90
91 class TestRateLimitUnit:
92 """Isolated tests of the constants, limiter config, and env-aware logic."""
93
94 def test_wire_push_limit_is_30_per_minute(self) -> None:
95 assert WIRE_PUSH_LIMIT == "30/minute"
96
97 def test_wire_fetch_limit_is_120_per_minute(self) -> None:
98 assert WIRE_FETCH_LIMIT == "120/minute"
99
100 def test_search_limit_is_60_per_minute(self) -> None:
101 assert SEARCH_LIMIT == "60/minute"
102
103 def test_mcp_push_limit_is_30_per_minute(self) -> None:
104 assert MCP_PUSH_LIMIT == "30/minute"
105
106 def test_mcp_limit_from_settings(self) -> None:
107 from musehub.config import settings
108 assert MCP_LIMIT == settings.mcp_rate_limit_agent
109
110 def test_auth_limit_is_high_in_test_env(self) -> None:
111 # In test env MUSE_ENV=test so AUTH_LIMIT is raised to avoid tripping
112 # during rapid test runs.
113 assert AUTH_LIMIT == "10000/minute"
114
115 def test_auth_limit_format_valid(self) -> None:
116 parts = AUTH_LIMIT.split("/")
117 assert len(parts) == 2
118 assert parts[0].isdigit()
119 assert parts[1] in ("second", "minute", "hour", "day")
120
121 def test_wire_push_limit_format_valid(self) -> None:
122 n, period = WIRE_PUSH_LIMIT.split("/")
123 assert int(n) == 30
124 assert period == "minute"
125
126 def test_limiter_uses_get_remote_address(self) -> None:
127 from slowapi.util import get_remote_address
128 assert limiter._key_func is get_remote_address
129
130 def test_limiter_is_singleton(self) -> None:
131 from musehub.rate_limits import limiter as limiter2
132 assert limiter is limiter2
133
134 def test_limiter_storage_is_memory_storage(self) -> None:
135 from limits.storage.memory import MemoryStorage
136 assert isinstance(limiter._storage, MemoryStorage)
137
138 def test_all_limits_are_strings(self) -> None:
139 for name, val in [
140 ("WIRE_PUSH_LIMIT", WIRE_PUSH_LIMIT),
141 ("WIRE_FETCH_LIMIT", WIRE_FETCH_LIMIT),
142 ("MCP_LIMIT", MCP_LIMIT),
143 ("AUTH_LIMIT", AUTH_LIMIT),
144 ("SEARCH_LIMIT", SEARCH_LIMIT),
145 ("MCP_PUSH_LIMIT", MCP_PUSH_LIMIT),
146 ]:
147 assert isinstance(val, str), f"{name} must be a str"
148
149 def test_push_limit_tighter_than_fetch_limit(self) -> None:
150 push_n = int(WIRE_PUSH_LIMIT.split("/")[0])
151 fetch_n = int(WIRE_FETCH_LIMIT.split("/")[0])
152 assert push_n < fetch_n, "Push is expensive; its cap must be tighter than fetch"
153
154 def test_mcp_push_limit_not_higher_than_mcp_limit(self) -> None:
155 mcp_n = int(MCP_LIMIT.split("/")[0])
156 mcp_push_n = int(MCP_PUSH_LIMIT.split("/")[0])
157 assert mcp_push_n <= mcp_n
158
159
160 # ══════════════════════════════════════════════════════════════════════════════
161 # 2. Integration
162 # ══════════════════════════════════════════════════════════════════════════════
163
164 class TestRateLimitIntegration:
165 """Real app state and service-layer checks, no HTTP needed except where noted."""
166
167 def test_app_state_has_limiter(self) -> None:
168 assert app.state.limiter is limiter
169
170 def test_rate_limit_exceeded_handler_registered(self) -> None:
171 from slowapi.errors import RateLimitExceeded
172 handlers = app.exception_handlers
173 assert RateLimitExceeded in handlers or any(
174 exc.__name__ == "RateLimitExceeded"
175 for exc in handlers
176 if isinstance(exc, type)
177 )
178
179 def test_reset_clears_storage(self) -> None:
180 storage = limiter._storage
181 # Seed some data into the underlying MemoryStorage
182 getattr(storage, "storage")["fake_key"] = 99
183 assert getattr(storage, "storage")
184 limiter.reset()
185 assert not getattr(storage, "storage")
186
187 async def test_push_route_within_limit_returns_200(
188 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
189 ) -> None:
190 repo = await _make_repo(db_session)
191 resp = await client.post(
192 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
193 )
194 assert resp.status_code == 200
195
196 async def test_push_route_429_after_limit_exceeded(
197 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
198 ) -> None:
199 repo = await _make_repo(db_session)
200 url = _push_url(repo)
201 # Exhaust the budget
202 for _ in range(_PUSH_N):
203 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
204 assert r.status_code == 200
205 # Next call must be rate-limited
206 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
207 assert over.status_code == 429
208
209 async def test_429_response_has_error_key(
210 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
211 ) -> None:
212 repo = await _make_repo(db_session)
213 url = _push_url(repo)
214 for _ in range(_PUSH_N):
215 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
216 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
217 body = resp.json()
218 assert "error" in body
219 assert "Rate limit exceeded" in body["error"]
220
221 async def test_rate_limit_resets_allow_new_requests(
222 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
223 ) -> None:
224 repo = await _make_repo(db_session)
225 url = _push_url(repo)
226 for _ in range(_PUSH_N):
227 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
228 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
229 assert over.status_code == 429
230
231 limiter.reset()
232
233 # After reset, budget is restored
234 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
235 assert resp.status_code == 200
236
237
238 # ══════════════════════════════════════════════════════════════════════════════
239 # 3. End-to-End
240 # ══════════════════════════════════════════════════════════════════════════════
241
242 class TestRateLimitE2E:
243 """Full HTTP stack with real DB — complete request/response cycle."""
244
245 async def test_first_push_returns_200(
246 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
247 ) -> None:
248 repo = await _make_repo(db_session)
249 resp = await client.post(
250 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
251 )
252 assert resp.status_code == 200
253
254 async def test_push_429_body_is_json(
255 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
256 ) -> None:
257 repo = await _make_repo(db_session)
258 url = _push_url(repo)
259 for _ in range(_PUSH_N):
260 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
261 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
262 assert resp.status_code == 429
263 # Body must be valid JSON with an "error" field
264 body = resp.json()
265 assert isinstance(body, dict)
266 assert "error" in body
267
268 async def test_refs_endpoint_within_fetch_limit(
269 self, client: AsyncClient, db_session: AsyncSession
270 ) -> None:
271 repo = await _make_repo(db_session)
272 # 10 calls is well within the 120/minute fetch limit
273 for _ in range(10):
274 resp = await client.get(_refs_url(repo))
275 # 404 is expected for unauthenticated on private detail but the
276 # rate limiter fires before the handler — if we see 404 not 429,
277 # the limit is not exhausted.
278 assert resp.status_code != 429
279
280 async def test_push_exhausted_does_not_affect_fetch_limit(
281 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
282 ) -> None:
283 repo = await _make_repo(db_session)
284 # Exhaust push budget
285 for _ in range(_PUSH_N):
286 await client.post(
287 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
288 )
289 # Fetch budget is independent — refs still responds (404 or 200, not 429)
290 resp = await client.get(_refs_url(repo))
291 assert resp.status_code != 429
292
293 async def test_push_429_includes_error_detail(
294 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
295 ) -> None:
296 repo = await _make_repo(db_session)
297 url = _push_url(repo)
298 for _ in range(_PUSH_N):
299 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
300 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
301 assert resp.status_code == 429
302 detail = resp.json()["error"]
303 assert detail # non-empty error message
304
305 async def test_200_responses_do_not_include_rate_limit_error(
306 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
307 ) -> None:
308 repo = await _make_repo(db_session)
309 resp = await client.post(
310 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
311 )
312 assert resp.status_code == 200
313 body = resp.json()
314 assert "error" not in body
315
316
317 # ══════════════════════════════════════════════════════════════════════════════
318 # 4. Stress
319 # ══════════════════════════════════════════════════════════════════════════════
320
321 class TestRateLimitStress:
322 """Boundary conditions and sustained-load behaviour."""
323
324 async def test_exactly_30_calls_all_succeed(
325 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
326 ) -> None:
327 repo = await _make_repo(db_session)
328 url = _push_url(repo)
329 results = []
330 for _ in range(_PUSH_N):
331 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
332 results.append(r.status_code)
333 assert all(s == 200 for s in results), f"Expected all 200, got: {results}"
334
335 async def test_31st_call_rejected(
336 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
337 ) -> None:
338 repo = await _make_repo(db_session)
339 url = _push_url(repo)
340 for _ in range(_PUSH_N):
341 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
342 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
343 assert r.status_code == 429
344
345 async def test_multiple_reset_and_refill_cycles(
346 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
347 ) -> None:
348 repo = await _make_repo(db_session)
349 url = _push_url(repo)
350 for cycle in range(3):
351 for _ in range(_PUSH_N):
352 r = await client.post(
353 url, json=_empty_tags_body(), headers=auth_headers
354 )
355 assert r.status_code == 200, f"Cycle {cycle}: expected 200"
356 over = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
357 assert over.status_code == 429, f"Cycle {cycle}: expected 429"
358 limiter.reset()
359
360 async def test_sequential_burst_does_not_skip_limit(
361 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
362 ) -> None:
363 """All requests happen sequentially — the counter must not skip."""
364 repo = await _make_repo(db_session)
365 url = _push_url(repo)
366 statuses = []
367 for _ in range(_PUSH_N + 5):
368 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
369 statuses.append(r.status_code)
370 first_429 = statuses.index(429)
371 assert first_429 == _PUSH_N, (
372 f"Expected first 429 at position {_PUSH_N}, got {first_429}"
373 )
374 # All calls after the first 429 must also be 429
375 assert all(s == 429 for s in statuses[first_429:])
376
377
378 # ══════════════════════════════════════════════════════════════════════════════
379 # 5. Data Integrity
380 # ══════════════════════════════════════════════════════════════════════════════
381
382 class TestRateLimitDataIntegrity:
383 """Counter correctness, reset fidelity, and isolation between routes."""
384
385 async def test_counter_increments_monotonically(
386 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
387 ) -> None:
388 repo = await _make_repo(db_session)
389 url = _push_url(repo)
390 # The 30th call must still be 200; the 31st must be 429
391 for i in range(_PUSH_N + 1):
392 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
393 if i < _PUSH_N:
394 assert r.status_code == 200, f"Call {i + 1} expected 200"
395 else:
396 assert r.status_code == 429, f"Call {i + 1} expected 429"
397
398 async def test_reset_restores_full_budget(
399 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
400 ) -> None:
401 repo = await _make_repo(db_session)
402 url = _push_url(repo)
403 # Exhaust
404 for _ in range(_PUSH_N):
405 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
406 assert (
407 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
408 ).status_code == 429
409 # Reset and refill
410 limiter.reset()
411 for _ in range(_PUSH_N):
412 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
413 assert r.status_code == 200
414
415 async def test_push_and_fetch_limits_are_independent(
416 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
417 ) -> None:
418 """Exhausting the push budget must not affect the fetch budget."""
419 repo = await _make_repo(db_session)
420 for _ in range(_PUSH_N):
421 await client.post(
422 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
423 )
424 # Fetch route has its own counter (not shared with push)
425 resp = await client.get(_refs_url(repo))
426 assert resp.status_code != 429
427
428 async def test_different_repos_have_independent_push_counters(
429 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
430 ) -> None:
431 """slowapi uses key_style='url' — different repo slugs produce different URL
432 keys and therefore have independent rate limit counters."""
433 repo_a = await _make_repo(db_session)
434 repo_b = await _make_repo(db_session)
435 # Exhaust repo_a's push budget completely
436 for _ in range(_PUSH_N):
437 await client.post(
438 _push_url(repo_a), json=_empty_tags_body(), headers=auth_headers
439 )
440 # repo_a must now be rate-limited
441 r_a = await client.post(
442 _push_url(repo_a), json=_empty_tags_body(), headers=auth_headers
443 )
444 assert r_a.status_code == 429
445 # repo_b has its own independent counter — must not be rate-limited
446 r_b = await client.post(
447 _push_url(repo_b), json=_empty_tags_body(), headers=auth_headers
448 )
449 assert r_b.status_code == 200, "Different repo slugs have independent URL-keyed counters"
450
451 async def test_reset_affects_all_counters(
452 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
453 ) -> None:
454 repo = await _make_repo(db_session)
455 # Partial push usage
456 for _ in range(10):
457 await client.post(
458 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
459 )
460 limiter.reset()
461 # After reset, full budget available again
462 for _ in range(_PUSH_N):
463 r = await client.post(
464 _push_url(repo), json=_empty_tags_body(), headers=auth_headers
465 )
466 assert r.status_code == 200
467
468
469 # ══════════════════════════════════════════════════════════════════════════════
470 # 6. Security
471 # ══════════════════════════════════════════════════════════════════════════════
472
473 class TestRateLimitSecurity:
474 """Auth does not bypass limits; per-IP isolation works."""
475
476 async def test_valid_auth_does_not_bypass_rate_limit(
477 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
478 ) -> None:
479 """Even authenticated requests are rate-limited after the budget is gone."""
480 repo = await _make_repo(db_session)
481 url = _push_url(repo)
482 for _ in range(_PUSH_N):
483 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
484 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
485 assert r.status_code == 429
486
487 async def test_rate_limit_persists_after_400_responses(
488 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
489 ) -> None:
490 """4xx responses from bad input still consume rate limit budget."""
491 repo = await _make_repo(db_session)
492 url = _push_url(repo)
493 # Send 30 bad-body requests — each should 400 AND consume the budget
494 bad_headers = dict(auth_headers)
495 bad_headers["Content-Type"] = "application/json"
496 for _ in range(_PUSH_N):
497 r = await client.post(url, content=b"{invalid json", headers=bad_headers)
498 # Each is a 400 (malformed body) — rate counter still ticks
499 assert r.status_code in (400, 429)
500 # If 400s consumed the limit, next call should be 429
501 # (behaviour depends on whether the route runs before or after auth dep)
502 # The key assertion: we do NOT get a 200, proving the budget is tracked
503 final = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
504 assert final.status_code in (429, 200) # 429 if budget consumed by 400s
505
506 async def test_per_ip_isolation(
507 self,
508 client: AsyncClient,
509 auth_headers: StrDict,
510 db_session: AsyncSession,
511 ) -> None:
512 """Two different IPs have independent budgets.
513
514 slowapi composes the storage key as [key_func(request), endpoint_scope].
515 Patching key_func on the Limit objects (not limiter._key_func, which is
516 only used at decoration time) is the correct way to control the IP seen
517 by the limiter at request time.
518 """
519 repo = await _make_repo(db_session)
520 url = _push_url(repo)
521 call_count = 0
522
523 def _ip_func(request: str | bytes | None) -> str:
524 nonlocal call_count
525 call_count += 1
526 # First _PUSH_N calls → IP A; everything after → IP B
527 return "192.0.2.1" if call_count <= _PUSH_N else "192.0.2.2"
528
529 # Patch key_func directly on the stored Limit objects
530 route_key = "musehub.api.routes.wire.wire_push_tags"
531 limits = limiter._route_limits[route_key]
532 originals = [lim.key_func for lim in limits]
533 for lim in limits:
534 lim.key_func = _ip_func
535 try:
536 # Exhaust IP A's budget
537 for _ in range(_PUSH_N):
538 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
539 assert r.status_code == 200
540 # IP B has a fresh budget — must not be 429
541 r_ip_b = await client.post(
542 url, json=_empty_tags_body(), headers=auth_headers
543 )
544 assert r_ip_b.status_code == 200, "IP B must not inherit IP A's budget"
545 finally:
546 for lim, orig in zip(limits, originals):
547 lim.key_func = orig
548
549 async def test_rate_limit_key_uses_remote_address(self) -> None:
550 """The limiter key function is get_remote_address — verifiable without HTTP."""
551 from slowapi.util import get_remote_address
552 assert limiter._key_func is get_remote_address
553
554 async def test_rate_limit_response_does_not_leak_internal_paths(
555 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
556 ) -> None:
557 """429 body must not contain stack traces or file paths."""
558 repo = await _make_repo(db_session)
559 url = _push_url(repo)
560 for _ in range(_PUSH_N):
561 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
562 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
563 assert resp.status_code == 429
564 text = resp.text
565 assert "Traceback" not in text
566 assert "/musehub/" not in text
567 assert ".py" not in text
568
569
570 # ══════════════════════════════════════════════════════════════════════════════
571 # 7. Performance
572 # ══════════════════════════════════════════════════════════════════════════════
573
574 class TestRateLimitPerformance:
575 """Overhead bounds for rate-limit checks and reset operations."""
576
577 async def test_30_push_requests_complete_within_time_budget(
578 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
579 ) -> None:
580 """30 sequential push requests must complete in under 5 seconds."""
581 repo = await _make_repo(db_session)
582 url = _push_url(repo)
583 start = time.perf_counter()
584 for _ in range(_PUSH_N):
585 r = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
586 assert r.status_code == 200
587 elapsed = time.perf_counter() - start
588 assert elapsed < 5.0, f"30 push requests took {elapsed:.2f}s (budget: 5s)"
589
590 def test_limiter_reset_completes_under_threshold(self) -> None:
591 """reset() must finish in under 50 ms regardless of storage size."""
592 storage = limiter._storage
593 # Populate storage with fake entries
594 for i in range(1000):
595 getattr(storage, "storage")[f"fake_key_{i}"] = i
596 start = time.perf_counter()
597 limiter.reset()
598 elapsed = time.perf_counter() - start
599 assert elapsed < 0.05, f"limiter.reset() took {elapsed * 1000:.1f}ms (budget: 50ms)"
600
601 async def test_rate_limit_overhead_per_request_is_negligible(
602 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
603 ) -> None:
604 """Average per-request overhead from rate-limit check < 50ms."""
605 repo = await _make_repo(db_session)
606 url = _push_url(repo)
607 n = 10
608 start = time.perf_counter()
609 for _ in range(n):
610 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
611 avg_ms = (time.perf_counter() - start) / n * 1000
612 assert avg_ms < 50, f"Average request time {avg_ms:.1f}ms exceeds 50ms budget"
613
614 async def test_429_response_is_fast(
615 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
616 ) -> None:
617 """Rate-limited responses must be returned quickly (< 100ms)."""
618 repo = await _make_repo(db_session)
619 url = _push_url(repo)
620 for _ in range(_PUSH_N):
621 await client.post(url, json=_empty_tags_body(), headers=auth_headers)
622 start = time.perf_counter()
623 resp = await client.post(url, json=_empty_tags_body(), headers=auth_headers)
624 elapsed_ms = (time.perf_counter() - start) * 1000
625 assert resp.status_code == 429
626 assert elapsed_ms < 100, f"429 response took {elapsed_ms:.1f}ms (budget: 100ms)"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago