Skip to content

fix(public-api): include statusCode in rate-limit errorResponseBuilder#3906

Open
capJavert wants to merge 4 commits into
mainfrom
fix/public-api-rate-limit-status-code
Open

fix(public-api): include statusCode in rate-limit errorResponseBuilder#3906
capJavert wants to merge 4 commits into
mainfrom
fix/public-api-rate-limit-status-code

Conversation

@capJavert
Copy link
Copy Markdown
Contributor

What

Adds statusCode: 429 to both errorResponseBuilder callbacks in src/routes/public/index.ts (IP rate limiter and per-user rate limiter).

Why

Follow-up to #3899. That PR made the global setErrorHandler preserve err.statusCode, but rate-limit hits on /public/v1/* are still surfacing as 500 in production.

Root cause: @fastify/rate-limit doesn't throw a FastifyError. It calls errorResponseBuilder() and throws the returned object verbatim. The current builder returns:

{
  error: 'rate_limit_exceeded',
  message: '...',
  retryAfter: 60,
}

No statusCode field. The thrown object has type: 'Object' (a plain object, not an Error instance) and err.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 a response_status_code=500 trace at the same millisecond.

Fix

errorResponseBuilder: () => ({
  statusCode: 429,           // ← added
  error: 'rate_limit_exceeded',
  message: 'User rate limit exceeded. Please slow down.',
  retryAfter: 60,
}),

Same change applied to the IP rate limiter.

Tests

Regression test in __tests__/routes/public/rateLimit.ts that fires 61 requests through the per-user limiter (cap is 60/min) and asserts:

  • Status is 429 (not 500)
  • Body shape is { statusCode: 429, error: 'rate_limit_exceeded', message: ... }

Fails on main, passes on this branch.

Impact

  • Consumers correctly see 429 with Retry-After instead of an opaque 500.
  • /public/v1 5xx metrics stop being polluted with rate-limit hits, so we can actually alert on real server errors.

@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.
@pulumi
Copy link
Copy Markdown

pulumi Bot commented May 24, 2026

🍹 The Update (preview) for dailydotdev/api/prod (at 69748ae) was successful.

✨ Neo Explanation

Routine deployment of a bug fix that corrects how rate-limit errors are surfaced to callers; all resource changes are image tag updates and migration job rotations. ✅ Low Risk

This is a standard application deployment rolling out a new container image (commit edf1a05e2b6a4ce6) containing the rate limiter errorResponseBuilder fix — changing it to return a proper Error object so that err.name and err.statusCode are preserved when the global error handler processes rate limit violations. The migration Jobs (both DB and Clickhouse) are cycled as part of every deploy, with old jobs deleted and new ones created against the new image.

Resource Changes

    Name                                                       Type                           Operation
~   vpc-native-temporal-deployment                             kubernetes:apps/v1:Deployment  update
~   vpc-native-clean-expired-better-auth-sessions-cron         kubernetes:batch/v1:CronJob    update
~   vpc-native-calculate-top-readers-cron                      kubernetes:batch/v1:CronJob    update
~   vpc-native-user-profile-updated-sync-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-check-analytics-report-cron                     kubernetes:batch/v1:CronJob    update
~   vpc-native-personalized-digest-deployment                  kubernetes:apps/v1:Deployment  update
~   vpc-native-expire-super-agent-trial-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-ws-deployment                                   kubernetes:apps/v1:Deployment  update
~   vpc-native-user-profile-analytics-clickhouse-cron          kubernetes:batch/v1:CronJob    update
~   vpc-native-worker-job-deployment                           kubernetes:apps/v1:Deployment  update
~   vpc-native-private-deployment                              kubernetes:apps/v1:Deployment  update
~   vpc-native-clean-old-notifications-cron                    kubernetes:batch/v1:CronJob    update
~   vpc-native-update-achievement-rarity-cron                  kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-user-companies-cron                kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-weekly-quests-cron                       kubernetes:batch/v1:CronJob    update
+   vpc-native-api-clickhouse-migration-2b6a4ce6               kubernetes:batch/v1:Job        create
~   vpc-native-update-views-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-bg-deployment                                   kubernetes:apps/v1:Deployment  update
~   vpc-native-daily-digest-cron                               kubernetes:batch/v1:CronJob    update
~   vpc-native-hourly-notification-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-rotate-daily-quests-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-generate-search-invites-cron                    kubernetes:batch/v1:CronJob    update
-   vpc-native-api-db-migration-edf1a05e                       kubernetes:batch/v1:Job        delete
~   vpc-native-materialize-monthly-best-post-archives-cron     kubernetes:batch/v1:CronJob    update
~   vpc-native-materialize-yearly-best-post-archives-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-images-cron                        kubernetes:batch/v1:CronJob    update
~   vpc-native-validate-active-users-cron                      kubernetes:batch/v1:CronJob    update
~   vpc-native-post-analytics-history-day-clickhouse-cron      kubernetes:batch/v1:CronJob    update
~   vpc-native-user-posts-analytics-refresh-cron               kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-channel-highlights-cron                   kubernetes:batch/v1:CronJob    update
~   vpc-native-squad-posts-analytics-refresh-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-user-profile-analytics-history-clickhouse-cron  kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-gifted-plus-cron                          kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-digests-cron                            kubernetes:batch/v1:CronJob    update
+   vpc-native-api-db-migration-2b6a4ce6                       kubernetes:batch/v1:Job        create
~   vpc-native-update-tags-str-cron                            kubernetes:batch/v1:CronJob    update
~   vpc-native-update-tag-materialized-views-cron              kubernetes:batch/v1:CronJob    update
~   vpc-native-channel-highlights-cron                         kubernetes:batch/v1:CronJob    update
~   vpc-native-clean-zombie-opportunities-cron                 kubernetes:batch/v1:CronJob    update
... and 12 other changes

capJavert added 3 commits May 25, 2026 07:09
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant