Thiết kế REST API dễ dùng, khó dùng sai: naming, status code, lỗi, pagination, idempotency và versioning
Bộ quy ước REST API mình áp cho mọi service Node.js: đặt tên resource, status code đúng, lỗi chuẩn RFC 9457, cursor pagination, Idempotency-Key, ETag/If-Match và báo deprecation bằng Sunset header.
Mình từng review một API đặt hàng mà mobile app gọi POST /createOrder. Request timeout trên 4G, app tự retry, và khách bị trừ tiền hai lần. Cùng API đó trả 200 OK kèm { "success": false } khi lỗi, mỗi endpoint một kiểu message. Còn khi hai nhân viên kế toán cùng sửa một hóa đơn, người lưu sau ghi đè người lưu trước mà không ai hay biết.
Không lỗi nào ở trên là bug logic. Đó là lỗi thiết kế contract: API cho phép client dùng sai một cách rất tự nhiên. Bài này là bộ quy ước mình áp cho mọi REST API mới. Mỗi quy ước đi kèm code chạy được với Hono, zod và PostgreSQL, kèm lý do, cái giá phải trả và edge case hay gây sự cố.
Bài chỉ bàn về contract (những gì client nhìn thấy). Phần implementation với MongoDB như schema, index và transaction nằm ở bài REST API với Node.js và MongoDB. Bài đó dùng lại đúng các quy ước ở đây.
TL;DR
- URL dùng danh từ số nhiều, nesting tối đa một cấp, method đúng ngữ nghĩa. Status code cần thuộc: 201 +
Location, 204, 400/422, 401/403, 404, 409, 412, 429. - Cả API chỉ có một format lỗi:
application/problem+jsontheo RFC 9457. - Dùng cursor pagination với cursor opaque và
limitcó trần. - Mọi POST tạo tiền hoặc đơn hàng đều nhận
Idempotency-Key. Server lưu response đầu tiên và replay khi client retry. ETag+If-Matchđể chặn lost update. GửiDeprecation+Sunsetrất lâu trước khi tắt version cũ.
Đặt tên resource và dùng method đúng ngữ nghĩa
Bắt đầu bằng bản đồ endpoint. Nếu bản đồ này đọc lên thấy tự nhiên, phần lớn tranh cãi về sau sẽ biến mất.
GET /v1/orders # list: filter, sort, pagination
POST /v1/orders # tạo đơn → 201 + Location
GET /v1/orders/{orderId} # chi tiết, trả ETag
PATCH /v1/orders/{orderId} # sửa một phần, bắt buộc If-Match
DELETE /v1/orders/{orderId} # xóa → 204
GET /v1/customers/{customerId}/orders # list trong ngữ cảnh customer (1 cấp)
POST /v1/orders/{orderId}/cancellation # hành động = tạo sub-resource
POST /v1/invoices/{invoiceId}/payments # thanh toán = tạo payment
Các quy tắc đằng sau:
- Collection dùng danh từ số nhiều:
/orders, không phải/orderhay/getOrders. Động từ đã nằm trong HTTP method, viết thêm động từ vào path là nói hai lần. - Nesting tối đa một cấp. Với
/customers/{id}/orders/{orderId}/items/{itemId}, client phải biết cả chuỗi cha mới chạm được item, và mỗi tầng là thêm một chỗ phải kiểm tra quyền. Item đã có ID duy nhất thì cho nó URL riêng hoặc embed luôn trong order. - Hành động không khớp CRUD (hủy đơn, gửi hóa đơn) thì mô hình hóa thành sub-resource được tạo ra:
POST /orders/{id}/cancellationthay vìPOST /cancelOrder. Cancellation khi đó có body (lý do hủy), có thể GET lại để xem trạng thái, và tuân theo cùng quy tắc idempotency như mọi POST khác. - Path dùng kebab-case, field JSON dùng camelCase (hoặc snake_case). Chọn một kiểu và áp cho toàn bộ API, kể cả query param.
| Method | Safe | Idempotent | Dùng cho |
|---|---|---|---|
| GET, HEAD | Có | Có | Đọc. Không bao giờ thay đổi state. |
| POST | Không | Không | Tạo resource, kích hoạt hành động |
| PUT | Không | Có | Thay thế toàn bộ resource |
| PATCH | Không | Không đảm bảo | Sửa một phần |
| DELETE | Không | Có | Xóa. Gọi lần hai vẫn ra cùng trạng thái cuối |
Cột idempotent quan trọng hơn bạn nghĩ. Proxy, HTTP client và SDK có thể tự retry các method idempotent khi mất kết nối. Vì vậy một GET /orders/{id}/confirm có side effect sẽ bị gọi lại mà bạn không kiểm soát được. PUT nghĩa là thay toàn bộ: client gửi thiếu field note thì note phải bị xóa. Nếu bạn chỉ muốn sửa vài field, hãy dùng PATCH với ngữ nghĩa JSON Merge Patch (RFC 7396): field có mặt thì ghi đè, null nghĩa là xóa field, field vắng mặt thì giữ nguyên.
Những status code thật sự cần dùng
Một request tạo đơn hàng đúng chuẩn trông như sau:
POST /v1/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: "5f0c2e1a-8d7b-4c47-9a53-0b6f1f7e2c11"
{"customerId":"0b9d2c7e-3f1a-4e55-9a10-2f4c8e6d1a77","currency":"VND","items":[{"sku":"TSHIRT-M","quantity":2}]}
HTTP/1.1 201 Created
Location: /v1/orders/7f3e9a52-1c4b-4d8e-b6a1-5e2f0c9d8b34
Content-Type: application/json
ETag: "1"
{"id":"7f3e9a52-1c4b-4d8e-b6a1-5e2f0c9d8b34","status":"pending","totalAmount":398000,"currency":"VND"}
Danh sách status code mình dùng, kèm quy tắc chọn:
- 200: GET hoặc PATCH thành công và có body.
- 201 +
Location: tạo resource đồng bộ. Trả luôn resource trong body để client khỏi phải GET lại. - 202 Accepted: đã nhận việc và sẽ xử lý nền, ví dụ export báo cáo. Trả
Locationtrỏ tới resource job để client poll. - 204: thành công, không có body (DELETE). Đừng gửi body kèm 204, nhiều client và proxy sẽ bỏ hoặc báo lỗi.
- 400 vs 422: mình dùng 400 khi request không parse được (JSON hỏng, thiếu header bắt buộc, cursor không giải mã được) và 422 khi parse được nhưng vi phạm schema hoặc business rule (quantity âm, currency không hỗ trợ). Nhiều team dùng 400 cho tất cả cũng không sai, miễn nhất quán và body lỗi chỉ rõ field nào hỏng.
- 401 vs 403: 401 là chưa xác thực hoặc token hết hạn, và theo RFC 9110 phải kèm header
WWW-Authenticate. Client nhận 401 thì refresh token hoặc đăng nhập lại. 403 là server biết bạn là ai nhưng không cho làm việc này, refresh token cũng vô ích. Trả 403 khi token hết hạn sẽ khiến app không biết phải refresh. - 404: không tồn tại, và cả khi resource tồn tại nhưng thuộc tenant khác. Trả 403 trong trường hợp này chẳng khác gì xác nhận với attacker rằng ID đó có thật.
- 409: xung đột với trạng thái hiện tại, ví dụ email đã đăng ký, hủy một đơn đã giao, hay Idempotency-Key đang được xử lý.
- 412 / 428:
If-Matchkhông khớp, hoặc server bắt buộc gửiIf-Matchmà client không gửi (chi tiết ở phần ETag). - 429: vượt rate limit, kèm
Retry-Aftertính bằng giây. SDK của bạn nên đợi đúng khoảng đó thay vì retry ngay. - 500 vs 503: 500 là bug của bạn. 503 là sự cố tạm thời (bảo trì, dependency sập), kèm
Retry-Afternếu biết trước.
Một format lỗi cho cả API: RFC 9457 problem+json
RFC 9457 (07/2023, thay thế RFC 7807) định nghĩa media type application/problem+json với các member type, title, status, detail, instance, và cho phép thêm extension member. Dùng chuẩn này nghĩa là client chỉ cần viết một error handler duy nhất, và tooling như OpenAPI generator hay API gateway đều đã hiểu sẵn format này.
// src/http/problem.ts
export const PROBLEM_BASE = 'https://api.acme.vn/problems'
export type ProblemDetails = {
type: string // URI ổn định, client dùng để rẽ nhánh
title: string // mô tả ngắn, không đổi giữa các lần xảy ra
status: number
detail?: string // chi tiết của lần xảy ra này
instance?: string
errors?: Array<{ pointer?: string; parameter?: string; detail: string }>
[extension: string]: unknown
}
export class ApiProblem extends Error {
constructor(
readonly problem: ProblemDetails,
readonly headers: Record<string, string> = {},
) {
super(problem.title)
}
}
export function problemResponse(
instance: string,
problem: ProblemDetails,
headers: Record<string, string> = {},
): Response {
return new Response(JSON.stringify({ ...problem, instance: problem.instance ?? instance }), {
status: problem.status,
headers: { 'Content-Type': 'application/problem+json', ...headers },
})
}
Gắn vào app để mọi lỗi, kể cả route không tồn tại và exception không lường trước, đều ra cùng một format:
// src/app.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { HTTPException } from 'hono/http-exception'
import { authenticate } from './auth.js' // trả về accountId hoặc null
import { ApiProblem, PROBLEM_BASE, problemResponse } from './http/problem.js'
export type AppEnv = { Variables: { accountId: string } }
export const app = new Hono<AppEnv>()
app.use(
'/v1/*',
cors({
origin: ['https://app.acme.vn'],
allowHeaders: ['Authorization', 'Content-Type', 'Idempotency-Key', 'If-Match'],
exposeHeaders: ['ETag', 'Location', 'Retry-After', 'Deprecation', 'Sunset', 'Link'],
}),
)
app.use('/v1/*', async (c, next) => {
const accountId = await authenticate(c.req.header('authorization'))
if (!accountId) {
throw new ApiProblem(
{ type: `${PROBLEM_BASE}/unauthenticated`, title: 'Chưa xác thực hoặc token hết hạn', status: 401 },
{ 'WWW-Authenticate': 'Bearer realm="api"' },
)
}
c.set('accountId', accountId)
await next()
})
app.notFound((c) =>
problemResponse(c.req.path, { type: 'about:blank', title: 'Not Found', status: 404 }),
)
app.onError((err, c) => {
if (err instanceof ApiProblem) return problemResponse(c.req.path, err.problem, err.headers)
if (err instanceof HTTPException) {
// ví dụ: Hono validator ném 400 khi body không phải JSON hợp lệ
return problemResponse(c.req.path, { type: 'about:blank', title: err.message || 'Bad Request', status: err.status })
}
console.error(err) // log đầy đủ ở server, không bao giờ trả stack trace cho client
return problemResponse(c.req.path, { type: 'about:blank', title: 'Internal Server Error', status: 500 })
})
// src/server.ts: import app, import các file route, rồi
// serve({ fetch: app.fetch, port: 3000 }) với serve từ '@hono/node-server'
Vài quy tắc mình giữ chặt:
typelà contract. Client rẽ nhánh theotype(hoặcstatus), không bao giờ parsetitle, vìtitlecó thể được dịch hoặc sửa câu chữ.about:blankdùng khi lỗi không có ngữ nghĩa gì thêm ngoài status code.- URI trong
typekhông bắt buộc phải truy cập được, nhưng nếu nó trỏ tới trang tài liệu giải thích lỗi thì người tích hợp sẽ biết ơn bạn. - Lỗi 500 chỉ trả thông tin chung. Muốn hỗ trợ debug thì thêm extension
traceIdđể support tra log, thay vì trả message của exception (message đó có thể chứa câu SQL hay đường dẫn file).
Validate input ở boundary với zod (ví dụ Hono)
Validate một lần ở rìa hệ thống. Sau lớp này, code bên trong chỉ làm việc với dữ liệu đã có type chắc chắn. Mình bọc zValidator để lỗi validation luôn ra problem+json, với pointer theo JSON Pointer đúng như ví dụ trong RFC 9457:
// src/http/validate.ts
import { zValidator } from '@hono/zod-validator'
import type { ValidationTargets } from 'hono'
import * as z from 'zod'
import { ApiProblem, PROBLEM_BASE } from './problem.js'
const toPointer = (path: PropertyKey[]) =>
'#/' + path.map((seg) => String(seg).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')
export const validate = <T extends z.ZodType, Target extends keyof ValidationTargets>(
target: Target,
schema: T,
) =>
zValidator(target, schema, (result) => {
if (!result.success) {
throw new ApiProblem({
type: `${PROBLEM_BASE}/validation-error`,
title: 'Dữ liệu gửi lên không hợp lệ',
status: 422,
errors: result.error.issues.map((issue) =>
target === 'json'
? { pointer: toPointer(issue.path), detail: issue.message }
: { parameter: issue.path.map(String).join('.'), detail: issue.message },
),
})
}
})
export const CreateOrderBody = z.strictObject({
customerId: z.uuid({ error: 'customerId phải là UUID' }),
currency: z.enum(['VND', 'USD'], { error: 'currency chỉ nhận VND hoặc USD' }),
items: z
.array(
z.strictObject({
sku: z.string().min(1).max(64),
quantity: z.int().positive({ error: 'quantity phải lớn hơn 0' }).max(1000),
}),
)
.min(1)
.max(100),
note: z.string().max(500).optional(),
})
Client gửi quantity: 0 và currency: "EUR" sẽ nhận về:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.acme.vn/problems/validation-error",
"title": "Dữ liệu gửi lên không hợp lệ",
"status": 422,
"instance": "/v1/orders",
"errors": [
{ "pointer": "#/currency", "detail": "currency chỉ nhận VND hoặc USD" },
{ "pointer": "#/items/0/quantity", "detail": "quantity phải lớn hơn 0" }
]
}
Vì sao lại là z.strictObject mà không phải z.object? z.object lặng lẽ bỏ qua key lạ, nên client gõ nhầm quantiy vẫn nhận 201 với quantity mặc định. Tệ hơn, nếu bạn từng spread body vào câu insert thì "status": "paid" gửi kèm sẽ thành lỗ hổng mass assignment. Strict object trả lỗi ngay, đúng chỗ.
Hai edge case với Hono:
- Target
jsonchỉ parse body khi request có headerContent-Type: application/json. Thiếu header thì validator nhận{}(docs Hono có cảnh báo rõ), và client sẽ thấy một loạt lỗi "thiếu field" rất khó hiểu. Mình thêm một middleware trả 415 cho POST/PUT/PATCH không có Content-Type JSON. - Khi validate target
header, key trong schema phải viết thường ('idempotency-key', không phải'Idempotency-Key').
Query string luôn là chuỗi, nên số phải dùng z.coerce.number(). Phần pagination ngay dưới đây sẽ dùng cách đó.
Pagination: offset vs cursor, filter và sort
LIMIT 20 OFFSET 100000 bắt PostgreSQL đọc rồi vứt đi 100.000 dòng trước khi trả 20 dòng bạn cần, nên trang càng sâu càng chậm. Nó còn không ổn định: có đơn mới chen vào giữa hai lần gọi thì cả danh sách bị đẩy xuống, client thấy một đơn xuất hiện hai lần. Offset chỉ hợp với bảng admin dữ liệu nhỏ, nơi người dùng thật sự cần "nhảy tới trang 37".
Mặc định của mình là cursor (keyset) pagination: nhớ vị trí của dòng cuối cùng rồi lấy tiếp từ đó.
// src/routes/orders.list.ts
import * as z from 'zod'
import { app } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from '../http/problem.js'
import { validate } from '../http/validate.js'
const ListOrdersQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20), // có trần, vượt trần thì 422
cursor: z.string().max(512).optional(),
status: z.enum(['pending', 'paid', 'cancelled']).optional(),
sort: z.enum(['-createdAt', 'createdAt']).default('-createdAt'), // allowlist, không nhận tên cột tùy ý
})
type Cursor = { t: string; id: string; s: string } // t = created_at dạng text để giữ microsecond
const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url')
function decodeCursor(raw: string, sort: string): Cursor {
try {
const c = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as Cursor
if (c.s !== sort || typeof c.t !== 'string' || typeof c.id !== 'string') throw new Error('mismatch')
return c
} catch {
throw new ApiProblem({ type: `${PROBLEM_BASE}/invalid-cursor`, title: 'Cursor không hợp lệ', status: 400 })
}
}
app.get('/v1/orders', validate('query', ListOrdersQuery), async (c) => {
const q = c.req.valid('query')
const desc = q.sort === '-createdAt'
const params: unknown[] = [c.get('accountId')]
let where = 'account_id = $1'
if (q.status) {
params.push(q.status)
where += ` AND status = $${params.length}`
}
if (q.cursor) {
const cur = decodeCursor(q.cursor, q.sort)
params.push(cur.t, cur.id)
where += ` AND (created_at, id) ${desc ? '<' : '>'} ($${params.length - 1}::timestamptz, $${params.length}::uuid)`
}
params.push(q.limit + 1) // lấy dư 1 dòng để biết còn trang sau không
const dir = desc ? 'DESC' : 'ASC'
const { rows } = await pool.query(
`SELECT id, status, total_amount, currency, created_at, created_at::text AS cursor_t
FROM orders WHERE ${where}
ORDER BY created_at ${dir}, id ${dir}
LIMIT $${params.length}`,
params,
)
const hasMore = rows.length > q.limit
const page = rows.slice(0, q.limit)
const last = page.at(-1)
return c.json({
data: page.map(({ cursor_t, ...order }) => order),
page: { hasMore, nextCursor: hasMore && last ? encodeCursor({ t: last.cursor_t, id: last.id, s: q.sort }) : null },
})
})
Index đi kèm: CREATE INDEX orders_account_created_id_idx ON orders (account_id, created_at, id);. PostgreSQL quét được index này theo cả hai chiều nên dùng chung cho cả sort tăng lẫn giảm. Cách đọc EXPLAIN để xác nhận index được dùng có trong bài tối ưu SQL query với PostgreSQL.
Những điểm dễ sai:
- Cursor phải opaque. Client chỉ được chuyển lại nguyên văn. Mình encode base64url để không ai bị cám dỗ tự chế cursor, nhờ vậy sau này có thể đổi cấu trúc bên trong mà không làm vỡ client. Cursor có chứa cả
sort, nên dùng cursor của-createdAtchocreatedAtsẽ nhận 400 chứ không ra kết quả sai. - Sort phải có tie-breaker duy nhất. Hai đơn trùng
created_atmà chỉ sort theocreated_atthì sẽ bị mất hoặc lặp dòng ở ranh giới trang. Phải thêmidvào cảORDER BYlẫn điều kiện so sánh. - Độ chính xác thời gian.
timestamptzcủa PostgreSQL lưu tới microsecond, cònDatecủa JavaScript chỉ có millisecond. Lấycreated_atquaDaterồi đưa vào cursor sẽ làm mất 3 chữ số, và điều kiện<sẽ bỏ sót hoặc lặp dòng. Vì vậy cursor ở trên dùngcreated_at::text. - Đừng trả
totalCountmặc định.COUNT(*)trên bảng lớn tốn ngang một lần full scan. Nếu UI thật sự cần, làm endpoint riêng hoặc tham số opt-in. - Quy ước filter: tên field trực tiếp cho so sánh bằng (
?status=paid), hậu tố cho khoảng (?createdFrom=...&createdTo=..., ISO 8601 có timezone),sortlà danh sách phân tách bằng dấu phẩy với-cho giảm dần. Mỗi giá trị sort được phép phải có index tương ứng, nên allowlist là bắt buộc.
Idempotency-Key cho POST tạo đơn và thanh toán
Đây là cách sửa vụ trừ tiền hai lần ở đầu bài. Client sinh một UUID cho mỗi thao tác nghiệp vụ (không phải mỗi lần gửi request) và gửi trong header Idempotency-Key. Server làm theo bốn bước:
- Lần đầu gặp key: chạy nghiệp vụ, lưu lại response.
- Gặp lại key với cùng payload: trả lại đúng response đã lưu, không chạy lại nghiệp vụ.
- Gặp lại key khi request đầu vẫn đang chạy: trả 409.
- Gặp lại key với payload khác: trả 422.
Các mã lỗi này theo IETF draft draft-ietf-httpapi-idempotency-key-header, hiện vẫn là Internet-Draft chứ chưa thành RFC. Draft cũng định nghĩa giá trị header là một Structured Field String (có dấu nháy kép). Nhiều API như Stripe lại nhận giá trị không có nháy, nên server của mình chấp nhận cả hai.
CREATE TABLE idempotency_keys (
account_id uuid NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
status text NOT NULL DEFAULT 'in_progress', -- in_progress | completed
response_status int,
response_headers jsonb,
response_body text,
created_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, key) -- key thuộc về từng account, không dùng chung toàn hệ thống
);
// src/http/idempotency.ts
import { createHash } from 'node:crypto'
import type { Context } from 'hono'
import type { PoolClient } from 'pg'
import type { AppEnv } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from './problem.js'
type Result = { status: number; body: unknown; headers?: Record<string, string> }
type StoredRow = { response_status: number; response_headers: Record<string, string>; response_body: string }
const replay = (row: StoredRow) =>
new Response(row.response_body, {
status: row.response_status,
headers: { ...row.response_headers, 'Idempotent-Replayed': 'true' },
})
export async function withIdempotency(
c: Context<AppEnv>,
payload: unknown,
run: (tx: PoolClient) => Promise<Result>,
): Promise<Response> {
const raw = c.req.header('idempotency-key')?.trim()
if (!raw) {
throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-required`, title: 'Thiếu header Idempotency-Key', status: 400 })
}
const key = raw.replace(/^"(.*)"$/, '$1')
if (key.length < 16 || key.length > 255) {
throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-invalid`, title: 'Idempotency-Key không hợp lệ', status: 400 })
}
const accountId = c.get('accountId')
const hash = createHash('sha256').update(`${c.req.method} ${c.req.path}\n${JSON.stringify(payload)}`).digest('hex')
// 1. Giành key. PRIMARY KEY đảm bảo chỉ một request thắng.
const claimed = await pool.query(
`INSERT INTO idempotency_keys (account_id, key, request_hash) VALUES ($1, $2, $3)
ON CONFLICT (account_id, key) DO NOTHING`,
[accountId, key, hash],
)
if (claimed.rowCount === 0) {
const { rows: [row] } = await pool.query(
`SELECT *, locked_at < now() - interval '30 seconds' AS stale
FROM idempotency_keys WHERE account_id = $1 AND key = $2`,
[accountId, key],
)
if (row && row.request_hash !== hash) {
throw new ApiProblem({ type: `${PROBLEM_BASE}/idempotency-key-reused`, title: 'Idempotency-Key đã dùng cho request khác', status: 422 })
}
if (row?.status === 'completed') return replay(row)
// Request đầu đang chạy, hoặc vừa thất bại và xóa key. Có lock quá 30s thì coi như process cũ đã chết, chiếm lại.
const takeover = row?.stale
? await pool.query(
`UPDATE idempotency_keys SET locked_at = now()
WHERE account_id = $1 AND key = $2 AND status = 'in_progress' AND locked_at < now() - interval '30 seconds'`,
[accountId, key],
)
: { rowCount: 0 }
if (takeover.rowCount === 0) {
throw new ApiProblem(
{ type: `${PROBLEM_BASE}/request-in-progress`, title: 'Request với key này đang được xử lý', status: 409 },
{ 'Retry-After': '2' },
)
}
}
// 2. Nghiệp vụ và response được lưu trong CÙNG một transaction.
const tx = await pool.connect()
try {
await tx.query('BEGIN')
const { rows: [locked] } = await tx.query(
`SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2 FOR UPDATE`,
[accountId, key],
)
if (locked?.status === 'completed') {
await tx.query('ROLLBACK') // request cũ chạy chậm nhưng đã xong trong lúc mình chờ lock
return replay(locked)
}
const result = await run(tx)
const headers = { 'Content-Type': 'application/json', ...result.headers }
const body = JSON.stringify(result.body)
await tx.query(
`UPDATE idempotency_keys
SET status = 'completed', response_status = $3, response_headers = $4, response_body = $5
WHERE account_id = $1 AND key = $2`,
[accountId, key, result.status, headers, body],
)
await tx.query('COMMIT')
return new Response(body, { status: result.status, headers })
} catch (err) {
await tx.query('ROLLBACK')
// Nghiệp vụ đã rollback, nên xóa key để client retry với chính key đó.
await pool.query(`DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2 AND status = 'in_progress'`, [accountId, key])
throw err
} finally {
tx.release()
}
}
Route tạo đơn chỉ còn vài dòng. insertOrder là hàm repository của bạn, nhận tx để chạy chung transaction:
app.post('/v1/orders', validate('json', CreateOrderBody), (c) =>
withIdempotency(c, c.req.valid('json'), async (tx) => {
const order = await insertOrder(tx, c.get('accountId'), c.req.valid('json'))
return { status: 201, body: order, headers: { Location: `/v1/orders/${order.id}`, ETag: `"${order.version}"` } }
}),
)
Vì sao phải viết dài như vậy:
- Key phải gắn với account. Nếu PRIMARY KEY chỉ là
key, user A gửi đúng key của user B (đoán được hoặc lộ trong log) sẽ nhận về response của B, tức là lộ dữ liệu. Ghépaccount_idvào khóa là bắt buộc. - Lưu response chung transaction với nghiệp vụ. Nếu ghi đơn hàng rồi mới lưu response ở bước riêng, process chết giữa hai bước sẽ để lại đơn hàng đã tạo nhưng key vẫn
in_progress. Lần retry sau sẽ tạo thêm đơn thứ hai. SELECT ... FOR UPDATEchặn cú takeover nhầm. Nếu request đầu chỉ chạy chậm chứ chưa chết, request chiếm key sẽ bị block ở dòng này cho tới khi request đầu commit, sau đó thấycompletedvà replay thay vì chạy lại.- Xóa key cũ. Hãy ghi chính sách hết hạn vào tài liệu (ví dụ 24 giờ) và chạy job
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours'. Client retry sau thời hạn đó sẽ được xử lý như request mới.
ETag + If-Match: chặn lost update
Quay lại vụ hai kế toán cùng sửa hóa đơn. Mỗi hóa đơn có cột version. GET trả ETag: "7". PATCH bắt buộc gửi If-Match: "7", và server chỉ cập nhật khi version trong DB vẫn là 7:
// src/routes/invoices.ts
import * as z from 'zod'
import { app } from '../app.js'
import { pool } from '../db.js'
import { ApiProblem, PROBLEM_BASE } from '../http/problem.js'
import { validate } from '../http/validate.js'
const PatchInvoice = z.strictObject({
dueDate: z.iso.date().optional(),
note: z.string().max(500).nullable().optional(), // null = xóa note (ngữ nghĩa merge patch)
})
app.get('/v1/invoices/:id', async (c) => {
const { rows: [inv] } = await pool.query(
'SELECT * FROM invoices WHERE id = $1 AND account_id = $2',
[c.req.param('id'), c.get('accountId')],
)
if (!inv) throw new ApiProblem({ type: 'about:blank', title: 'Not Found', status: 404 })
c.header('ETag', `"${inv.version}"`)
return c.json(inv)
})
app.patch('/v1/invoices/:id', validate('json', PatchInvoice), async (c) => {
const ifMatch = c.req.header('if-match')
if (!ifMatch) {
throw new ApiProblem({ type: `${PROBLEM_BASE}/precondition-required`, title: 'Cần gửi If-Match với ETag mới nhất', status: 428 })
}
const m = /^"(\d+)"$/.exec(ifMatch.trim()) // If-Match so sánh strong: W/"7" không bao giờ khớp
const patch = c.req.valid('json')
const { rows: [updated] } = await pool.query(
`UPDATE invoices
SET due_date = CASE WHEN $4::boolean THEN $5::date ELSE due_date END,
note = CASE WHEN $6::boolean THEN $7 ELSE note END,
version = version + 1,
updated_at = now()
WHERE id = $1 AND account_id = $2 AND version = $3
RETURNING *`,
[c.req.param('id'), c.get('accountId'), m ? Number(m[1]) : -1,
'dueDate' in patch, patch.dueDate ?? null, 'note' in patch, patch.note ?? null],
)
if (!updated) {
const exists = await pool.query('SELECT 1 FROM invoices WHERE id = $1 AND account_id = $2', [c.req.param('id'), c.get('accountId')])
if (exists.rowCount === 0) throw new ApiProblem({ type: 'about:blank', title: 'Not Found', status: 404 })
throw new ApiProblem({ type: `${PROBLEM_BASE}/version-mismatch`, title: 'Hóa đơn đã bị người khác sửa, hãy tải lại', status: 412 })
}
c.header('ETag', `"${updated.version}"`)
return c.json(updated)
})
Điều kiện version = $3 nằm ngay trong câu UPDATE, nên phép kiểm tra và phép ghi là một thao tác atomic. Không cần lock, không có race giữa lúc đọc và lúc ghi. Người lưu sau nhận 412, UI hiện "dữ liệu đã thay đổi, tải lại?" thay vì âm thầm ghi đè.
Hai cái bẫy. Thứ nhất, browser chỉ đọc được header ETag khi CORS khai báo nó trong exposeHeaders, và chỉ gửi được If-Match khi header này nằm trong allowHeaders. Cấu hình cors() ở đầu bài đã có cả hai. Thứ hai, If-Match luôn so sánh kiểu strong, nên ETag dạng weak W/"7" sẽ không bao giờ khớp. ETag dùng cho concurrency phải là strong ETag.
Versioning và báo deprecation bằng Sunset
| Chiến lược | Ví dụ | Ưu | Nhược |
|---|---|---|---|
| URL path | /v1/orders |
Dễ thấy, dễ route ở gateway, dễ cache | Major version mới kéo theo cả bộ URL mới |
| Header | Api-Version: 2026-03-01 hoặc Accept: application/vnd.acme.v2+json |
URL ổn định, version được theo từng thay đổi nhỏ | Khó debug bằng trình duyệt, cache phải Vary theo header |
| Query param | /orders?version=2 |
Dễ thử | Dễ bị quên, lẫn lộn với filter |
Mặc định của mình là /v1 trong path. Phần lớn API nội bộ và partner không cần gì hơn. Version kiểu ngày trong header (như Stripe) đáng làm khi bạn có nhiều client bên ngoài nâng cấp với tốc độ rất khác nhau.
Quy tắc quan trọng hơn cách đặt version: thay đổi additive thì không cần version mới, ví dụ thêm field optional trong response, thêm endpoint, thêm query param optional. Thay đổi breaking thì cần: xóa hoặc đổi tên field, đổi kiểu dữ liệu, thêm input bắt buộc, đổi sort mặc định, đổi ngữ nghĩa status code. Thêm giá trị enum mới nằm giữa hai loại. Hãy ghi rõ trong tài liệu rằng client phải chấp nhận giá trị enum chưa biết, ngay từ v1.
Khi quyết định bỏ một endpoint, hãy báo bằng header chuẩn chứ đừng chỉ báo qua email: Deprecation (RFC 9745) cho biết endpoint bị deprecated từ khi nào, Sunset (RFC 8594) cho biết thời điểm nó ngừng hoạt động.
// src/http/deprecation.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../app.js'
import { ApiProblem, PROBLEM_BASE } from './problem.js'
export const deprecated = (opts: { since: Date; sunset: Date; docs: string }) =>
createMiddleware<AppEnv>(async (c, next) => {
if (Date.now() >= opts.sunset.getTime()) {
throw new ApiProblem({ type: `${PROBLEM_BASE}/endpoint-retired`, title: 'Endpoint đã ngừng hoạt động', status: 410, detail: `Xem hướng dẫn: ${opts.docs}` })
}
console.warn({ msg: 'deprecated endpoint called', path: c.req.path, accountId: c.get('accountId') })
await next()
c.header('Deprecation', `@${Math.floor(opts.since.getTime() / 1000)}`) // RFC 9745: Unix timestamp
c.header('Sunset', opts.sunset.toUTCString()) // RFC 8594: HTTP-date
c.header('Link', `<${opts.docs}>; rel="deprecation"; type="text/html"`)
})
// app.get('/v1/reports/sales', deprecated({ since: new Date('2026-09-01'), sunset: new Date('2027-03-01'), docs: 'https://docs.acme.vn/migrate/sales-report-v2' }), handler)
Dòng log deprecated endpoint called có giá trị hơn mọi email thông báo. Gần tới ngày sunset, bạn query log theo accountId là biết chính xác ai còn gọi, rồi liên hệ trực tiếp từng người.
OpenAPI-first và bảng quyết định
Toàn bộ quy ước ở trên chỉ sống sót khi được ghi vào contract mà máy đọc được. Mình viết openapi.yaml trước khi viết handler, review nó như review code, rồi generate type cho cả server lẫn client.
openapi: 3.1.0
info: { title: Acme Orders API, version: 1.0.0 }
paths:
/v1/orders:
post:
operationId: createOrder
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateOrder' }
responses:
'201':
description: Đã tạo đơn
headers:
Location: { schema: { type: string } }
ETag: { schema: { type: string } }
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'409': { $ref: '#/components/responses/Problem' }
'422': { $ref: '#/components/responses/Problem' }
components:
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: { type: string, minLength: 16, maxLength: 255 }
responses:
Problem:
description: Lỗi theo RFC 9457
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
schemas:
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri-reference }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
errors:
type: array
items:
type: object
properties:
pointer: { type: string }
parameter: { type: string }
detail: { type: string }
# CreateOrder, Order: khai báo tương tự schema zod ở trên
npx @redocly/cli lint openapi.yaml # bắt lỗi contract trong CI
npx openapi-typescript openapi.yaml -o src/generated/api.d.ts # type cho client (dùng với openapi-fetch)
Bảng quyết định mình dùng khi review thiết kế:
| Quyết định | Mặc định của mình | Đổi sang lựa chọn khác khi |
|---|---|---|
| Pagination | Cursor, limit ≤ 100 |
Bảng admin nhỏ, cần nhảy trang thì dùng offset |
| Lỗi validation | 422 + errors[].pointer |
Team đã quen dùng 400 cho mọi lỗi thì giữ 400, miễn nhất quán |
| Idempotency | Bắt buộc với POST có tiền hoặc đơn hàng | POST ít rủi ro (gửi feedback) thì để optional |
| Concurrency | ETag + If-Match, 428 khi thiếu |
Resource chỉ một người sửa thì có thể bỏ |
| Versioning | /v1 trong path |
Nhiều client ngoài nâng cấp lệch nhau thì dùng version theo ngày trong header |
| Contract | OpenAPI viết tay trước, generate type | Monorepo full-stack TypeScript thì cân nhắc tRPC |
totalCount |
Không trả | UI bắt buộc phải có thì dùng endpoint riêng hoặc ước lượng |
Những lỗi thường gặp
Trả 200 cho lỗi. Monitoring đếm 5xx sẽ thấy mọi thứ xanh trong lúc client lỗi hàng loạt, còn retry policy của SDK cũng không phân biệt được thành công với thất bại.
// Sai
return c.json({ success: false, message: 'Hết hàng' }, 200)
// Đúng
throw new ApiProblem({ type: `${PROBLEM_BASE}/out-of-stock`, title: 'Sản phẩm đã hết hàng', status: 409, sku: 'TSHIRT-M' })
Nhận tên cột sort tùy ý từ client. Đây là SQL injection nếu bạn nối chuỗi, và là full table scan nếu cột đó không có index.
// Sai
const sql = `SELECT * FROM orders ORDER BY ${c.req.query('sort')}`
// Đúng: allowlist bằng z.enum, map sang mệnh đề ORDER BY viết sẵn
const SORTS = { '-createdAt': 'created_at DESC, id DESC', createdAt: 'created_at ASC, id ASC' } as const
Idempotency-Key unique toàn cục. Như đã phân tích ở trên, cách này làm lộ response của account khác. Khóa phải là (account_id, key).
limit không có trần. ?limit=1000000 là cách DoS rẻ nhất, không cần botnet. Đặt max(100) ngay trong schema.
Trả 403 khi token hết hạn. Interceptor của frontend chỉ refresh khi nhận 401, nên người dùng bị kẹt ở màn hình "không có quyền". 401 kèm WWW-Authenticate mới đúng.
Tạo version mới cho mọi thay đổi. /v2 chỉ vì thêm một field optional sẽ khiến bạn phải duy trì hai codebase. Thay đổi additive không cần version.
Checklist
- Rà lại bản đồ endpoint: danh từ số nhiều, nesting tối đa một cấp, không có động từ trong path, không GET nào có side effect.
- Mọi response lỗi, kể cả 404 route và 500, đều là
application/problem+jsonvớitypeổn định. - Body được validate bằng
z.strictObject, lỗi trảerrors[].pointer. Request không có Content-Type JSON nhận 415. - List endpoint dùng cursor opaque, sort có tie-breaker
id,limitcó trần, có index khớp với từng sort. - POST tạo đơn hoặc thanh toán bắt buộc
Idempotency-Key, khóa(account_id, key), response lưu chung transaction với nghiệp vụ. - Resource nhiều người cùng sửa có
ETagstrong, PATCH trả 428 khi thiếuIf-Matchvà 412 khi lệch version. - CORS
exposeHeaderscóETag,Location,Retry-After.allowHeaderscóIdempotency-Key,If-Match. - Endpoint sắp bỏ gửi
Deprecation+Sunset+Link, log lại ai còn gọi, sau ngày sunset trả 410. -
openapi.yamlđược lint trong CI và là nguồn để generate type cho client.
Đọc thêm
- RFC 9457: Problem Details for HTTP APIs
- RFC 9110: HTTP Semantics (định nghĩa status code và conditional request)
- RFC 8594: The Sunset HTTP Header Field và RFC 9745: The Deprecation HTTP Response Header Field
- IETF draft: The Idempotency-Key HTTP Header Field
- OpenAPI Specification
- Bài liên quan: REST API với Node.js và MongoDB có thể scale, HTTP caching từ A tới Z, GraphQL vs REST