---
title: "Thiết kế API cho người khác dùng: báo lỗi, chia trang, thử lại và giới hạn lượt gọi"
url: https://phatdev.net/vi/blog/rest-api-design-practical
lang: vi
updated: 2026-10-21
alternate:
  en: https://phatdev.net/en/blog/rest-api-design-practical
markdown: https://phatdev.net/vi/blog/rest-api-design-practical.md
---

# Thiết kế API cho người khác dùng: báo lỗi, chia trang, thử lại và giới hạn lượt gọi

> Những quy ước giúp API ít làm gãy ứng dụng của người khác: đặt tên đường dẫn, báo lỗi một khuôn, chia trang bằng con trỏ, đánh số phiên bản, chống tạo đơn hai lần và giới hạn lượt gọi.

Chuyên mục: Backend | Đăng: 2026-10-19 | Cấp độ: Trung cấp | 8 phút đọc

Tags: rest, api-design, problem-json, rfc-9457, pagination, idempotency, rate-limit, versioning

Một đối tác nối app của họ vào API của bạn. Ba tháng sau bạn đổi tên một trường trong dữ liệu trả về cho "gọn hơn", và app của họ ngừng chạy mà bạn không hề hay biết. Sửa một hàm nội bộ thì bạn sửa luôn chỗ gọi nó. Sửa API công khai thì có người ở ngoài tầm nhìn của bạn bị gãy.

Vì vậy mình dành thời gian cho phần thiết kế trước khi viết code. Mục tiêu là một API đoán được: người dùng đọc một lần là biết lỗi trông thế nào, trang tiếp ở đâu, thử lại có an toàn không, còn bao nhiêu lượt gọi. Bài này nói về những quy ước mình hay dùng: đặt tên đường dẫn, cách báo lỗi, chia trang, đánh số phiên bản, và hai thứ hay bị quên là chống tạo đơn hai lần và giới hạn lượt gọi.

![Một trung tâm tài nguyên nối với các thẻ tài nguyên xung quanh](https://phatdev.net/uploads/blog/rest-api-design-practical/resources-1920.webp)

## Đặt tên đường dẫn

Đường dẫn là danh từ, không phải động từ. Hành động đã nằm trong phương thức HTTP rồi.

- Danh từ số nhiều: `/v1/orders`, `/v1/orders/42`. Không viết `/getOrder` hay `/createOrder`.
- Lồng tối đa một cấp: `/v1/orders/42/items`. Sâu hơn thì khó đọc, hãy cho thứ con một đường dẫn riêng.
- Hành động không giống thêm xoá sửa thì coi nó là một thứ có thể tạo ra: `POST /v1/orders/42/cancellation` thay cho `POST /v1/cancelOrder`.

Phương thức nên khớp với việc: `GET` để đọc (trả `200`), `POST` để tạo (trả `201`), `PUT` để thay toàn bộ, `PATCH` để sửa một phần, `DELETE` để xoá (trả `204`). Có một khái niệm cần nhớ: "idempotent" nghĩa là gọi một lần hay gọi mười lần cho cùng kết quả cuối. `GET`, `PUT`, `DELETE` có tính này, còn `POST` thì không. Nó quyết định việc client có được tự thử lại khi mất mạng hay không, và dẫn tới phần chống tạo đơn hai lần ở dưới.

## Báo lỗi theo một khuôn chung

Thay vì mỗi chỗ một kiểu, hãy dùng khuôn chuẩn RFC 9457 (loại nội dung `application/problem+json`, thay cho RFC 7807 từ 2023). Khuôn có bốn trường chính: `type` (địa chỉ định danh loại lỗi), `title` (tóm tắt ngắn, không đổi), `status` (mã HTTP), `detail` (mô tả riêng lần này). Bạn thêm trường riêng nếu cần, ví dụ danh sách lỗi theo từng ô nhập.

Lỗi nên đi kèm mã HTTP đúng, vì máy dò, proxy và bộ giám sát chỉ nhìn mã đó. Bảng mã mình dùng: `400` yêu cầu không đọc được (JSON hỏng), `401` chưa đăng nhập, `403` đã đăng nhập nhưng không có quyền, `404` không tìm thấy, `409` xung đột với trạng thái hiện tại, `422` đọc được nhưng nội dung sai quy tắc, `429` gọi quá nhiều, `5xx` lỗi phía server. Với 5xx đừng bao giờ đưa chi tiết nội bộ (câu SQL, đường dẫn máy chủ) vào `detail`. Về xác thực người gọi, xem [Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#](https://phatdev.net/vi/blog/jwt-across-languages).

## Chia trang bằng con trỏ

Danh sách cần chia trang, có hai kiểu. Kiểu số trang (`?page=3`) dễ làm, nhưng chậm dần ở trang sâu, và nếu dữ liệu đổi giữa hai lần gọi thì bạn bị lặp hoặc sót dòng. Kiểu con trỏ (cursor) trả kèm một mã đánh dấu vị trí, client gửi lại mã đó để lấy trang tiếp. Lý do ở phía database nằm trong bài [Truy vấn chậm? Cách đọc EXPLAIN và đặt index đúng chỗ](https://phatdev.net/vi/blog/mysql-index-explain).

Cursor nên "mờ" với client: client chỉ chuyển nguyên mã, không tự dựng, để sau này bạn đổi cách mã hoá mà không làm gãy ai. Đặt giới hạn trên cho số dòng mỗi trang. Để biết trang cuối, lấy `limit + 1` dòng và xem có dư không.

## Phiên bản và khai tử

Mình đặt phiên bản trên đường dẫn (`/v1/...`) vì dễ thấy, dễ cache, dễ debug bằng trình duyệt. Quan trọng hơn chỗ đặt số là kỷ luật thay đổi. Thêm trường mới hay thêm đường dẫn mới thì không cần bản mới, và nên dặn client bỏ qua trường lạ. Đổi tên, xoá trường, đổi kiểu dữ liệu là thay đổi phá vỡ và cần phiên bản mới.

Khi bỏ một phiên bản cũ, báo trước bằng header: `Deprecation` cho biết khi nào bị coi là lỗi thời (RFC 9745), `Sunset` cho biết khi nào ngừng hẳn (RFC 8594), và `Link` với `rel="successor-version"` chỉ tới bản mới.

## Chống tạo đơn hai lần

Tình huống thật: app gọi `POST /orders`, server tạo đơn và trừ tiền xong, nhưng phản hồi bị mất vì đứt mạng. App không biết đơn đã tạo, nên thử lại. Khách bị trừ tiền hai lần.

Giải pháp quen thuộc là khoá chống lặp (Idempotency-Key). App sinh một mã ngẫu nhiên cho mỗi ý định tạo đơn và gửi kèm trong header. Lần đầu, server xử lý bình thường và lưu kết quả cùng mã đó. Nếu mã đó đến lần nữa với nội dung y hệt, server không làm lại mà phát lại đúng kết quả cũ. Nếu mã cũ đến với nội dung khác, đó là lỗi. Lưu ý đây vẫn là bản nháp của IETF (đã hết hạn), chưa phải chuẩn chính thức, nhưng nhiều dịch vụ thanh toán đã áp dụng.

![Một yêu cầu đi qua dãy cổng: kiểm tra, khoá idempotency, hạn mức, rồi tới phản hồi](https://phatdev.net/uploads/blog/rest-api-design-practical/request-gates-1920.webp)

## Giới hạn lượt gọi

Giới hạn lượt gọi (rate limit) bảo vệ server khỏi client bị lỗi vòng lặp và khỏi lạm dụng. Phần quan trọng không kém là cho client biết hạn mức để họ tự điều tiết. Bản nháp IETF `draft-ietf-httpapi-ratelimit-headers` (bản 11) định nghĩa hai header `RateLimit-Policy` và `RateLimit`. Nhiều API vẫn dùng tên cũ như `X-RateLimit-Remaining`. Chọn một kiểu, ghi vào tài liệu, giữ nhất quán. Khi vượt hạn mức, trả `429` kèm `Retry-After`.

## Cho bạn nào viết code

Mình dựng một server Node.js 20 nhỏ không dùng thư viện, rồi thử từng điểm bằng `curl`.

Hàm báo lỗi:

```ts
function problem(res, status, title, detail, extra = {}, headers = {}) {
  const body = { type: `https://api.example.com/problems/${extra.code}`, title, status, detail, ...extra };
  delete body.code;
  res.writeHead(status, { "content-type": "application/problem+json", ...headers });
  res.end(JSON.stringify(body));
}
```

Gửi dữ liệu sai thì nhận về:

```json
{"type":"https://api.example.com/problems/validation","title":"Dữ liệu không hợp lệ","status":422,
 "detail":"Một hoặc nhiều trường sai.",
 "errors":[{"field":"item","message":"tối thiểu 3 ký tự"},{"field":"qty","message":"số nguyên từ 1 đến 100"}]}
```

`GET /v1/orders/9999` ra `404` với `content-type: application/problem+json`. Chia trang bằng con trỏ: mình duyệt hết 95 đơn với `limit=40` ra 3 trang (40, 40, 15), trang cuối có `next_cursor: null`, và trong header `Link` có địa chỉ trang kế (`rel="next"`). Route `/v0/orders` trả đủ `Deprecation: @1767225600`, `Sunset` và `Link` tới `/v1/orders`.

Phần chống lặp:

```ts
const key = String(req.headers["idempotency-key"]).replace(/^"|"$/g, "");
const hash = sha(JSON.stringify(body));
const prev = idem.get(key);
if (prev) {
  if (prev.hash !== hash) return problem(res, 422, "Idempotency-Key bị dùng lại", "Khoá này đã dùng với nội dung khác.", { code: "idempotency-key-reused" });
  if (prev.state === "processing") return problem(res, 409, "Yêu cầu đang xử lý", "Thử lại sau.", { code: "idempotency-in-flight" }, { "retry-after": "1" });
  return json(res, prev.status, prev.body, { "idempotent-replayed": "true" });   // phát lại đúng kết quả cũ
}
idem.set(key, { hash, state: "processing" });
// ... xử lý thật, rồi lưu { hash, state: "done", status: 201, body }
```

Kết quả đo: lần 1 với khoá `t-1` ra `201`, đơn số 96. Lần 2 cùng khoá, cùng nội dung: vẫn `201`, vẫn đơn 96, kèm `idempotent-replayed: true`, không có đơn mới. Lần 3 cùng khoá nhưng `qty` khác: `422`. Mình cũng thử ba request song song cùng khoá mới trong lần đo đầu, ra `409`, `409`, `201`, và chỉ một đơn được tạo. Khi đem vào thực tế, bạn cần lưu khoá ở kho bền vững hoặc Redis với hạn dùng (thường 24 giờ), tách khoá theo từng người dùng, và chỉ ghi kết quả khi đã xong.

Giới hạn lượt gọi: đặt 5 lượt mỗi 10 giây, gửi 7 lần liên tiếp. Năm lần đầu `200` với `RateLimit: "default";r=4;t=10` giảm dần tới `r=0`. Lần sáu và bảy ra `429` kèm `Retry-After: 10` và thân lỗi `problem+json`.

Bộ đếm của mình là cửa sổ cố định theo IP, nên có điểm yếu: client gửi đủ hạn mức ở cuối cửa sổ này rồi đủ hạn mức nữa ở đầu cửa sổ sau. Cho API thật, hãy dùng thùng token (token bucket) hoặc cửa sổ trượt, đếm trong Redis để nhiều tiến trình dùng chung, và tính theo khoá API hay người dùng thay vì chỉ theo IP, vì cả văn phòng có thể chung một IP. Cách lưu bộ đếm có hạn dùng giống bài [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api).

## Lỗi hay gặp

Hay gặp nhất là trả `200` kèm `{"success": false}` cho lỗi. Bộ giám sát nhìn mã HTTP và thấy mọi thứ đều ổn trong lúc khách không thể đặt hàng.

Kế đến là phân trang bằng số trang trên bảng lớn, hoặc trả toàn bộ danh sách không giới hạn. Một ngày bảng lớn lên và endpoint đó tự sập.

Và đừng đổi tên trường mà không tăng phiên bản. Bạn nghĩ đó là sửa nhỏ, nhưng với người đang dùng thì đó là phá vỡ.

## Nên làm gì tiếp theo

Lấy một endpoint đang có và kiểm ba điều: lỗi có trả đúng mã HTTP và cùng một khuôn không, danh sách có chia trang bằng con trỏ và có giới hạn trên không, và `POST` tạo thứ quan trọng đã nhận khoá chống lặp chưa. Làm ba việc này từ đầu thì rẻ, sửa sau khi đã có người dùng thì đắt.

## Nguồn tham khảo

- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
- [RFC 6585: Additional HTTP Status Codes (429)](https://www.rfc-editor.org/rfc/rfc6585)
- [RFC 9745: The Deprecation HTTP Response Header Field](https://www.rfc-editor.org/rfc/rfc9745)
- [RFC 8594: The Sunset HTTP Header Field](https://www.rfc-editor.org/rfc/rfc8594)
- [RFC 8288: Web Linking](https://www.rfc-editor.org/rfc/rfc8288)
- [IETF draft: RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [IETF draft: The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)

## Bài liên quan

- [Cho web nhanh hơn bằng Redis: nhớ tạm kết quả để database đỡ quá tải](https://phatdev.net/vi/blog/redis-cache-aside-api.md)
- [Truy vấn chậm? Cách đọc EXPLAIN và đặt index đúng chỗ](https://phatdev.net/vi/blog/mysql-index-explain.md)
- [Thẻ đăng nhập JWT dùng chung giữa Node.js, PHP, Python và C#](https://phatdev.net/vi/blog/jwt-across-languages.md)
