fix(errors): browsers get the 429 page, not raw JSON; 403 copy stops contradicting itself #5548
2 changed files+52−7
Modifiedsrc/middleware/rate-limit.ts+38−2View fileUnifiedSplit
@@ -7,6 +7,18 @@
77
88import { createMiddleware } from "hono/factory";
99import { clientIpFrom } from "../lib/client-ip";
10import { renderStandaloneErrorPage } from "../views/error-page";
11
12/**
13 * Browser detection for error shape. This middleware guards
14 * browser-facing surfaces (search, login, register — 6/min anonymous on
15 * /search/nl) yet dumped raw `{"error":"Rate limit exceeded"}` JSON into
16 * the viewport; error-page.tsx documented the defect in its own comments
17 * and shipped anyway (flow audit #5). API callers keep the JSON shape.
18 */
19function wantsHtml(accept: string | undefined): boolean {
20 return (accept || "").includes("text/html");
21}
1022
1123interface RateLimitEntry {
1224 count: number;
@@ -96,6 +108,17 @@ export function rateLimit(
96108 // exhaust the budget for all the others. It is refused instead.
97109 const ip = clientIpFrom(c);
98110 if (!ip) {
111 if (wantsHtml(c.req.header("accept"))) {
112 return c.html(
113 renderStandaloneErrorPage({
114 code: "400",
115 eyebrow: "Bad request",
116 title: "We couldn't tell who was asking.",
117 body: "This endpoint is rate-limited per caller, and the request arrived without a trustworthy client address. If you're behind an unusual proxy, try again without it.",
118 }),
119 400
120 );
121 }
99122 return c.json(
100123 { error: "Could not determine client address" },
101124 400
@@ -128,11 +151,24 @@ export function rateLimit(
128151 c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
129152
130153 if (entry.count > effectiveMax) {
131 c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
154 const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
155 c.header("Retry-After", String(retryAfter));
156 if (wantsHtml(c.req.header("accept"))) {
157 return c.html(
158 renderStandaloneErrorPage({
159 code: "429",
160 eyebrow: "Too many requests",
161 title: "You're going a little too fast.",
162 body: `We rate-limit this endpoint to keep things responsive for everyone. The bucket refills in about ${retryAfter} second${retryAfter === 1 ? "" : "s"} — wait it out and try again. Signed-in users get a ${authedMultiplier}× bigger bucket.`,
163 signedIn: Boolean(user),
164 }),
165 429
166 );
167 }
132168 return c.json(
133169 {
134170 error: "Rate limit exceeded",
135 retryAfter: Math.ceil((entry.resetAt - now) / 1000),
171 retryAfter,
136172 },
137173 429
138174 );
Modifiedsrc/views/error-page.tsx+14−5View fileUnifiedSplit
@@ -216,7 +216,14 @@ export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ us
216216 code="403"
217217 eyebrow="Forbidden"
218218 title={message ?? "Admin access required."}
219 body="You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin."
219 // The body must branch on `user` like the suggestions above already
220 // did — the old unconditional copy told signed-OUT visitors "You're
221 // signed in" while offering them a Sign in link in the same card.
222 body={
223 user
224 ? "You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin."
225 : "This resource is restricted. Signing in with an account that has access may be all you need."
226 }
220227 user={user}
221228 primaryCta={{ href: "/", label: "Go home" }}
222229 secondaryCta={{ href: "/help", label: "Get help" }}
@@ -227,10 +234,12 @@ export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ us
227234};
228235
229236/* ─────────────────────────────────────────────────────────────────────────
230 * 429 — Too many requests. The rate-limit middleware currently returns a
231 * JSON payload (API-shaped), but we expose this surface for any HTML
232 * route that wants to render a friendly throttling page. Surfaces the
233 * Retry-After timing so the user knows when to come back.
237 * 429 — Too many requests. Since 2026-08-27 the rate-limit middleware
238 * content-negotiates: browsers (Accept: text/html) get the standalone
239 * 429 page via renderStandaloneErrorPage; API callers keep the JSON
240 * shape. This JSX variant remains for layout-wrapped routes that want to
241 * render the throttling page inline. Surfaces the Retry-After timing so
242 * the user knows when to come back.
234243 * ───────────────────────────────────────────────────────────────────── */
235244export const RateLimitPage: FC<{ user?: User | null; retryAfterSeconds?: number; requestId?: string }> = ({ user, retryAfterSeconds, requestId }) => (
236245 <ErrorPage
237246
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts