Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/rateLimitContract.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
31 changes: 27 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FastifyInstance> {
// Share the structured logger configuration (level, redaction, dev pretty
// printing) with Fastify so `req.log` is scrubbed the same way.
Expand Down Expand Up @@ -110,7 +117,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// requests fall back to a conservative shared limit keyed by IP. The IP
// fallback is overridable via RATE_LIMIT_IP_MAX so load tests (which flood
// from a single IP without a key) can measure the endpoint, not the limiter.
const ipRateLimitMax = parseInt(process.env.RATE_LIMIT_IP_MAX ?? '100', 10)
const ipRateLimitMax = parseInt(process.env.RATE_LIMIT_IP_MAX ?? String(RATE_LIMITS.ipDefault), 10)
await app.register(rateLimit, {
max: (req) => req.apiKey?.ratePerMin ?? ipRateLimitMax,
timeWindow: '1 minute',
Expand All @@ -120,14 +127,30 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
allowList: (req) => 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
Expand Down
Loading