Skip to content

Latest commit

 

History

History
245 lines (203 loc) · 6.62 KB

File metadata and controls

245 lines (203 loc) · 6.62 KB
title Add Rate Limiting to APIs
id api-rate-limiting
skillLevel intermediate
applicationPatternId building-apis
summary Protect your API from abuse by limiting request rates per client.
tags
building-apis
rate-limiting
security
performance
rule
description
Use a rate limiter service to enforce request quotas per client.
author PaulJPhilp
related
api-middleware
api-authentication
lessonOrder 1

Guideline

Implement rate limiting as a service that tracks request counts and enforces limits per client (IP, API key, or user).


Rationale

Rate limiting protects your API:

  1. Prevent abuse - Stop malicious flooding
  2. Fair usage - Share resources among clients
  3. Cost control - Limit expensive operations
  4. Stability - Prevent cascading failures

Good Example

import { Effect, Context, Layer, Ref, HashMap, Data, Duration } from "effect"
import { HttpServerRequest, HttpServerResponse } from "@effect/platform"

// ============================================
// 1. Define rate limit types
// ============================================

interface RateLimitConfig {
  readonly maxRequests: number
  readonly windowMs: number
}

interface RateLimitState {
  readonly count: number
  readonly resetAt: number
}

class RateLimitExceededError extends Data.TaggedError("RateLimitExceededError")<{
  readonly retryAfter: number
  readonly limit: number
}> {}

// ============================================
// 2. Rate limiter service
// ============================================

interface RateLimiter {
  readonly check: (key: string) => Effect.Effect<void, RateLimitExceededError>
  readonly getStatus: (key: string) => Effect.Effect<{
    remaining: number
    resetAt: number
  }>
}

class RateLimiterService extends Context.Tag("RateLimiter")<
  RateLimiterService,
  RateLimiter
>() {}

// ============================================
// 3. In-memory rate limiter implementation
// ============================================

const makeRateLimiter = (config: RateLimitConfig) =>
  Effect.gen(function* () {
    const state = yield* Ref.make(HashMap.empty<string, RateLimitState>())

    const getOrCreateState = (key: string, now: number) =>
      Ref.modify(state, (map) => {
        const existing = HashMap.get(map, key)

        if (existing._tag === "Some") {
          // Check if window expired
          if (now >= existing.value.resetAt) {
            // Start new window
            const newState: RateLimitState = {
              count: 0,
              resetAt: now + config.windowMs,
            }
            return [newState, HashMap.set(map, key, newState)]
          }
          return [existing.value, map]
        }

        // Create new entry
        const newState: RateLimitState = {
          count: 0,
          resetAt: now + config.windowMs,
        }
        return [newState, HashMap.set(map, key, newState)]
      })

    const incrementCount = (key: string) =>
      Ref.modify(state, (map) => {
        const existing = HashMap.get(map, key)
        if (existing._tag === "Some") {
          const updated = { ...existing.value, count: existing.value.count + 1 }
          return [updated.count, HashMap.set(map, key, updated)]
        }
        return [1, map]
      })

    const limiter: RateLimiter = {
      check: (key) =>
        Effect.gen(function* () {
          const now = Date.now()
          const currentState = yield* getOrCreateState(key, now)

          if (currentState.count >= config.maxRequests) {
            const retryAfter = Math.ceil((currentState.resetAt - now) / 1000)
            return yield* Effect.fail(
              new RateLimitExceededError({
                retryAfter,
                limit: config.maxRequests,
              })
            )
          }

          yield* incrementCount(key)
        }),

      getStatus: (key) =>
        Effect.gen(function* () {
          const now = Date.now()
          const currentState = yield* getOrCreateState(key, now)
          return {
            remaining: Math.max(0, config.maxRequests - currentState.count),
            resetAt: currentState.resetAt,
          }
        }),
    }

    return limiter
  })

// ============================================
// 4. Rate limit middleware
// ============================================

const withRateLimit = <A, E, R>(
  handler: Effect.Effect<A, E, R>
): Effect.Effect<
  A | HttpServerResponse.HttpServerResponse,
  E,
  R | RateLimiterService | HttpServerRequest.HttpServerRequest
> =>
  Effect.gen(function* () {
    const request = yield* HttpServerRequest.HttpServerRequest
    const rateLimiter = yield* RateLimiterService

    // Use IP address as key (in production, might use user ID or API key)
    const clientKey = request.headers["x-forwarded-for"] || "unknown"

    const result = yield* rateLimiter.check(clientKey).pipe(
      Effect.matchEffect({
        onFailure: (error) =>
          Effect.succeed(
            HttpServerResponse.json(
              {
                error: "Rate limit exceeded",
                retryAfter: error.retryAfter,
              },
              {
                status: 429,
                headers: {
                  "Retry-After": String(error.retryAfter),
                  "X-RateLimit-Limit": String(error.limit),
                  "X-RateLimit-Remaining": "0",
                },
              }
            )
          ),
        onSuccess: () => handler,
      })
    )

    return result
  })

// ============================================
// 5. Usage example
// ============================================

const RateLimiterLive = Layer.effect(
  RateLimiterService,
  makeRateLimiter({
    maxRequests: 100,      // 100 requests
    windowMs: 60 * 1000,   // per minute
  })
)

const apiEndpoint = withRateLimit(
  Effect.gen(function* () {
    // Your actual handler logic
    return HttpServerResponse.json({ data: "Success!" })
  })
)

Rate Limit Strategies

Strategy Key Best For
IP-based Client IP Public APIs
User-based User ID Authenticated APIs
API key API key B2B services
Endpoint IP + path Per-endpoint limits

Response Headers

Header Purpose
X-RateLimit-Limit Max requests allowed
X-RateLimit-Remaining Requests left
X-RateLimit-Reset When window resets
Retry-After Seconds until retry

Best Practices

  1. Return 429 - Standard "Too Many Requests" status
  2. Include Retry-After - Tell clients when to retry
  3. Log violations - Track abuse patterns
  4. Tiered limits - Different limits for different tiers
  5. Graceful degradation - Consider "soft" limits first