diff --git a/examples/mock-server/README.md b/examples/mock-server/README.md index 80a1ef7..551c341 100644 --- a/examples/mock-server/README.md +++ b/examples/mock-server/README.md @@ -33,7 +33,7 @@ terminal, then run a consumer demo in another (`make demo-config` etc. — see | `/user/` | GET | Default happy-path body when the script queue is empty (unversioned, mirrors the backend) | | `/headers/` | GET | Default happy-path body when the script queue is empty (unversioned, mirrors the backend) | | `/status/` | GET | Default happy-path body when the script queue is empty | -| (anything else) | * | `404 {"s":"error","errmsg":"..."}` when no script step matches | +| (anything else) | * | `404 {"s":"no_data"}` when no script step matches — mirrors the backend's `custom_404` | ## Scripted-step semantics @@ -44,6 +44,12 @@ terminal, then run a consumer demo in another (`make demo-config` etc. — see - `cf-ray` is added to every response if you don't set it yourself — that's what the SDK reads for `requestId` on the response envelope and exception context, so populating it makes the demos' logs traceable. +- The four `x-api-ratelimit-*` headers (`limit`, `remaining`, `reset`, + `consumed`) are added to **every** response — scripted, default, and 404 — + mirroring the backend's `update_user_quota`. They populate + `client.getRateLimits()`. To exercise the exhausted-credits / §10.3 preflight + path, script your own `x-api-ratelimit-remaining: 0` (plus a future `reset`); + your value wins via `setdefault`. - Once popped, a step is gone. Re-script if you need the same shape twice. ## Why this is separate from the SDK's tests diff --git a/examples/mock-server/server.py b/examples/mock-server/server.py index a09d9f2..26c35f0 100644 --- a/examples/mock-server/server.py +++ b/examples/mock-server/server.py @@ -85,6 +85,21 @@ class ScriptedStep(BaseModel): ) +def _default_ratelimit_headers() -> dict[str, str]: + # Mirrors the backend's update_user_quota(): every response — including the + # 404 from custom_404 — carries the four x-api-ratelimit-* headers the SDK's + # RateLimitHeaders.parse() reads. Parse is all-or-nothing, so always emit all + # four. Scripts can override any of these (e.g. remaining=0 to drive the + # §10.3 preflight / exhausted-credits path) — see catch_all's setdefault. + reset_epoch = int(time.time()) + 24 * 3600 # next daily quota reset + return { + "x-api-ratelimit-limit": "100000", + "x-api-ratelimit-remaining": "99999", + "x-api-ratelimit-reset": str(reset_epoch), + "x-api-ratelimit-consumed": "1", + } + + def _default_status_body() -> str: now_epoch = int(time.time()) return json.dumps( @@ -185,6 +200,9 @@ async def catch_all(full_path: str, request: Request) -> Response: # unless the script explicitly overrode it. response_headers.setdefault("cf-ray", f"mock-{int(time.time() * 1000)}") response_headers.setdefault("Content-Type", "application/json") + # Rate-limit headers, unless the script set its own (e.g. remaining=0). + for _k, _v in _default_ratelimit_headers().items(): + response_headers.setdefault(_k, _v) _request_log.append( {"path": path, "method": method, "status": step.status, "scripted": True} ) @@ -206,6 +224,7 @@ async def catch_all(full_path: str, request: Request) -> Response: headers={ "Content-Type": "application/json", "cf-ray": f"mock-{int(time.time() * 1000)}", + **_default_ratelimit_headers(), }, ) @@ -223,4 +242,7 @@ def _default_response_for(path: str) -> tuple[str, int]: return _DEFAULT_HEADERS, 200 if path in ("/status/", "/status"): return _default_status_body(), 200 - return json.dumps({"s": "error", "errmsg": f"unknown endpoint {path}"}), 404 + # Unknown routes mirror the backend's custom_404: {"s":"no_data"} at 404, + # NOT an error envelope. The SDK treats 404+no_data as a successful empty + # response (Response.isNoData()), so this keeps the mock faithful. + return json.dumps({"s": "no_data"}), 404