[Bug] /api/system/version rate limiter rejects with 503 instead of 429 (RejectionStatusCode default) #341

Closed
opened 2026-07-03 18:01:41 +00:00 by spikerj · 3 comments
Owner

Found by the P1 anonymous walk while tightening the console-error assertion (#314, epic #307).

Symptom

Once /api/system/version exceeds its 10-req/min rate limit, the API rejects with HTTP 503, not 429:

$ for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code} " https://api.spikersoft.com/api/system/version; done
200 200 200 200 200 200 200 200 200 200 503 503

The 503 is served by Kestrel itself (security headers + x-trace-id present), so it's the app's rate-limiting middleware — not Traefik.

Root cause

ASP.NET Core's built-in Microsoft.AspNetCore.RateLimiting middleware defaults RateLimiterOptions.RejectionStatusCode to 503 Service Unavailable. It was never set to 429.

Why it matters

  • The SPA's VersionService handles 429 gracefully (console.warn + "Unknown" fallback) but treats 503 as a generic failure and logs console.error — which now trips the E2E walk's hardened no-console-error assertion (each page load in the walk fetches the version, blowing the 10/min budget).
  • 503 is semantically "service down" — it pollutes uptime monitoring and tells clients/proxies the wrong thing. Rate-limit rejections should be 429 (+ Retry-After).

Fix

Set options.RejectionStatusCode = StatusCodes.Status429TooManyRequests on the rate limiter registration (and ideally emit Retry-After). Watch middleware order re CORS headers on the rejection — same trap as #311.

E2E linkage

The anonymous walk allowlists this exact noise (e2e/support/fixtures.ts, EXPECTED_CONSOLE_NOISE) scoped to /api/system/version and referencing this issue — remove those entries once this ships.

Found by the P1 anonymous walk while tightening the console-error assertion (#314, epic #307). ## Symptom Once `/api/system/version` exceeds its 10-req/min rate limit, the API rejects with **HTTP 503**, not 429: ``` $ for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code} " https://api.spikersoft.com/api/system/version; done 200 200 200 200 200 200 200 200 200 200 503 503 ``` The 503 is served by Kestrel itself (security headers + `x-trace-id` present), so it's the app's rate-limiting middleware — not Traefik. ## Root cause ASP.NET Core's built-in `Microsoft.AspNetCore.RateLimiting` middleware defaults `RateLimiterOptions.RejectionStatusCode` to **503 Service Unavailable**. It was never set to 429. ## Why it matters - The SPA's `VersionService` handles 429 gracefully (`console.warn` + "Unknown" fallback) but treats 503 as a generic failure and logs `console.error` — which now trips the E2E walk's hardened no-console-error assertion (each page load in the walk fetches the version, blowing the 10/min budget). - 503 is semantically "service down" — it pollutes uptime monitoring and tells clients/proxies the wrong thing. Rate-limit rejections should be 429 (+ `Retry-After`). ## Fix Set `options.RejectionStatusCode = StatusCodes.Status429TooManyRequests` on the rate limiter registration (and ideally emit `Retry-After`). Watch middleware order re CORS headers on the rejection — same trap as #311. ## E2E linkage The anonymous walk allowlists this exact noise (`e2e/support/fixtures.ts`, `EXPECTED_CONSOLE_NOISE`) scoped to `/api/system/version` and referencing this issue — remove those entries once this ships.
spikerj added the bug label 2026-07-03 18:03:28 +00:00
Author
Owner

Fix open: spikersoft-backend PR #59RejectionStatusCode = 429 + Retry-After from lease metadata on the built-in limiter (the Redis limiter already returned 429). Will verify the live endpoint returns 429 after merge+deploy, then remove the two /api/system/version allowlist entries from EXPECTED_CONSOLE_NOISE in the E2E fixtures and close.

Fix open: spikersoft-backend PR #59 — `RejectionStatusCode = 429` + `Retry-After` from lease metadata on the built-in limiter (the Redis limiter already returned 429). Will verify the live endpoint returns 429 after merge+deploy, then remove the two `/api/system/version` allowlist entries from `EXPECTED_CONSOLE_NOISE` in the E2E fixtures and close.
Author
Owner

Backend fix (spikersoft-backend PR #59) merged and verified live: /api/system/version now returns 9×200 then 429 with Retry-After: 60 (was 503).

Frontend follow-up open: spikersoft-angular PR #109VersionService no longer console.errors on the graceful 429/offline paths (the catch-all log fired before the warn branches), and the two 503-era allowlist entries are removed from EXPECTED_CONSOLE_NOISE (one narrowed entry remains for Chromium's browser-emitted 429 resource-load line, which app code can't suppress). Anonymous walk green against the live 429s (53/53 + 2 skips). Closing once #109 merges.

Backend fix (spikersoft-backend PR #59) merged and **verified live**: `/api/system/version` now returns 9×200 then `429` with `Retry-After: 60` (was 503). Frontend follow-up open: spikersoft-angular PR #109 — `VersionService` no longer `console.error`s on the graceful 429/offline paths (the catch-all log fired before the warn branches), and the two 503-era allowlist entries are removed from `EXPECTED_CONSOLE_NOISE` (one narrowed entry remains for Chromium's browser-emitted 429 resource-load line, which app code can't suppress). Anonymous walk green against the live 429s (53/53 + 2 skips). Closing once #109 merges.
Author
Owner

Both halves are now merged: spikersoft-backend PR #59 (429 + Retry-After — verified live) and spikersoft-angular PR #109 (VersionService warn-only on the 429/offline paths; 503-era allowlist entries removed, one narrowed browser-emitted-429 entry remains). Anonymous walk green against the live 429s. Closing.

Both halves are now merged: spikersoft-backend PR #59 (429 + `Retry-After` — verified live) and spikersoft-angular PR #109 (VersionService warn-only on the 429/offline paths; 503-era allowlist entries removed, one narrowed browser-emitted-429 entry remains). Anonymous walk green against the live 429s. Closing.
Sign in to join this conversation.