import { Injectable } from '@nestjs/common';
export interface RateLimitResult {
allowed: boolean;
remaining: number;
limit: number;
retryAfterMs: number;
}
@Injectable()
export class SlidingWindowCounter {
private readonly hits = new Map<string, number[]>();
constructor(
private readonly limit = 100,
private readonly windowMs = 60_000,
) {}
hit(key: string): RateLimitResult {
const now = Date.now();
const cutoff = now - this.windowMs;
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (timestamps.length >= this.limit) {
const oldest = timestamps[0];
this.hits.set(key, timestamps);
return {
allowed: false,
remaining: 0,
limit: this.limit,
retryAfterMs: Math.max(0, oldest + this.windowMs - now),
};
}
timestamps.push(now);
this.hits.set(key, timestamps);
return {
allowed: true,
remaining: this.limit - timestamps.length,
limit: this.limit,
retryAfterMs: 0,
};
}
sweep(): void {
const cutoff = Date.now() - this.windowMs;
for (const [key, timestamps] of this.hits) {
const live = timestamps.filter((t) => t > cutoff);
if (live.length === 0) {
this.hits.delete(key);
} else {
this.hits.set(key, live);
}
}
}
}
import {
CallHandler,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
import { SlidingWindowCounter, RateLimitResult } from './sliding-window.counter';
@Injectable()
export class WebhookRateLimitInterceptor implements NestInterceptor {
constructor(private readonly counter: SlidingWindowCounter) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const http = context.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
const key = this.resolveKey(req);
const result = this.counter.hit(key);
if (!result.allowed) {
this.applyHeaders(res, result);
res.setHeader('Retry-After', Math.ceil(result.retryAfterMs / 1000));
throw new HttpException(
'Webhook rate limit exceeded',
HttpStatus.TOO_MANY_REQUESTS,
);
}
return next.handle().pipe(tap(() => this.applyHeaders(res, result)));
}
private resolveKey(req: Request): string {
const source = req.headers['x-webhook-source'];
if (typeof source === 'string' && source.length > 0) {
return `src:${source}`;
}
return `ip:${req.ip}`;
}
private applyHeaders(res: Response, result: RateLimitResult): void {
res.setHeader('X-RateLimit-Limit', result.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
}
}
import {
Body,
Controller,
Headers,
HttpCode,
Post,
UseInterceptors,
} from '@nestjs/common';
import { WebhookRateLimitInterceptor } from './webhook-rate-limit.interceptor';
import { WebhookProcessor } from './webhook.processor';
interface WebhookEvent {
id: string;
type: string;
payload: Record<string, unknown>;
}
@Controller('webhooks')
export class WebhookController {
constructor(private readonly processor: WebhookProcessor) {}
@Post('events')
@HttpCode(202)
@UseInterceptors(WebhookRateLimitInterceptor)
async receive(
@Body() event: WebhookEvent,
@Headers('x-webhook-source') source: string,
): Promise<{ accepted: true; id: string }> {
await this.processor.enqueue({ ...event, source });
return { accepted: true, id: event.id };
}
}
This snippet shows how to protect a webhook endpoint from bursty upstream traffic using a NestJS interceptor backed by a small in-memory sliding-window counter. Webhook providers often retry aggressively or fan out events, so an unbounded endpoint can be overwhelmed by a single misbehaving sender. Rather than reaching for a heavyweight distributed limiter, this approach keeps per-key counters in process memory, which is fast, dependency-free, and appropriate when a single instance handles a given provider or when approximate limits are acceptable.
The SlidingWindowCounter service is the core algorithm. It stores an array of hit timestamps per key inside a Map, and on each hit(key) call it first prunes any timestamps that fall outside the current window before deciding whether the request is allowed. A pure fixed-window counter suffers from boundary bursts — a caller can send a full quota at the end of one window and another full quota at the start of the next. The sliding window avoids that by always measuring the trailing windowMs, so the effective rate is smoother. The service returns a small result object carrying allowed, the remaining budget, and a retryAfterMs hint computed from the oldest in-window timestamp, which lets the caller populate a Retry-After header. A periodic sweep() clears empty buckets so memory does not grow with the number of distinct keys seen over time.
The WebhookRateLimitInterceptor adapts that algorithm to the HTTP layer. It implements NestInterceptor, resolves a rate-limit key from the request — preferring a stable x-webhook-source header and falling back to the remote IP — and calls counter.hit(key). When the limit is exceeded it sets the standard X-RateLimit-* and Retry-After response headers and throws HttpException with status 429, short-circuiting before the route handler ever runs. When the request is allowed it returns next.handle() and attaches the same informational headers via an RxJS tap, so successful responses still advertise the remaining budget. Doing this in an interceptor rather than the controller keeps the limiting concern orthogonal and reusable across endpoints.
The WebhookController wires everything together with @UseInterceptors(WebhookRateLimitInterceptor) on a single POST /webhooks/events route. The handler itself stays trivial: by the time it executes, the interceptor has already guaranteed the caller is within budget. @HttpCode(202) reflects the common webhook contract of acknowledging receipt quickly and processing asynchronously.
The main trade-off to understand is that the counter is per-process. Behind a load balancer with multiple instances, each replica enforces its own share of the limit, so the global limit is effectively limit * instances and is only approximate. For strict global enforcement a shared store such as Redis is required, but the in-memory design is ideal for low-latency, single-tenant, or best-effort scenarios, and the SlidingWindowCounter interface is deliberately narrow so it could later be swapped for a Redis-backed implementation without touching the interceptor.
Share this code
Here's the card — post it anywhere.