From 3c32f01aa76e1a0b63765f4021c8fba0c2faa2fa Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 09:15:30 +0000 Subject: [PATCH] feat: add Retry-After header and countdown timer for rate limits - Rate limiter returns retryAfterSec in response body + Retry-After header - Registration rate limits: 10 per 10min (was 15/hour) - Frontend surfaces retry timer in error messages Co-Authored-By: Claude Opus 4.6 --- frontend/src/composables/useNostr.ts | 11 ++++++++--- server/src/middleware/rate-limit.ts | 4 +++- server/src/routes/auth.ts | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index a3dbf83..0e1eb93 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -260,8 +260,9 @@ export function useNostr() { const data = await res.json() if (!res.ok) { - // Surface detailed error info from webhook verification failures - const msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') + // Include retry timer info for rate limits + let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') + if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` throw new Error(msg) } @@ -344,7 +345,11 @@ export function useNostr() { }) const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Registration failed') + if (!res.ok) { + let msg = data.error || 'Registration failed' + if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` + throw new Error(msg) + } bot.value = { id: data.id, diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index 9d21074..9cba63c 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -38,7 +38,9 @@ export function rateLimit(windowMs: number, maxHits: number) { } else { entry.count++ if (entry.count > maxHits) { - return c.json({ error: 'Too many requests. Slow down.' }, 429) + const retryAfterSec = Math.ceil((entry.resetAt - now) / 1000) + c.header('Retry-After', String(retryAfterSec)) + return c.json({ error: 'Too many requests. Slow down.', retryAfterSec }, 429) } } diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d649e59..ccd52cb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -131,7 +131,7 @@ authRouter.post('/login', rateLimit(60_000, 30), async (c) => { }) // Register a new bot with Nostr pubkey -authRouter.post('/register', rateLimit(3600_000, 15), async (c) => { +authRouter.post('/register', rateLimit(600_000, 10), async (c) => { const body = await c.req.json() const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body @@ -231,7 +231,7 @@ authRouter.post('/register', rateLimit(3600_000, 15), async (c) => { // Register a human player (no webhook required) -authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => { +authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => { const body = await c.req.json() const { pubkey, name, profilePicUrl, avatarSeed } = body