fix(public-api): include statusCode in rate-limit errorResponseBuilder#3906
Conversation
@fastify/rate-limit's errorResponseBuilder returns a plain object that is then thrown as the error. Without an explicit statusCode field, the global setErrorHandler (which keys off err.statusCode) treats it as an unknown error and returns 500 instead of 429. Adds 'statusCode: 429' to both IP and per-user errorResponseBuilders. Now exceeding either limit returns a proper 429 with retryAfter and the rate_limit_exceeded error code, matching what the docs promise and what consumers expect. Regression test exercises the per-user limit (61 requests > 60/min cap) and asserts the 429 status + body shape.
|
🍹 The Update (preview) for dailydotdev/api/prod (at 9f1f62b) was successful. ✨ Neo ExplanationStandard image rollout deploying a rate-limit error-handling bugfix across all workloads, with routine migration Job rotation. ✅ Low RiskThis is a routine application deployment rolling out commit 🔵 Info — The GCP provider warning about missing default credentials is a diagnostics-only issue (no resources depend on it in this update) and does not affect the Kubernetes resources being deployed. Resource Changes Name Type Operation
~ vpc-native-channel-highlights-cron kubernetes:batch/v1:CronJob update
~ vpc-native-worker-job-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-personalized-digest-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-post-analytics-clickhouse-cron kubernetes:batch/v1:CronJob update
~ vpc-native-post-analytics-history-day-clickhouse-cron kubernetes:batch/v1:CronJob update
~ vpc-native-calculate-top-readers-cron kubernetes:batch/v1:CronJob update
~ vpc-native-hourly-notification-cron kubernetes:batch/v1:CronJob update
~ vpc-native-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-clean-expired-better-auth-sessions-cron kubernetes:batch/v1:CronJob update
~ vpc-native-user-profile-updated-sync-cron kubernetes:batch/v1:CronJob update
~ vpc-native-bg-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-materialize-yearly-best-post-archives-cron kubernetes:batch/v1:CronJob update
~ vpc-native-clean-gifted-plus-cron kubernetes:batch/v1:CronJob update
~ vpc-native-generate-search-invites-cron kubernetes:batch/v1:CronJob update
~ vpc-native-clean-old-notifications-cron kubernetes:batch/v1:CronJob update
~ vpc-native-update-tag-materialized-views-cron kubernetes:batch/v1:CronJob update
~ vpc-native-update-source-public-threshold-cron kubernetes:batch/v1:CronJob update
- vpc-native-api-db-migration-edf1a05e kubernetes:batch/v1:Job delete
~ vpc-native-update-views-cron kubernetes:batch/v1:CronJob update
~ vpc-native-user-profile-analytics-history-clickhouse-cron kubernetes:batch/v1:CronJob update
~ vpc-native-update-current-streak-cron kubernetes:batch/v1:CronJob update
~ vpc-native-validate-active-users-cron kubernetes:batch/v1:CronJob update
~ vpc-native-clean-zombie-images-cron kubernetes:batch/v1:CronJob update
~ vpc-native-expire-super-agent-trial-cron kubernetes:batch/v1:CronJob update
~ vpc-native-user-profile-analytics-clickhouse-cron kubernetes:batch/v1:CronJob update
~ vpc-native-generic-referral-reminder-cron kubernetes:batch/v1:CronJob update
~ vpc-native-update-achievement-rarity-cron kubernetes:batch/v1:CronJob update
~ vpc-native-rotate-daily-quests-cron kubernetes:batch/v1:CronJob update
~ vpc-native-ws-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-update-tags-str-cron kubernetes:batch/v1:CronJob update
~ vpc-native-temporal-deployment kubernetes:apps/v1:Deployment update
~ vpc-native-clean-channel-highlights-cron kubernetes:batch/v1:CronJob update
~ vpc-native-materialize-monthly-best-post-archives-cron kubernetes:batch/v1:CronJob update
~ vpc-native-clean-stale-user-transactions-cron kubernetes:batch/v1:CronJob update
~ vpc-native-clean-zombie-users-cron kubernetes:batch/v1:CronJob update
~ vpc-native-sync-subscription-with-cio-cron kubernetes:batch/v1:CronJob update
~ vpc-native-channel-digests-cron kubernetes:batch/v1:CronJob update
+ vpc-native-api-clickhouse-migration-1d845b4a kubernetes:batch/v1:Job create
~ vpc-native-daily-digest-cron kubernetes:batch/v1:CronJob update
+ vpc-native-api-db-migration-1d845b4a kubernetes:batch/v1:Job create
~ vpc-native-update-highlighted-views-cron kubernetes:batch/v1:CronJob update
... and 12 other changes |
The global setErrorHandler rewrites the response body using 'err.name || "Error"', which drops the original 'rate_limit_exceeded' code from @fastify/rate-limit's errorResponseBuilder. Asserting on it fails. Fixing that round-trip is out of scope here — we just verify the 429 status and message string for now.
…dler @fastify/rate-limit's errorResponseBuilder returns a plain object that is thrown verbatim, including an 'error' field naming the failure code (e.g. 'rate_limit_exceeded'). The previous handler discarded it and substituted 'Error' (because the thrown plain object has no .name). Prefer the object's own 'error' field when present, fall back to err.name, then 'Error'. Restores the documented public API response shape for 429s.
…hack @fastify/rate-limit's default errorResponseBuilder returns 'new Error()' with statusCode set (see index.js#L31). Our overrides were returning plain objects which lost both the Error prototype (no .name) and any non-statusCode metadata. Switch both IP and per-user builders to return a real Error with .name = 'rate_limit_exceeded' and .statusCode = 429. The global setErrorHandler then works as-is: err.statusCode preserves 429, err.name preserves the public 'rate_limit_exceeded' code in the body. Reverts the err.error workaround added in the previous commit — no longer needed once builders return proper Errors.
What
Adds
statusCode: 429to botherrorResponseBuildercallbacks insrc/routes/public/index.ts(IP rate limiter and per-user rate limiter).Why
Follow-up to #3899. That PR made the global
setErrorHandlerpreserveerr.statusCode, but rate-limit hits on/public/v1/*are still surfacing as 500 in production.Root cause:
@fastify/rate-limitdoesn't throw aFastifyError. It callserrorResponseBuilder()and throws the returned object verbatim. The current builder returns:No
statusCodefield. The thrown object hastype: 'Object'(a plain object, not anErrorinstance) anderr.statusCode === undefined, so the global handler falls through to the default 500 branch.Verified in observability: every
User rate limit exceeded. Please slow down.log line lines up with aresponse_status_code=500trace at the same millisecond.Fix
Same change applied to the IP rate limiter.
Tests
Regression test in
__tests__/routes/public/rateLimit.tsthat fires 61 requests through the per-user limiter (cap is 60/min) and asserts:429(not 500){ statusCode: 429, error: 'rate_limit_exceeded', message: ... }Fails on
main, passes on this branch.Impact
Retry-Afterinstead of an opaque 500./public/v15xx metrics stop being polluted with rate-limit hits, so we can actually alert on real server errors.