thedotmack/claude-mem · warning

rate_limited

rate_limited

Error message

Rate limit exceeded (${opts.max} requests / ${opts.windowSec}s)

What it means

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.

Source

Thrown at src/server/middleware/rate-limit.ts:42

async function enforceRateLimit(
  repo: PostgresRateLimitRepository,
  opts: { windowSec: number; max: number },
  subject: string,
  res: Response,
  next: NextFunction,
): Promise<Response | void> {
  const start = floorToWindow(Date.now(), opts.windowSec);
  const resetMs = start.getTime() + opts.windowSec * 1000;
  const result = await repo.hit({ subjectId: subject, windowStart: start, limit: opts.max });
  res.setHeader('X-RateLimit-Limit', String(opts.max));
  res.setHeader('X-RateLimit-Remaining', String(Math.max(0, opts.max - result.count)));
  // Unix-seconds reset time — the conventional companion to Retry-After that
  // most client libraries read to schedule automatic retries.
  res.setHeader('X-RateLimit-Reset', String(Math.ceil(resetMs / 1000)));
  if (!result.allowed) {
    const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
    res.setHeader('Retry-After', String(retryAfter));
    return res.status(429).json({
      error: 'rate_limited',
      message: `Rate limit exceeded (${opts.max} requests / ${opts.windowSec}s)`,
    });
  }
  return next();
}

/** Fixed-window per-key limiter: `max` requests per `windowSec`. */
export function requireRateLimit(pool: PostgresPool, opts: { windowSec: number; max: number }): RequestHandler {
  const repo = new PostgresRateLimitRepository(pool);
  return async (req: Request, res: Response, next: NextFunction) => {
    const subject = req.authContext?.apiKeyId;
    if (!subject) return next(); // unauthenticated / local-dev bypass: nothing to limit
    try {
      return await enforceRateLimit(repo, opts, subject, res, next);
    } catch (error) {
      logger.warn('HTTP', 'rate limit check failed; allowing request (fail open)', {
        error: error instanceof Error ? error.message : String(error),

View on GitHub (pinned to e2d1df569a)

Solutions

  1. Honor the Retry-After header: wait that many seconds (plus jitter) before the next request.
  2. Switch to POST /v1/events/batch (up to 500 events per call) to collapse many hits into one.
  3. Give each worker its own API key so limits are per-key, not shared.
  4. If the cap is genuinely too low for legit traffic, raise the limiter's max/windowSec configuration server-side.

Example fix

// before: naive loop trips 429
for (const e of events) await post('/v1/events', e);

// after: batch + honor Retry-After
async function postWithRetry(url, body) {
  const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
  if (res.status === 429) {
    const wait = Number(res.headers.get('retry-after') ?? 1);
    await new Promise(r => setTimeout(r, (wait + Math.random()) * 1000));
    return postWithRetry(url, body);
  }
  return res;
}
await postWithRetry(`${base}/v1/events/batch`, events.slice(0, 500));
Defensive patterns

Strategy: retry

Validate before calling

// Pre-check remaining budget from the last response's headers
function canSend(rateState: { remaining: number }): boolean {
  return rateState.remaining > 0;
}

Type guard

interface RateLimitBody { error: string; message: string }
function isRateLimited(res: Response): boolean {
  return res.status === 429;
}

Try / catch

async function fetchRateLimited(url: string, init: RequestInit, tries = 5): Promise<Response> {
  const res = await fetch(url, init);
  if (res.status !== 429) return res;
  if (tries === 0) throw new Error('rate limit exhausted retries');
  const retryAfter = Number(res.headers.get('retry-after') ?? 1);
  const reset = res.headers.get('x-ratelimit-reset');
  await new Promise(r => setTimeout(r, (retryAfter + Math.random()) * 1000));
  return fetchRateLimited(url, init, tries - 1);
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20). Data as JSON: /api/errors/a9bb744e28f5ab56. Report an issue: GitHub.