{"record":{"id":"a9bb744e28f5ab56","repo":"thedotmack/claude-mem","slug":"rate-limited","errorCode":"rate_limited","errorMessage":"Rate limit exceeded (${opts.max} requests / ${opts.windowSec}s)","messagePattern":"Rate limit exceeded \\((.+?) requests / (.+?)s\\)","errorType":"http","errorClass":null,"httpStatus":429,"severity":"warning","filePath":"src/server/middleware/rate-limit.ts","lineNumber":42,"sourceCode":"async function enforceRateLimit(\n  repo: PostgresRateLimitRepository,\n  opts: { windowSec: number; max: number },\n  subject: string,\n  res: Response,\n  next: NextFunction,\n): Promise<Response | void> {\n  const start = floorToWindow(Date.now(), opts.windowSec);\n  const resetMs = start.getTime() + opts.windowSec * 1000;\n  const result = await repo.hit({ subjectId: subject, windowStart: start, limit: opts.max });\n  res.setHeader('X-RateLimit-Limit', String(opts.max));\n  res.setHeader('X-RateLimit-Remaining', String(Math.max(0, opts.max - result.count)));\n  // Unix-seconds reset time — the conventional companion to Retry-After that\n  // most client libraries read to schedule automatic retries.\n  res.setHeader('X-RateLimit-Reset', String(Math.ceil(resetMs / 1000)));\n  if (!result.allowed) {\n    const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));\n    res.setHeader('Retry-After', String(retryAfter));\n    return res.status(429).json({\n      error: 'rate_limited',\n      message: `Rate limit exceeded (${opts.max} requests / ${opts.windowSec}s)`,\n    });\n  }\n  return next();\n}\n\n/** Fixed-window per-key limiter: `max` requests per `windowSec`. */\nexport function requireRateLimit(pool: PostgresPool, opts: { windowSec: number; max: number }): RequestHandler {\n  const repo = new PostgresRateLimitRepository(pool);\n  return async (req: Request, res: Response, next: NextFunction) => {\n    const subject = req.authContext?.apiKeyId;\n    if (!subject) return next(); // unauthenticated / local-dev bypass: nothing to limit\n    try {\n      return await enforceRateLimit(repo, opts, subject, res, next);\n    } catch (error) {\n      logger.warn('HTTP', 'rate limit check failed; allowing request (fail open)', {\n        error: error instanceof Error ? error.message : String(error),","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/server/middleware/rate-limit.ts#L24-L60","documentation":"429 from the fixed-window rate limiter: the subject (per API key id) exceeded opts.max requests inside the current opts.windowSec window. The middleware sets X-RateLimit-Limit, X-RateLimit-Remaining, and Unix-seconds X-RateLimit-Reset on every response, plus Retry-After (seconds until window reset) when it rejects — clients should schedule retries from those headers, not guess.","triggerScenarios":"Burst of N+1 requests from one API key within windowSec (e.g. >max POST /v1/events in a minute); a retry loop without backoff re-tripping the window; many worker processes sharing one key so their combined traffic exceeds the per-key cap.","commonSituations":"Bulk backfill scripts hammering the events endpoint sequentially instead of batching; fan-out from parallel agents sharing a team key; window boundary causing a burst right after reset.","solutions":["Honor the Retry-After header: wait that many seconds (plus jitter) before the next request.","Switch to POST /v1/events/batch (up to 500 events per call) to collapse many hits into one.","Give each worker its own API key so limits are per-key, not shared.","If the cap is genuinely too low for legit traffic, raise the limiter's max/windowSec configuration server-side."],"exampleFix":"// before: naive loop trips 429\nfor (const e of events) await post('/v1/events', e);\n\n// after: batch + honor Retry-After\nasync function postWithRetry(url, body) {\n  const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });\n  if (res.status === 429) {\n    const wait = Number(res.headers.get('retry-after') ?? 1);\n    await new Promise(r => setTimeout(r, (wait + Math.random()) * 1000));\n    return postWithRetry(url, body);\n  }\n  return res;\n}\nawait postWithRetry(`${base}/v1/events/batch`, events.slice(0, 500));","handlingStrategy":"retry","validationCode":"// Pre-check remaining budget from the last response's headers\nfunction canSend(rateState: { remaining: number }): boolean {\n  return rateState.remaining > 0;\n}","typeGuard":"interface RateLimitBody { error: string; message: string }\nfunction isRateLimited(res: Response): boolean {\n  return res.status === 429;\n}","tryCatchPattern":"async function fetchRateLimited(url: string, init: RequestInit, tries = 5): Promise<Response> {\n  const res = await fetch(url, init);\n  if (res.status !== 429) return res;\n  if (tries === 0) throw new Error('rate limit exhausted retries');\n  const retryAfter = Number(res.headers.get('retry-after') ?? 1);\n  const reset = res.headers.get('x-ratelimit-reset');\n  await new Promise(r => setTimeout(r, (retryAfter + Math.random()) * 1000));\n  return fetchRateLimited(url, init, tries - 1);\n}","preventionTips":["Always read Retry-After / X-RateLimit-* headers; never poll on a fixed interval.","Prefer the batch endpoint (500 events/request) over per-event calls.","Give each concurrent worker its own API key.","Add jitter to delays to avoid thundering herd at window reset."],"tags":["rate-limit","http-429","throttling","middleware"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","contentChangedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}