From 363009de672de22d2c489b427d0ee45e0286ed53 Mon Sep 17 00:00:00 2001 From: sparkysolutions10-pixelmy Date: Mon, 19 Oct 2026 10:05:57 +0530 Subject: [PATCH] fix(api): standard 429 response contract with retryAfter and Retry-After header (#272) --- README.md | 27 ++++++++++++++ src/__tests__/rateLimitContract.test.ts | 49 +++++++++++++++++++++++++ src/app.ts | 31 ++++++++++++++-- 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/rateLimitContract.test.ts diff --git a/README.md b/README.md index 0b6cdb7c..f55a8d11 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,33 @@ curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated *(Note: timestamp values above reflect the dynamic hourly anchor at seed execution time and request time).* +## Rate Limiting & 429 Handling + +Lens enforces rate limits per client IP or authenticated API key to protect system throughput: + +| Tier / Endpoint | Window | Limit | +| :--- | :--- | :--- | +| **Public Unauthenticated (by IP)** | 1 minute | `100 requests` (`RATE_LIMITS.ipDefault`) | +| **Authenticated API Keys** | 1 minute | `1,000 requests` (`RATE_LIMITS.authenticatedDefault`) | +| **Public Health/Monitoring (`/status`)** | 1 minute | `1,000 requests` (`RATE_LIMITS.monitoring`) | + +### Handling 429 (Too Many Requests) +When a client exceeds their allowance, Lens replies with HTTP status `429 Too Many Requests`. + +1. **`Retry-After` Header:** Specifies the number of seconds to wait before retrying (e.g., `Retry-After: 42`). +2. **Standardized Error Body:** + ```json + { + "statusCode": 429, + "error": "Too Many Requests", + "code": "rate_limited", + "message": "Rate limit exceeded, retry in 42 seconds", + "retryAfter": 42 + } + ``` +Callers should parse the `Retry-After` header or `retryAfter` field and apply exponential backoff. + + ## Environment Variables | Variable | Description | Default | Required | diff --git a/src/__tests__/rateLimitContract.test.ts b/src/__tests__/rateLimitContract.test.ts new file mode 100644 index 00000000..5ca90216 --- /dev/null +++ b/src/__tests__/rateLimitContract.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { buildApp, RATE_LIMITS } from '../app' +import type { FastifyInstance } from 'fastify' + +describe('Rate Limiting & 429 Contract (#272)', () => { + let app: FastifyInstance + + beforeEach(async () => { + process.env.REQUIRE_API_KEY = 'false' + process.env.RATE_LIMIT_IP_MAX = '5' + app = await buildApp() + await app.ready() + }) + + afterEach(async () => { + delete process.env.REQUIRE_API_KEY + delete process.env.RATE_LIMIT_IP_MAX + await app.close() + }) + + it('exports RATE_LIMITS constants with default quotas matching documentation', () => { + expect(RATE_LIMITS.ipDefault).toBe(100) + expect(RATE_LIMITS.authenticatedDefault).toBe(1000) + expect(RATE_LIMITS.monitoring).toBe(1000) + expect(RATE_LIMITS.timeWindow).toBe('1 minute') + }) + + it('returns 429 with code rate_limited and Retry-After header when limit exceeded', async () => { + let rateLimitedRes: any = null + + for (let i = 0; i < 7; i++) { + const res = await app.inject({ + method: 'GET', + url: '/pairs', + }) + if (res.statusCode === 429) { + rateLimitedRes = res + break + } + } + + expect(rateLimitedRes).not.toBeNull() + expect(rateLimitedRes.statusCode).toBe(429) + const body = JSON.parse(rateLimitedRes.payload) + expect(body.code).toBe('rate_limited') + expect(typeof body.retryAfter).toBe('number') + expect(rateLimitedRes.headers['retry-after']).toBeDefined() + }) +}) diff --git a/src/app.ts b/src/app.ts index 405e4c48..e93322df 100644 --- a/src/app.ts +++ b/src/app.ts @@ -66,6 +66,13 @@ export interface BuildAppOptions { * * The returned app is NOT listening; the caller calls `app.listen(...)`. */ +export const RATE_LIMITS = { + ipDefault: 100, + authenticatedDefault: 1000, + monitoring: 1000, + timeWindow: '1 minute', +} as const + export async function buildApp(options: BuildAppOptions = {}): Promise { // Share the structured logger configuration (level, redaction, dev pretty // printing) with Fastify so `req.log` is scrubbed the same way. @@ -110,7 +117,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise req.apiKey?.ratePerMin ?? ipRateLimitMax, timeWindow: '1 minute', @@ -120,14 +127,30 @@ export async function buildApp(options: BuildAppOptions = {}): Promise PROBE_PATHS.has(req.url), errorResponseBuilder: (req, context) => { rate_limit_rejections_total.inc({ scope: req.apiKey ? 'key' : 'ip' }) + const seconds = typeof context.after === 'number' ? context.after : (Math.ceil(context.ttl / 1000) || 60) return { statusCode: 429, error: 'Too Many Requests', - code: req.apiKey ? 'PER_KEY_RATE_LIMITED' : 'IP_RATE_LIMITED', - message: `Rate limit exceeded, retry in ${context.after}`, - retryAfter: context.after + code: 'rate_limited', + message: `Rate limit exceeded, retry in ${seconds} seconds`, + retryAfter: seconds, + } + }, + }) + + // Ensure Retry-After header is always present on 429 responses (#272) + app.addHook('onSend', async (_req, reply, payload) => { + if (reply.statusCode === 429 && !reply.hasHeader('retry-after')) { + try { + const body = typeof payload === 'string' ? JSON.parse(payload) : payload + if (body?.retryAfter) { + reply.header('Retry-After', String(body.retryAfter)) + } + } catch { + // non-fatal } } + return payload }) // The per-instance ceiling runs after the per-key/IP limiter. Health checks