Skip to content

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

Merged
capJavert merged 5 commits into
mainfrom
fix/public-api-rate-limit-status-code
May 25, 2026
Merged

fix(public-api): include statusCode in rate-limit errorResponseBuilder#3906
capJavert merged 5 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 9f1f62b) was successful.

✨ Neo Explanation

Standard image rollout deploying a rate-limit error-handling bugfix across all workloads, with routine migration Job rotation. ✅ Low Risk

This is a routine application deployment rolling out commit 9f1f62b (previously edf1a05e). The code change fixes the public API rate limiter's errorResponseBuilder to return a proper Error object instead of a plain object, ensuring err.name and err.statusCode are correctly propagated to Fastify's global error handler. The deployment and cron image tags are updated to the new commit, and the per-commit migration Jobs (DB and Clickhouse) are cycled to the new SHA as expected.

🔵 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

capJavert and others added 4 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.
@capJavert capJavert merged commit 763e026 into main May 25, 2026
7 of 9 checks passed
@capJavert capJavert deleted the fix/public-api-rate-limit-status-code branch May 25, 2026 09:41
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