All files / src/services rateLimiter.ts

100% Statements 100/100
95.65% Branches 22/23
100% Functions 5/5
100% Lines 100/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 1011x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 5x 1x 1x 1x 1x 1x 1x 87x 87x 87x 87x 87x 87x 87x 11x 11x 11x 87x 69x 69x 69x 1x 1x 1x 72x 72x 71x 71x 1x 1x 1x 69x 69x 69x 69x 69x 69x 69x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 7x 74x 69x 69x 69x 74x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 65x 74x 7x  
import type { HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
 
/**
 * In-process fixed-window per-IP rate limiter (research.md R8). Deliberately
 * not a persisted guarantee: state is a plain `Map`, per Functions instance
 * — Functions can scale out, so this is friction against a single hot
 * source, not a hard control. The real backstops are per-user monthly caps
 * (meteringService) and the deployment TPM quota. Applied to analyze and
 * billing routes (contracts/metering.md).
 */
 
const WINDOW_MS = 60_000;
const DEFAULT_ANALYZE_PER_MIN = 30;
const DEFAULT_BILLING_PER_MIN = 10;
 
interface WindowState {
  windowStart: number;
  count: number;
}
 
const buckets = new Map<string, WindowState>();
 
/** Drops all tracked windows so tests can start from a clean slate. */
export function resetRateLimiterForTests(): void {
  buckets.clear();
}
 
/**
 * Fixed-window check-and-increment for an arbitrary key (contracts/metering.md
 * defaults: research R8). Returns true when the request is allowed.
 */
export function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number = WINDOW_MS,
  now: number = Date.now()
): boolean {
  const state = buckets.get(key);
  if (!state || now - state.windowStart >= windowMs) {
    buckets.set(key, { windowStart: now, count: 1 });
    return true;
  }
  if (state.count >= limit) return false;
  state.count++;
  return true;
}
 
/** First address in `x-forwarded-for` (the client, per Azure Functions' proxy chain). */
export function extractClientIp(request: HttpRequest): string {
  const header = request.headers.get("x-forwarded-for");
  if (!header) return "unknown";
  return header.split(",")[0].trim();
}
 
export type RouteKey = "analyze" | "billing";
 
function routeLimit(routeKey: RouteKey): number {
  const envVar =
    routeKey === "analyze" ? "RATE_LIMIT_ANALYZE_PER_MIN" : "RATE_LIMIT_BILLING_PER_MIN";
  const parsed = Number(process.env[envVar]);
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
  return routeKey === "analyze" ? DEFAULT_ANALYZE_PER_MIN : DEFAULT_BILLING_PER_MIN;
}
 
export type PlainHandler = (
  request: HttpRequest,
  context: InvocationContext
) => Promise<HttpResponseInit>;
 
/**
 * Wraps a whole route (before withAuth) so unauthenticated bursts are also
 * throttled. 429 `RATE_LIMITED` — distinct from metering's
 * `USAGE_LIMIT_REACHED`, both are 429s but clients branch on `error.code`
 * (contracts/metering.md).
 */
export function withRateLimit(routeKey: RouteKey, handler: PlainHandler): PlainHandler {
  return async (request, context) => {
    if (request.method === "OPTIONS") return handler(request, context);
 
    const limit = routeLimit(routeKey);
    const ip = extractClientIp(request);
    if (!checkRateLimit(`${routeKey}:${ip}`, limit)) {
      return {
        status: 429,
        headers: {
          "Content-Type": "application/json",
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Headers": "Content-Type, x-functions-key, Authorization",
        },
        jsonBody: {
          error: {
            code: "RATE_LIMITED",
            message: "Too many requests. Please wait a moment and try again.",
          },
        },
      };
    }
    return handler(request, context);
  };
}