Giao diện
Thiết kế API — REST, GraphQL và gRPC
Năm 2021, một team tại Việt Nam triển khai hệ thống thanh toán dùng REST API cho toàn bộ internal communication giữa 12 microservices. Latency trung bình p99 đạt 180ms — chấp nhận được với public API, nhưng khi nhân lên qua 4-5 hop giữa các service, end-to-end latency chạm 900ms. Họ chuyển inter-service communication sang gRPC, giữ REST cho public-facing endpoints, và p99 giảm xuống còn 320ms.
Câu chuyện trên minh hoạ một sự thật: không có paradigm API nào là "tốt nhất". REST, GraphQL, gRPC — mỗi cái sinh ra để giải quyết một bài toán khác nhau. Kỹ sư giỏi không tranh cãi "REST vs GraphQL" mà hỏi: "Ai gọi API này, data shape ra sao, và SLA là bao nhiêu?" Bài viết này đi sâu vào từng paradigm, các kỹ thuật thiết kế production-grade (versioning, pagination, rate limiting, idempotency), và đặc biệt là những trade-off mà documentation chính thức ít khi nói rõ.
Bức tranh tư duy
Hãy hình dung API như hệ thống giao thông trong một thành phố lớn:
- REST là mạng lưới đường bộ: phổ biến, ai cũng biết đi, có biển báo rõ ràng (HTTP methods, status codes). Nhưng khi muốn chở hàng nặng liên tục giữa hai nhà máy, đường bộ không phải lựa chọn nhanh nhất.
- gRPC là đường sắt chuyên dụng: nhanh, hiệu quả cho vận chuyển hàng loạt giữa các trạm cố định (microservices), nhưng bạn không thể dễ dàng đặt thêm trạm mới — cần infrastructure riêng.
- GraphQL là dịch vụ giao hàng theo yêu cầu: khách hàng chỉ định chính xác món cần nhận, không thừa không thiếu. Tuy nhiên, hệ thống dispatch phía sau phải đủ thông minh để tổng hợp đơn hàng hiệu quả.
Khi nào phép so sánh này không còn đúng? Khi API không chỉ phục vụ request-response mà cần streaming hai chiều (bidirectional streaming) — lúc đó gRPC vượt khỏi ẩn dụ "đường sắt" và trở thành một kênh liên lạc real-time mà REST và GraphQL subscription không thể sánh kịp về hiệu năng.
Cốt lõi kỹ thuật
REST — Kiến trúc resource-centric
REST (Representational State Transfer) là architectural style, không phải protocol. Roy Fielding định nghĩa 6 constraints: stateless, client-server, cacheable, uniform interface, layered system, và code-on-demand (optional). Phần lớn API tự nhận là "RESTful" thực tế chỉ là HTTP API — không tuân thủ HATEOAS.
HTTP Methods và tính chất:
| Method | Idempotent | Safe | Mục đích |
|---|---|---|---|
GET | Có | Có | Đọc resource |
POST | Không | Không | Tạo resource mới |
PUT | Có | Không | Thay thế toàn bộ resource |
PATCH | Tuỳ thiết kế | Không | Cập nhật một phần |
DELETE | Có | Không | Xoá resource |
Resource naming convention chuẩn:
GET /v1/orders # Danh sách orders
GET /v1/orders/{orderId} # Một order cụ thể
GET /v1/orders/{orderId}/items # Sub-resource
POST /v1/orders # Tạo order mới
PATCH /v1/orders/{orderId} # Cập nhật order
DELETE /v1/orders/{orderId} # Xoá orderGraphQL — Query language cho API
GraphQL cho phép client khai báo chính xác data cần lấy trong một request duy nhất, giải quyết hai vấn đề lớn của REST: over-fetching (trả về quá nhiều field) và under-fetching (phải gọi nhiều endpoint).
graphql
# Client chỉ lấy đúng những field cần hiển thị
query OrderSummary($userId: ID!) {
user(id: $userId) {
name
orders(first: 10, status: ACTIVE) {
edges {
node {
id
totalAmount
createdAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}Trade-off quan trọng: GraphQL đẩy complexity từ client sang server. N+1 query problem yêu cầu DataLoader, và bạn cần query complexity analysis để chặn những query quá nặng.
gRPC — Binary protocol cho inter-service
gRPC sử dụng HTTP/2 và Protocol Buffers (binary serialization), cho throughput cao hơn REST/JSON 5-10 lần. Hỗ trợ 4 communication patterns:
protobuf
// order_service.proto
syntax = "proto3";
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc StreamOrderUpdates(OrderFilter) returns (stream OrderEvent);
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItem items = 2;
string idempotency_key = 3;
}
message Order {
string id = 1;
string status = 2;
int64 total_cents = 3;
google.protobuf.Timestamp created_at = 4;
}API Versioning — Chiến lược evolution
Ba cách phổ biến, mỗi cách có trade-off riêng:
Quy tắc breaking change:
| Thay đổi | Breaking? | Ví dụ |
|---|---|---|
| Xoá field trong response | Có | Bỏ user.age |
| Đổi tên field | Có | name → full_name |
| Đổi kiểu dữ liệu | Có | id: number → id: string |
| Thêm required field (request) | Có | Thêm region bắt buộc |
| Thêm optional field | Không | Thêm nickname tuỳ chọn |
| Thêm endpoint mới | Không | GET /v1/analytics |
Nguyên tắc chung: với public API, hỗ trợ tối thiểu 2 versions song song và thông báo deprecation ít nhất 6 tháng trước khi sunset.
Pagination — Cursor vs Offset
Offset pagination đơn giản nhưng có hai lỗi nghiêm trọng ở quy mô lớn: (1) OFFSET 100000 buộc database scan từ đầu, (2) data bị trôi khi có insert/delete giữa các request.
Cursor pagination giải quyết cả hai — dùng giá trị cột indexed (thường là ID hoặc timestamp) làm mốc:
sql
-- Cursor-based: luôn O(log n) nhờ index
SELECT * FROM orders
WHERE created_at < '2024-06-15T10:00:00Z'
ORDER BY created_at DESC
LIMIT 20;
-- Offset-based: O(n) khi offset lớn
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;| Tiêu chí | Offset | Cursor |
|---|---|---|
| Nhảy đến trang N | Được | Không hỗ trợ |
| Hiệu năng dataset lớn | Kém (O(offset)) | Tốt (O(log n)) |
| Kết quả nhất quán | Có thể trôi | Ổn định |
| Phù hợp infinite scroll | Không lý tưởng | Rất phù hợp |
| Tính tổng số record | Dễ | Tốn thêm query |
Rate Limiting — Bảo vệ hệ thống
Ba thuật toán phổ biến:
Token Bucket — thuật toán được AWS API Gateway, Stripe sử dụng:
typescript
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(private readonly capacity: number, private readonly refillRate: number) {
this.tokens = capacity;
this.lastRefill = Date.now();
}
tryConsume(): boolean {
this.refill();
if (this.tokens < 1) return false;
this.tokens -= 1;
return true;
}
private refill(): void {
const elapsed = (Date.now() - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
this.lastRefill = Date.now();
}
}Response headers chuẩn RFC 6585:
http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1718451600Idempotency — An toàn khi retry
Trong distributed system, network timeout không có nghĩa là request thất bại — có thể server đã xử lý nhưng response bị mất. Idempotency đảm bảo client retry an toàn mà không gây side effect kép (debit 2 lần, tạo 2 đơn hàng).
Idempotency key nên dùng UUID v4 do client tạo, lưu trong Redis/DynamoDB với TTL 24-48 giờ.
OpenAPI Specification
Định nghĩa API contract bằng OpenAPI 3.x giúp tự động sinh documentation, client SDK, và validation middleware. Mỗi endpoint cần khai báo rõ: parameters, request body schema, response schema cho cả success và error cases (201, 400, 409, 429). OpenAPI spec nên sống cùng codebase, được validate trong CI, và là source of truth cho API documentation.
Thực chiến
Bài toán: Thiết kế Public API cho E-commerce Platform
Một platform thương mại điện tử cần expose API cho đối tác (merchant) quản lý sản phẩm và đơn hàng. Yêu cầu: hỗ trợ 500 merchant, mỗi merchant tối đa 50 request/giây, data có thể lên tới hàng triệu orders.
Kiến trúc tổng quan:
Quyết định thiết kế:
- REST cho public API — merchant đã quen HTTP/JSON, tooling phong phú (Postman, curl), dễ cache qua CDN.
- gRPC cho inter-service — Order Service gọi Inventory Service hàng triệu lần/ngày, binary protocol giảm latency 60%.
- Cursor pagination — catalog có triệu sản phẩm, offset không chấp nhận được.
- Idempotency-Key bắt buộc cho POST — tránh duplicate order khi merchant retry.
Implementation chi tiết:
typescript
// src/controllers/order.controller.ts
import { Request, Response, NextFunction } from 'express';
import { OrderService } from '../services/order.service';
import { IdempotencyStore } from '../stores/idempotency.store';
import { AppError } from '../errors/app-error';
export class OrderController {
constructor(
private readonly orderService: OrderService,
private readonly idempotencyStore: IdempotencyStore,
) {}
async createOrder(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
throw new AppError(400, 'MISSING_IDEMPOTENCY_KEY',
'Header Idempotency-Key là bắt buộc cho POST requests');
}
// Kiểm tra idempotency — trả cached response nếu key đã tồn tại
const cached = await this.idempotencyStore.get(idempotencyKey);
if (cached) {
res.status(cached.statusCode).json(cached.body);
return;
}
// Validate input
const { items, shippingAddress } = req.body;
if (!items?.length) {
throw new AppError(400, 'EMPTY_ORDER', 'Đơn hàng phải có ít nhất 1 sản phẩm');
}
// Tạo order — service layer xử lý business logic
const order = await this.orderService.create({
merchantId: req.auth.merchantId,
items,
shippingAddress,
});
// Lưu response vào idempotency store (TTL: 48h)
const response = { statusCode: 201, body: order };
await this.idempotencyStore.set(idempotencyKey, response, 48 * 3600);
res.status(201).json(order);
} catch (error) {
next(error);
}
}
}Error response chuẩn RFC 7807:
typescript
// src/middleware/error-handler.ts
export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
type: `https://api.example.com/errors/${err.code}`,
title: err.message,
status: err.statusCode,
detail: err.detail,
instance: req.originalUrl,
traceId: req.headers['x-trace-id'] || req.id,
});
return;
}
// Lỗi không mong đợi — log đầy đủ, trả thông tin tối thiểu cho client
console.error('Unhandled error:', { error: err.message, stack: err.stack, path: req.originalUrl });
res.status(500).json({
type: 'https://api.example.com/errors/INTERNAL_ERROR',
title: 'Internal Server Error',
status: 500,
instance: req.originalUrl,
});
}Tại sao mỗi quyết định được chọn?
- RFC 7807 error format: Chuẩn hoá error giúp merchant parse lỗi bằng code thay vì string matching.
traceIdcho phép support team debug nhanh. - Limit cứng
Math.min(limit, 100): Ngăn client lấy quá nhiều data trong 1 request, bảo vệ database và bandwidth. - Idempotency store dùng TTL: Tự dọn dẹp, không cần cron job. 48 giờ đủ để cover mọi retry scenario hợp lý.
Sai lầm điển hình
Sai lầm 1: Dùng verb trong URL
# SAI — URL chứa hành động
POST /api/createUser
GET /api/getUser?id=123
POST /api/deleteUser
# ĐÚNG — Resource-centric, method quyết định hành động
POST /api/v1/users # Tạo user
GET /api/v1/users/123 # Lấy user
DELETE /api/v1/users/123 # Xoá userHậu quả production: API surface phình to không kiểm soát. Sau 2 năm, team kia có 200+ endpoint kiểu /doSomething mà không ai nhớ hết, documentation trở thành ác mộng.
Sai lầm 2: Trả về 200 cho mọi response
typescript
// SAI — Status code luôn 200, error nằm trong body
app.post('/orders', async (req, res) => {
try {
const order = await createOrder(req.body);
res.json({ success: true, data: order });
} catch (err) {
res.json({ success: false, error: err.message }); // vẫn 200!
}
});
// ĐÚNG — Dùng HTTP status code đúng ngữ nghĩa
app.post('/orders', async (req, res, next) => {
try {
const order = await createOrder(req.body);
res.status(201).json(order); // 201 Created
} catch (err) {
if (err instanceof ValidationError) {
res.status(400).json({
type: 'https://api.example.com/errors/VALIDATION_ERROR',
title: 'Invalid request data',
status: 400,
errors: err.details,
});
return;
}
next(err); // 500 qua error handler
}
});Hậu quả production: Monitoring tool (Datadog, Grafana) dựa vào status code để alert. Nếu mọi response đều 200, bạn mất khả năng detect lỗi tự động. API Gateway cũng không thể retry đúng logic.
Sai lầm 3: Không có idempotency cho POST mutation
typescript
// SAI — Không có idempotency key, retry gây duplicate
app.post('/payments', async (req, res) => {
const payment = await chargeCard(req.body); // Trừ tiền 2 lần nếu retry!
res.json(payment);
});
// ĐÚNG — Idempotency key ngăn duplicate charge
app.post('/payments', async (req, res) => {
const key = req.headers['idempotency-key'];
if (!key) {
return res.status(400).json({
type: 'https://api.example.com/errors/MISSING_IDEMPOTENCY_KEY',
title: 'Idempotency-Key header is required',
status: 400,
});
}
const existing = await idempotencyStore.get(key);
if (existing) {
return res.status(existing.statusCode).json(existing.body);
}
const payment = await chargeCard(req.body);
await idempotencyStore.set(key, { statusCode: 201, body: payment });
res.status(201).json(payment);
});Hậu quả production: Một fintech startup mất 3 tháng đầu hoạt động phải refund thủ công hàng trăm giao dịch duplicate vì thiếu idempotency. Chi phí không chỉ tiền bạc mà còn mất niềm tin từ đối tác.
Sai lầm 4: Expose internal ID và database schema
typescript
// SAI — Lộ auto-increment ID và internal field names
{
"id": 47832, // Attacker biết có ~47832 users
"password_hash": "...", // Lộ thông tin nhạy cảm
"internal_status": 3 // Magic number không ai hiểu
}
// ĐÚNG — Public ID opaque, chỉ trả field cần thiết
{
"id": "usr_a8f3kd92mx", // Opaque, không đoán được
"email": "user@example.com",
"status": "active",
"created_at": "2024-06-15T10:30:00Z"
}Hậu quả production: Sequential ID cho phép enumeration attack — attacker thử GET /users/1, /users/2, ... để duyệt toàn bộ user base. Với UUID hoặc prefixed random ID, attack surface giảm đáng kể.
Sai lầm 5: GraphQL không giới hạn query depth
graphql
# SAI — Cho phép query đệ quy vô hạn
query MaliciousQuery {
user(id: "1") {
friends {
friends {
friends {
friends {
# ... 20 cấp nữa → server OOM
}
}
}
}
}
}typescript
// ĐÚNG — Giới hạn query complexity
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 10,
listFactor: 20,
}),
],
});Hậu quả production: Một GraphQL endpoint không có depth limit bị exploit bằng nested query, gây CPU spike 100% trong 4 phút — đủ để down toàn bộ cluster.
Under the Hood
So sánh hiệu năng thực tế
Benchmark dưới đây đo trên cùng một workload (CRUD đơn giản, payload 1KB, cùng infrastructure):
| Metric | REST/JSON | GraphQL/JSON | gRPC/Protobuf |
|---|---|---|---|
| Serialization size | ~1.2 KB | ~1.0 KB | ~0.4 KB |
| Latency p50 | 12 ms | 15 ms | 4 ms |
| Latency p99 | 45 ms | 60 ms | 12 ms |
| Throughput (req/s/core) | ~8,000 | ~5,000 | ~25,000 |
| CPU usage (relative) | 1x | 1.5x | 0.6x |
| Browser support | Native | Native | Cần grpc-web |
Lưu ý: GraphQL latency cao hơn REST do query parsing và resolver orchestration. Tuy nhiên, với use case cần nhiều data từ nhiều source, 1 GraphQL request thay thế 3-5 REST requests — tổng latency end-to-end thấp hơn.
Khi nào dùng gì — theo quy mô
Chi phí vận hành ẩn
| Yếu tố | REST | GraphQL | gRPC |
|---|---|---|---|
| Developer onboarding | Thấp (1 ngày) | Trung bình (3-5 ngày) | Trung bình (2-3 ngày) |
| Tooling & monitoring | Rất phong phú | Cần chuyên biệt | Cần chuyên biệt |
| Cache infrastructure | CDN + HTTP cache | Custom (normalized) | Không có chuẩn |
| Schema evolution | Đơn giản | Schema stitching phức tạp | Proto backward compat |
| Debugging production issues | Dễ (plaintext) | Trung bình (JSON) | Khó (binary) |
Checklist ghi nhớ
✅ Checklist triển khai
Thiết kế API
- [ ] Chọn paradigm phù hợp với consumer (public → REST, internal → gRPC, mobile BFF → GraphQL)
- [ ] Resource naming: danh từ số nhiều, không dùng verb trong URL
- [ ] HTTP status code đúng ngữ nghĩa (201 Created, 400 Bad Request, 429 Too Many Requests)
- [ ] Error response theo RFC 7807 với traceId
Versioning
- [ ] Chiến lược versioning rõ ràng từ ngày đầu (URL path hoặc header)
- [ ] Deprecation policy: thông báo 6+ tháng trước khi sunset version cũ
- [ ] Backward compatibility test trong CI pipeline
Pagination
- [ ] Cursor pagination cho dataset lớn (> 10K records) hoặc real-time data
- [ ] Limit cứng tối đa (thường 100 items/page)
- [ ] Response bao gồm
has_morevànext_cursor
Security & Reliability
- [ ] Rate limiting với response headers chuẩn (X-RateLimit-*, Retry-After)
- [ ] Idempotency-Key bắt buộc cho mọi POST mutation có side effect
- [ ] Opaque public ID, không expose auto-increment hoặc internal ID
- [ ] Input validation ở API layer, không phụ thuộc hoàn toàn vào database constraints
GraphQL (nếu sử dụng)
- [ ] Query depth limit và complexity analysis
- [ ] DataLoader cho N+1 query prevention
- [ ] Persisted queries cho production (chặn arbitrary query)
Documentation & Contract
- [ ] OpenAPI spec (REST) hoặc SDL (GraphQL) được maintain cùng code
- [ ] Contract testing giữa provider và consumer
Bài tập luyện tập
Bài 1: Thiết kế API cho hệ thống quản lý task
Thiết kế REST API cho một ứng dụng quản lý task (kiểu Trello đơn giản) với các yêu cầu:
- CRUD cho boards, lists, và cards
- Cursor pagination cho danh sách cards
- Idempotency cho tạo card
- Rate limiting: 100 requests/phút/user
🧠 Quiz
Câu hỏi: Endpoint nào dưới đây đúng chuẩn REST resource naming?
A. POST /api/v1/boards/{boardId}/createList B. POST /api/v1/boards/{boardId}/lists C. GET /api/v1/getCards?boardId=123 D. PUT /api/v1/cards/update/456
Đáp án: B
Giải thích: REST dùng danh từ cho resource, HTTP method quyết định hành động. POST /boards/{boardId}/lists — tạo list mới trong board, rõ ràng và đúng chuẩn. Các đáp án khác đều chứa verb trong URL.
Gợi ý thiết kế
yaml
# Boards
GET /v1/boards # Danh sách boards
POST /v1/boards # Tạo board (Idempotency-Key required)
GET /v1/boards/{boardId} # Chi tiết board
PATCH /v1/boards/{boardId} # Cập nhật board
# Lists (sub-resource của board)
GET /v1/boards/{boardId}/lists # Danh sách lists trong board
POST /v1/boards/{boardId}/lists # Tạo list
PATCH /v1/boards/{boardId}/lists/{listId} # Cập nhật list
DELETE /v1/boards/{boardId}/lists/{listId} # Xoá list
# Cards (cursor pagination)
GET /v1/lists/{listId}/cards?cursor=xxx&limit=20
POST /v1/lists/{listId}/cards # Idempotency-Key required
PATCH /v1/cards/{cardId}
DELETE /v1/cards/{cardId}
# Response format cho pagination
{
"data": [...],
"pagination": {
"next_cursor": "card_abc123",
"has_more": true
}
}Bài 2: Phân tích trade-off — REST vs GraphQL cho mobile app
Một ứng dụng mobile hiển thị trang chủ gồm: thông tin user, 5 đơn hàng gần nhất (mỗi đơn có danh sách sản phẩm), và 3 thông báo mới nhất. So sánh cách triển khai bằng REST và GraphQL.
🧠 Quiz
Câu hỏi: Với REST, cần tối thiểu bao nhiêu HTTP requests để lấy đủ data cho trang chủ trên?
A. 1 request B. 3 requests (user + orders + notifications) C. 8 requests (user + orders + 5 order details + notifications) D. Tuỳ thuộc vào API design, có thể là 1 nếu dùng BFF pattern
Đáp án: D
Giải thích: Nếu API được thiết kế granular theo resource, cần ít nhất 3 requests (option B), có thể lên 8+ nếu order detail là endpoint riêng (option C). Tuy nhiên, BFF (Backend for Frontend) pattern cho phép tạo endpoint /v1/homepage trả về tất cả data cần thiết trong 1 request. GraphQL giải quyết bài toán này tự nhiên hơn vì client tự chọn data cần lấy.
So sánh chi tiết
REST granular: 8 requests (user + orders + 5 order details + notifications). REST BFF: 1 request tổng hợp, nhưng coupling frontend-backend chặt.
GraphQL approach:
graphql
query Homepage {
me {
name
orders(first: 5) { edges { node { id total items { name } } } }
notifications(first: 3) { edges { node { id message createdAt } } }
}
}Trade-off: GraphQL linh hoạt hơn BFF nhưng cần DataLoader, complexity limiting, và caching strategy riêng.
Bài 3: Implement rate limiter
Viết middleware Express.js cho rate limiting dùng sliding window + Redis. Yêu cầu: 100 req/phút/API key, response headers chuẩn, 429 với Retry-After.
🧠 Quiz
Câu hỏi: Tại sao sliding window log chính xác hơn fixed window counter?
A. Vì sliding window dùng ít memory hơn B. Vì fixed window cho phép burst gấp đôi limit tại biên giữa hai window C. Vì sliding window nhanh hơn khi query Redis D. Vì fixed window không hỗ trợ distributed system
Đáp án: B
Giải thích: Với fixed window 100 req/phút, client có thể gửi 100 req ở giây cuối của window cũ và 100 req ở giây đầu của window mới — tổng cộng 200 req trong 2 giây. Sliding window đếm request trong chính xác 60 giây gần nhất, loại bỏ burst ở biên window.
Implementation gợi ý
typescript
import { Redis } from 'ioredis';
import { Request, Response, NextFunction } from 'express';
const WINDOW_SEC = 60;
const MAX_REQ = 100;
export async function slidingWindowRateLimit(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) return res.status(401).json({ error: 'Missing API key' });
const now = Date.now();
const key = `ratelimit:${apiKey}`;
// Redis sorted set: score = timestamp, member = unique ID
const pipe = redis.pipeline();
pipe.zremrangebyscore(key, 0, now - WINDOW_SEC * 1000);
pipe.zadd(key, now, `${now}:${Math.random()}`);
pipe.zcard(key);
pipe.expire(key, WINDOW_SEC);
const results = await pipe.exec();
const count = results?.[2]?.[1] as number;
res.setHeader('X-RateLimit-Limit', MAX_REQ);
res.setHeader('X-RateLimit-Remaining', Math.max(0, MAX_REQ - count));
if (count > MAX_REQ) {
res.setHeader('Retry-After', WINDOW_SEC);
return res.status(429).json({
type: 'https://api.example.com/errors/RATE_LIMIT_EXCEEDED',
title: 'Too Many Requests',
status: 429,
});
}
next();
}