# Đừng để job đồng bộ của bạn khiến khách khóa API key

> Một job đồng bộ chạy vô tư có thể khiến khách khóa API key của bạn ngay tuần đầu. Ngược lại, nếu API của bạn không có giới hạn thì chỉ một client lỗi cũng đủ làm nó sập; cả hai chiều đều xử lý được bằng vài chục dòng code.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-rate-limit-va-api-gateway/

Thử hình dung tuần đầu bạn làm việc ở site khách hàng. Job đồng bộ 3.000 bản ghi bạn viết hôm qua chạy rất nhanh, và cũng chính vì nhanh mà hệ thống ERP của khách trả lỗi hàng loạt suốt đêm. Sáng nay team IT của khách gửi email báo họ đã tạm khóa API key của bạn cho đến khi job được sửa.

Với FDE, đây là tình huống rất dễ xảy ra. Bạn thường đứng giữa hai hệ thống: gọi API do khách sở hữu, đồng thời mở API của mình cho hệ thống của khách gọi vào. Ở chiều thứ nhất, bạn phải là một client biết điều. Ở chiều thứ hai, bạn phải tự đặt giới hạn để một client lỗi không đánh sập dịch vụ.

Bài này đi qua cả hai chiều. Bạn sẽ viết một client biết đọc tín hiệu 429 và tự giảm tốc, rồi viết một bộ giới hạn đơn giản đặt trước API của mình. Code là Python ở dạng phác thảo, đã lược bớt cho dễ đọc và chỉ dùng thư viện chuẩn. Bạn cần Python 3, một terminal, và một API thật hoặc giả để thử.

## Rate limit thực chất bảo vệ cái gì?

MDN định nghĩa rate limiting là kiểm soát số thao tác được thực hiện trong một khoảng thời gian, chủ yếu để hệ thống không bị quá tải rồi chậm đi. Server có thể giới hạn số request nhận từ một client trong một khoảng thời gian, vừa để giữ hiệu năng vừa để giảm thiểu tấn công như DoS.

Giới hạn thường được tính theo IP của client. Nếu request có xác thực hoặc cookie thì giới hạn có thể tính theo user hay theo ứng dụng. Ở site khách, điều đó có nghĩa là mọi job dùng chung một API key cũng dùng chung một hạn mức, và job đồng bộ của bạn có thể đang lấy mất phần của một ứng dụng nội bộ khác.

Bắt đầu bằng một phép tính đơn giản. Giả sử API của khách cho phép 10 request mỗi giây và bạn cần đồng bộ 3.000 bản ghi, mỗi bản ghi một request. Thời gian tối thiểu là 300 giây, tức 5 phút. Mọi cách để chạy nhanh hơn 5 phút đều chỉ đổi lấy lỗi.

## Bước 1: đọc đúng tín hiệu 429 và Retry-After

Mã HTTP 429 Too Many Requests cho biết client đã gửi quá nhiều request trong một khoảng thời gian. Giới hạn có thể áp cho toàn server hoặc cho từng tài nguyên. Server có thể gửi kèm header Retry-After để cho biết client cần đợi bao lâu trước khi thử lại.

Chỗ dễ sai là Retry-After có thể mang một trong hai dạng giá trị. Dạng thứ nhất là một ngày HTTP, dạng thứ hai là số nguyên không âm chỉ số giây cần đợi. Code chỉ xử lý được một dạng sẽ hỏng ngay khi gặp server dùng dạng còn lại.

```python
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone

def retry_after_seconds(value):
if value is None:
return None
value = value.strip()
if value.isdigit():               # dạng số giây
return int(value)
try:                              # dạng ngày HTTP
when = parsedate_to_datetime(value)
delta = (when - datetime.now(timezone.utc)).total_seconds()
return max(0, delta)
except (TypeError, ValueError):
return None
```

**Kiểm tra:** `retry_after_seconds("120")` phải trả về 120. Với một ngày HTTP cách hiện tại vài phút, kết quả phải là số giây dương. Với một chuỗi rác, hàm phải trả về `None` chứ không được ném lỗi.

## Bước 2: retry có jitter khi server không nói phải đợi bao lâu

Không phải server nào cũng gửi Retry-After. Khi thiếu header này, cách thường dùng là exponential backoff: mỗi lần thử lại thì đợi lâu gấp đôi lần trước. Bài viết trên AWS Architecture Blog cho thấy backoff thuần không có jitter cho kết quả tệ nhất trong mô phỏng của họ, vì nhiều client lỗi cùng lúc sẽ thử lại cùng lúc.

```python
import random, time

def backoff_delay(attempt, base=1, cap=60):
return random.uniform(0, min(cap, base * 2 ** attempt))

def call_with_retry(send, max_attempts=5):
for attempt in range(max_attempts):
status, headers = send()      # send() là hàm giả định của bạn
if status != 429:
return status
wait = retry_after_seconds(headers.get("Retry-After"))
time.sleep(wait if wait is not None else backoff_delay(attempt))
raise RuntimeError("Hết lượt retry")
```

**Kiểm tra:** cho `send()` giả trả 429 ba lần rồi trả 200, sau đó in ra các khoảng đợi. Chúng phải ngẫu nhiên và có xu hướng tăng dần, không được là 1, 2, 4 giống hệt nhau ở mọi lần chạy.

## Bước 3: đừng đợi đến lúc bị 429 mới giảm tốc

Retry là lưới an toàn, không phải chiến lược. Một client tốt tự throttle ngay từ đầu. MDN phân biệt hai khái niệm: throttling vẫn cho thao tác chạy nhưng chỉ ở một tốc độ tối đa cố định, còn debounce gộp nhiều lời gọi sát nhau thành một và đợi đến khi các lời gọi dừng hẳn.

Job đồng bộ cần throttling. Debounce hợp với những thứ như ô tìm kiếm phía client hơn.

Tyk so sánh vài thuật toán quen thuộc. Fixed window đơn giản nhưng dễ bị burst, sliding log chính xác nhưng tốn bộ nhớ, còn token bucket xử lý burst linh hoạt. Token bucket hoạt động như một cái xô: token được thêm vào với tốc độ cố định, mỗi request lấy đi một token, xô đầy thì token mới bị bỏ đi.

```python
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens = capacity
self.last = time.monotonic()

def take(self):
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False

bucket = TokenBucket(rate=8, capacity=8)   # để dư 20% so với trần 10/giây
def throttled_send(send):
while not bucket.take():
time.sleep(0.05)
return call_with_retry(send)
```

Đặt 8 thay vì 10 là để chừa chỗ cho các ứng dụng khác dùng chung key. Đây là một lựa chọn thận trọng, không phải con số chuẩn. **Kiểm tra:** gửi 80 request qua `throttled_send` thì tổng thời gian phải vào khoảng 9 giây, không phải gần 0 giây.

## Bước 4: giờ bạn là chủ API, giới hạn đặt ở đâu?

Thử một ví dụ giả định với fixed window: giới hạn 100 request mỗi phút, client gửi 100 request ở giây cuối của phút trước và 100 request ở giây đầu của phút sau. Cả hai cửa sổ đều hợp lệ, nhưng backend phải nhận 200 request trong khoảng 2 giây. Token bucket với capacity nhỏ sẽ không để đợt burst đó lọt qua.

Bucket ấy nên nằm ở API gateway, lớp phần mềm mà IBM mô tả là điểm vào duy nhất cho client đi tới nhiều dịch vụ backend. Rate limit là việc của gateway chứ không phải của load balancer, vì gateway là nơi ngăn từng client hay từng IP làm quá tải dịch vụ.

Đặt giới hạn ở đây, như Tyk khuyến nghị, giúp chính sách được áp thống nhất theo API key, IP hoặc user.

Ở gateway, hãy tách hai chức năng mà bạn đã dùng ở Bước 3. Rate limiting đặt trần số request và chặn phần vượt, còn throttling làm chậm, trì hoãn hoặc xếp hàng request khi lưu lượng tăng đột biến. Cùng một token bucket, phía client dùng `sleep` để xếp hàng, còn phía server từ chối ngay.

## Bước 5: trả 429 sao cho client tự điều chỉnh được

Tyk ghi rằng trả mã 429 là thông lệ chuẩn, kèm các header như Retry-After, X-RateLimit-Limit và X-RateLimit-Remaining. Phản hồi của bạn chính là tín hiệu mà client ở Bước 1 cần đọc. Đoạn dưới mô phỏng logic này, mỗi API key có một bucket riêng.

```python
buckets = {}
LIMIT = 10

def check(api_key):
b = buckets.setdefault(api_key, TokenBucket(rate=LIMIT, capacity=LIMIT))
allowed = b.take()
headers = {"X-RateLimit-Limit": str(LIMIT),
"X-RateLimit-Remaining": str(int(b.tokens))}
if not allowed:
headers["Retry-After"] = "1"
return 429, headers
return 200, headers
```

Đây chỉ là bản minh họa chạy trong một process. Khi triển khai thật, bạn cấu hình chính sách này trên gateway mà khách đang dùng, theo tài liệu của sản phẩm đó, chứ không tự viết. **Kiểm tra:** gọi `check("key-a")` 15 lần liên tiếp thì phải thấy khoảng 5 lần trả 429, trong khi `check("key-b")` vẫn trả 200.

## Những lỗi khiến job tích hợp bị khóa key

Một lỗi hay gặp là retry ngay lập tức khi nhận 429. Mã này vốn báo rằng client đã gửi quá nhiều request, nên thử lại tức thì chỉ dồn thêm tải vào đúng hệ thống đang quá tải. Lỗi thứ hai là chỉ parse Retry-After dạng số, đến khi gặp dạng ngày thì job crash lúc 2 giờ sáng.

Lỗi thứ ba khó thấy hơn: chạy song song nhiều worker, mỗi worker có bucket riêng đặt 8 request mỗi giây. Năm worker cộng lại thành 40 request mỗi giây, gấp bốn lần trần. Mọi worker dùng chung một key thì phải dùng chung một bucket.

**Điểm mấu chốt:** Retry chỉ để chữa cháy. Cách bảo vệ hệ thống của khách là tự throttle dưới mức trần ngay từ request đầu tiên.

## Ở site khách, bắt đầu từ ba câu hỏi

Trong buổi làm việc đầu tiên với team IT của khách, hãy hỏi ba câu trước khi viết dòng code nào: trần là bao nhiêu, tính theo key, IP hay user, và key này còn được ứng dụng nào khác dùng chung. Câu trả lời sẽ quyết định tham số `rate` của bạn.

Từ đó, bạn tính được thời gian tối thiểu cho mỗi job như phép tính 5 phút ở trên và báo trước cho khách.

Nếu JD bạn đang nhắm tới có nhắc đến việc tích hợp với hệ thống của khách, hãy mang chính ví dụ này vào buổi phỏng vấn: phép tính hạn mức, bucket dùng chung cho mọi worker, và cách bạn đọc Retry-After.

Khách hàng hiếm khi nhớ job của bạn chạy nhanh đến đâu. Điều họ nhớ là suốt dự án, hệ thống của họ chưa lần nào báo lỗi vì bạn.

**Thử ngay tuần này:**

- Tìm một đoạn code gọi API bên ngoài trong dự án của bạn và kiểm tra xem nó có đọc Retry-After không. Nếu chưa, thêm hàm retry_after_seconds trong bài và viết unit test cho cả hai dạng giá trị.
- Chạy thử TokenBucket với rate=5, capacity=10, bắn 30 request liên tiếp rồi đếm số request bị từ chối để tự thấy burst được xử lý ra sao.
- Viết một dòng cho CV mô tả việc bạn giữ một job tích hợp không vượt hạn mức API, kèm con số cụ thể: tốc độ, số bản ghi, số lần nhận 429 trước và sau khi sửa.

## Nguồn

- [Rate limit - Glossary | MDN](https://developer.mozilla.org/en-US/docs/Glossary/Rate_limit)

- [Throttle - Glossary | MDN](https://developer.mozilla.org/en-US/docs/Glossary/Throttle)

- [Debounce - Glossary | MDN](https://developer.mozilla.org/en-US/docs/Glossary/Debounce)

- [429 Too Many Requests - HTTP | MDN](https://developer.mozilla.org/docs/Web/HTTP/Reference/Status/429)

- [Retry-After header - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After)

- [API rate limiting explained: From basics to best practices (Tyk)](https://tyk.io/learning-center/api-rate-limiting/)

- [What is an API gateway? (IBM)](https://www.ibm.com/blog/api-gateway/)

- [API gateway vs. Load balancer: Do you need one or both?](https://roadmap.sh/network-engineer/api-gateway-vs-load-balancer)

- [Exponential Backoff And Jitter (AWS Architecture Blog)](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
