calcom/cal.diy · warning · ThrottlerException

CustomThrottlerGuard - Too many requests. Please try again l

Error message

CustomThrottlerGuard - Too many requests. Please try again later.

What it means

Thrown by CustomThrottlerGuard.handleApiEndpointThrottle when a route decorated with @Throttle exceeds its per-endpoint rate limit. The guard combines the tracker (API key hash, IP hash, OAuth client hash, or access token hash) with the throttle option name to form a unique rate-limit key, then increments a Redis counter. If the counter exceeds the limit during the TTL or block-duration window, this exception fires and NestJS returns HTTP 429.

Source

Thrown at apps/api/v2/src/lib/throttler-guard.ts:74

    const request = context.switchToHttp().getRequest<Request>();
    const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
    const response = context.switchToHttp().getResponse<Response>();
    const tracker = await this.getTracker(request);
    if (throttleOptions) {
      return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
    }

    if (tracker.startsWith("api_key_")) {
      return this.handleApiKeyRequest(tracker, response);
    } else {
      return this.handleNonApiKeyRequest(tracker, response);
    }
  }

  private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
    const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
    if (isBlocked) {
      throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
    }

    return true;
  }

  private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
    const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);

    let allLimitsBlocked = true;
    for (const rateLimit of rateLimits) {
      const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
      if (!isBlocked) {
        allLimitsBlocked = false;
      }
    }

    if (allLimitsBlocked) {
      throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");

View on GitHub (pinned to 176037d0af)

Solutions

  1. Inspect the X-RateLimit-Remaining-<Name> and X-RateLimit-Reset-<Name> response headers (set in incrementRateLimit at lines 192-198) to see remaining quota before retrying.
  2. Implement exponential backoff on the client: wait for the X-RateLimit-Reset value, then retry with increasing delay.
  3. If the limit is genuinely too low for a legitimate use case, adjust the @Throttle decorator parameters on the specific controller method or raise the corresponding env defaults.
  4. In test environments, clear the Redis keys matching the tracker pattern or use a separate Redis instance to avoid cross-test contamination.

Example fix

// before: client sends requests in a tight loop
for (const item of items) {
  await api.post('/throttled-endpoint', item);
}

// after: respect rate-limit headers and back off
const makeRequest = async (item) => {
  const res = await api.post('/throttled-endpoint', item);
  const remaining = res.headers['x-ratelimit-remaining-custom'];
  if (Number(remaining) <= 1) {
    const resetMs = Number(res.headers['x-ratelimit-reset-custom']);
    await new Promise(r => setTimeout(r, resetMs));
  }
};
for (const item of items) {
  await makeRequest(item);
}
Defensive patterns

Strategy: retry

Validate before calling

// Before sending, check the last response's rate-limit headers
const canSend = (lastResponse: Response): boolean => {
  const remaining = Number(lastResponse.headers.get('x-ratelimit-remaining-custom') ?? 1);
  return remaining > 0;
};
if (!canSend(lastRes)) {
  const resetMs = Number(lastRes.headers.get('x-ratelimit-reset-custom') ?? 60000);
  await new Promise(r => setTimeout(r, resetMs));
}

Try / catch

// Retry with exponential backoff respecting Retry-After
const callWithBackoff = async <T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> => {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (err: any) {
      const isThrottle = err?.response?.status === 429 || err?.name === 'ThrottlerException';
      if (!isThrottle || attempt >= maxRetries) throw err;
      const retryAfter = Number(err?.response?.headers?.['retry-after'] ?? Math.pow(2, attempt));
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      attempt++;
    }
  }
};

Prevention

When it happens

Trigger: A request hits a controller method decorated with @Throttle(name, limit, ttl, blockDuration). The same tracker (e.g. api_key_<sha256>) sends more than 'limit' requests within 'ttl' milliseconds, or is still within the 'blockDuration' window after a previous violation. This path is taken only when throttleOptions is non-null (line 60-61), bypassing the generic API-key and non-API-key rate limit handlers.

Common situations: Tight per-endpoint throttle limits set too low for legitimate burst traffic. Integration tests hammering a throttled endpoint without resetting Redis. A webhook receiver or polling client exceeding the decorated endpoint's custom limit. Environment variable RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS set very high, causing prolonged blocks.

Understand the failure class

Related errors


AI-assisted analysis of calcom/cal.diy@176037d0af (2026-08-12). Data as JSON: /api/errors/ebadec69ba85bd9f. Report an issue: GitHub.