Chương 26. Retry, Timeout Và Idempotency

Ở chương trước, ta nói về concurrency:

> Có bao nhiêu job đang được xử lý cùng lúc?

Nhưng khi nhiều job chạy cùng lúc, một sự thật sẽ lộ ra rất nhanh:

> Việc gì cũng có thể lỗi.

API bên ngoài có thể chậm.

Database có thể timeout.

Network có thể rớt.

Worker có thể chết.

Webhook có thể gửi trùng.

User có thể bấm hai lần.

Queue có thể deliver lại message.

Vì vậy, hệ thống job nền không thể chỉ có:

Lấy job -> xử lý -> xong

Nó cần ba thứ bắt buộc:

  • Timeout: không chờ mãi.
  • Retry: thử lại khi lỗi tạm thời.
  • Idempotency: thử lại nhiều lần vẫn không làm sai dữ liệu.

Và khi retry vẫn không xong, cần thêm:

  • Dead Letter Queue: nơi giữ job thất bại để kiểm tra.

Chương này rất quan trọng.

Vì concurrency cao mà không có timeout/retry/idempotency thì chỉ làm lỗi xảy ra nhanh hơn và rộng hơn.

---

26.1. Ví dụ quán bánh: gọi khách không được thì làm gì?

Quán bánh cần gọi khách để xác nhận địa chỉ giao hàng.

Nhân viên gọi lần đầu.

Khách không nghe máy.

Có nên bỏ đơn luôn không?

Thường là không.

Quán có thể thử lại sau 5 phút.

Nếu vẫn không nghe, thử lại lần nữa.

Nhưng cũng không thể gọi mãi mãi.

Sau 3 lần không được, đơn chuyển sang trạng thái cần xử lý thủ công.

Đây chính là:

  • Timeout: mỗi cuộc gọi không chờ mãi.
  • Retry: gọi lại vài lần.
  • Giới hạn retry: không gọi vô hạn.
  • Failed/DLQ: đưa vào nhóm cần xử lý riêng.

Nhưng có một điểm nữa:

Nếu nhân viên lỡ bấm gọi lại hai lần, không được tạo hai đơn giao hàng khác nhau.

Đó là idempotency.

---

26.2. Timeout là gì?

Timeout là thời gian tối đa ta sẵn sàng chờ một việc.

Ví dụ:

Gọi Gemini API: timeout 120 giây.
Gửi email: timeout 10 giây.
Gọi CRM: timeout 5 giây.
Query database: timeout 2 giây hoặc tùy loại query.

Timeout trả lời câu hỏi:

> Tôi chờ anh tối đa bao lâu trước khi coi như lần thử này không thành công?

Không có timeout là rất nguy hiểm.

Một job có thể treo mãi.

Một worker slot có thể bị giữ vĩnh viễn.

Queue có thể chậm dần.

Người vận hành không biết job còn sống hay đã chết.

Timeout là dây an toàn.

---

26.3. Timeout không có nghĩa là việc chắc chắn thất bại

Đây là điểm rất quan trọng.

Khi ta gọi một API và timeout, ta chỉ biết:

Ta không nhận được kết quả trong thời gian chờ.

Ta không biết chắc:

  • API chưa nhận request.
  • API nhận rồi nhưng xử lý chậm.
  • API xử lý thành công nhưng response bị mất.
  • API xử lý thất bại.

Ví dụ payment:

Backend gọi payment provider để hoàn tiền.
Request timeout.

Không được kết luận ngay:

Hoàn tiền thất bại.

Cũng không được retry bừa nếu retry có thể hoàn tiền hai lần.

Phải có idempotency key hoặc query lại trạng thái provider.

Timeout nghĩa là:

> Kết quả chưa rõ.

Không phải:

> Chắc chắn chưa làm.

---

26.4. Retry là gì?

Retry nghĩa là thử lại sau khi lỗi.

Ví dụ:

Gửi email lỗi timeout.
Thử lại sau 30 giây.
Nếu vẫn lỗi, thử lại sau 2 phút.
Nếu vẫn lỗi, thử lại sau 10 phút.

Retry rất hữu ích với lỗi tạm thời:

  • Network chập chờn.
  • Provider trả 503.
  • Database deadlock tạm thời.
  • Rate limit có Retry-After.
  • Worker bị restart giữa chừng.

Retry giúp hệ thống tự phục hồi.

Nếu không retry, rất nhiều lỗi nhỏ sẽ biến thành lỗi người dùng thấy.

Nhưng retry cũng nguy hiểm nếu dùng sai.

---

26.5. Retry sai nguy hiểm thế nào?

Giả sử job hoàn tiền:

RefundPayment(refund_123)

Gọi provider timeout.

Worker retry.

Nếu provider đã hoàn tiền ở lần đầu nhưng response bị mất, lần retry có thể hoàn tiền lần hai nếu không có idempotency.

Đó là lỗi nghiêm trọng.

Ví dụ email:

Gửi email thành công nhưng worker chết trước khi ghi sent.

Job retry.

User nhận hai email.

Có thể chấp nhận với email marketing.

Nhưng với OTP hoặc reset password, có thể gây rối.

Ví dụ AI Judge:

Job chấm xong và lưu điểm.

Worker chết trước khi ack queue.

Queue deliver lại.

Worker chấm lại và ghi kết quả khác.

Rất tệ.

Retry mà không có idempotency là con dao hai lưỡi.

---

26.6. Idempotency là gì?

Idempotency nghĩa là:

> Gọi cùng một hành động nhiều lần vẫn cho kết quả như gọi một lần.

Ví dụ đơn giản:

Set status = CANCELLED

Gọi một lần hay ba lần, kết quả cuối vẫn là CANCELLED.

Nhưng:

Trừ 100.000 đồng

Gọi ba lần thì trừ 300.000 đồng.

Không idempotent.

Muốn biến nó thành idempotent, cần transaction id:

Withdraw(wallet_id, amount, transaction_id)

Nếu transaction_id đã xử lý rồi, không trừ lần nữa.

Một câu nhớ:

> Idempotency là khả năng chịu được việc bị gọi lại.

Trong hệ thống phân tán, bạn gần như luôn phải thiết kế như thể việc bị gọi lại sẽ xảy ra.

---

26.7. Idempotency key là gì?

Idempotency key là mã định danh cho một yêu cầu nghiệp vụ.

Ví dụ:

POST /payments
Idempotency-Key: checkout_order_123

Nếu request này được gửi lại, server biết:

Đây là cùng một yêu cầu tạo payment cho order_123.

Nó không tạo payment mới.

Nó trả lại kết quả cũ hoặc trạng thái hiện tại.

Trong job nền:

RunGradingJob(job_123)

job_123 chính là key.

Worker retry cùng job thì không được tạo kết quả mới không kiểm soát.

Trong webhook:

event_id = evt_456

event_id giúp biết webhook này đã xử lý chưa.

---

26.8. Những việc nào cần idempotency?

Cần idempotency cho mọi thao tác có side effect quan trọng.

Ví dụ:

  • Tạo order.
  • Tạo payment.
  • Hoàn tiền.
  • Trừ tiền ví.
  • Gửi email quan trọng.
  • Tạo grading result.
  • Cấp quyền truy cập.
  • Tạo invoice.
  • Cập nhật learning progress.
  • Xử lý webhook.
  • Xử lý event từ queue/pubsub.

Không phải mọi thứ đều cần mức chặt như nhau.

Ví dụ:

Analytics event trùng đôi khi có thể dedup sau.

Typing indicator trùng không sao.

Nhưng payment/refund/wallet/grading result thì phải rất cẩn thận.

---

26.9. Retry lỗi nào?

Không phải lỗi nào cũng nên retry.

Nên retry lỗi tạm thời:

Network timeout
HTTP 503
HTTP 502
HTTP 504
Database deadlock
Rate limit 429 với Retry-After
Provider tạm unavailable

Không nên retry lỗi chắc chắn do input hoặc quyền:

Invalid email
Permission denied
Payload thiếu field bắt buộc
Order không tồn tại
User không có quyền
File sai format không thể đọc

Nếu retry lỗi không thể tự hết, job chỉ làm đầy queue.

Ví dụ:

Email sai format:

abc@@example

Retry 10 lần cũng không thành email đúng.

Nên fail rõ.

---

26.10. Retry bao nhiêu lần?

Không có con số chung.

Phụ thuộc vào loại việc.

Ví dụ:

Gửi email:
  retry 5 lần trong vài giờ.

Gọi AI API:
  retry 2-3 lần với lỗi tạm thời.

Payment webhook:
  có thể retry, nhưng phải idempotent.

Report generation:
  retry ít nếu lỗi do data; retry nếu lỗi hạ tầng.

Nếu retry quá ít, lỗi tạm thời dễ thành lỗi cuối.

Nếu retry quá nhiều, queue bị nghẽn và hệ thống tốn tài nguyên.

Điểm quan trọng:

> Retry phải có giới hạn.

Sau giới hạn đó, job nên chuyển sang failed hoặc DLQ.

---

26.11. Backoff là gì?

Backoff nghĩa là lần retry sau chờ lâu hơn lần trước.

Ví dụ:

retry 1 sau 10 giây
retry 2 sau 30 giây
retry 3 sau 2 phút
retry 4 sau 10 phút

Tại sao cần backoff?

Nếu provider đang lỗi, retry ngay lập tức có thể làm nó quá tải hơn.

Backoff cho hệ thống thời gian hồi phục.

Một kiểu phổ biến là exponential backoff:

1s -> 2s -> 4s -> 8s -> 16s

Nhưng không nhất thiết phải máy móc.

Quan trọng là không retry dồn dập.

---

26.12. Jitter là gì?

Jitter là thêm ngẫu nhiên vào thời gian retry.

Ví dụ:

Thay vì 1.000 job cùng retry đúng sau 60 giây, mỗi job retry trong khoảng:

45 - 75 giây

Vì sao cần?

Nếu tất cả retry cùng lúc, hệ thống tạo một đợt sóng tải mới.

Đó là retry storm.

Jitter giúp trải retry ra.

Một câu nhớ:

> Backoff giúp chậm lại. Jitter giúp không cùng lao lại một lúc.

---

26.13. Retry storm là gì?

Retry storm là khi rất nhiều request/job lỗi cùng lúc rồi retry cùng lúc, làm hệ thống càng quá tải.

Ví dụ:

Gemini API chậm trong 1 phút.

1.000 job timeout.

Tất cả retry sau 30 giây.

Sau 30 giây, 1.000 job cùng gọi lại.

Gemini hoặc hệ thống của ta càng bị dồn tải.

Cách giảm:

  • Backoff.
  • Jitter.
  • Rate limiter.
  • Circuit breaker.
  • Giới hạn concurrency.
  • Tạm pause queue nếu provider lỗi nặng.

Retry không có kiểm soát có thể làm sự cố lớn hơn.

---

26.14. Dead Letter Queue là gì?

Dead Letter Queue, gọi tắt là DLQ, là nơi đưa job/message đã retry nhiều lần nhưng vẫn thất bại.

Ví dụ:

RunGradingJob retry 3 lần vẫn lỗi do prompt quá dài
-> đưa vào DLQ

DLQ giúp:

  • Không retry vô hạn.
  • Không chặn queue chính.
  • Có nơi để debug.
  • Có thể sửa dữ liệu rồi replay.
  • Có alert cho team.

DLQ không phải thùng rác.

Nó là phòng chờ xử lý lỗi.

Nếu job vào DLQ mà không ai xem, DLQ mất ý nghĩa.

---

26.15. Timeout đặt ở đâu?

Timeout không chỉ có một chỗ.

Có thể có:

  • HTTP client timeout.
  • Database query timeout.
  • Job execution timeout.
  • Queue visibility timeout.
  • Webhook provider timeout.
  • Load balancer timeout.

Ví dụ AI Judge:

Gemini HTTP timeout: 120 giây
Job timeout: 150 giây
Queue visibility timeout: lớn hơn job timeout một chút
Frontend polling timeout: ngắn

Các timeout phải hợp nhau.

Nếu queue visibility timeout là 60 giây nhưng job thường chạy 90 giây, queue có thể giao cùng job cho worker khác trong khi worker đầu vẫn chạy.

Kết quả là chạy trùng.

Timeout sai có thể tạo lỗi rất khó hiểu.

---

26.16. Timeout nên dài bao nhiêu?

Không có con số chung.

Timeout nên dựa trên:

  • Thời gian xử lý bình thường.
  • Thời gian xử lý p95/p99.
  • Kỳ vọng người dùng.
  • Chi phí giữ worker.
  • External API behavior.
  • Khả năng retry.

Ví dụ:

Nếu Gemini thường trả trong 90 giây, có thể đặt:

HTTP timeout: 120 giây
Job timeout: 150 giây

Nhưng nếu thường xuyên chạm timeout, đừng chỉ tăng timeout mãi.

Hãy hỏi:

  • Provider có đang chậm không?
  • Prompt có quá lớn không?
  • Response có quá dài không?
  • Concurrency có quá cao không?
  • Network có vấn đề không?
  • Có cần chia job không?

Timeout là dây an toàn, không phải cách che hệ thống chậm vô hạn.

---

26.17. Timeout và cancellation

Khi job timeout, ta cần quyết định:

  • Mark failed?
  • Retry?
  • Cancel external request?
  • Để request ngoài tiếp tục chạy?
  • Query lại trạng thái?

Với một số API, khi client timeout, provider vẫn có thể tiếp tục xử lý.

Ví dụ payment/refund:

Request timeout không có nghĩa provider không làm.

Với AI grading, nếu request timeout, có thể retry nếu job idempotent.

Nhưng cũng cần cẩn thận:

  • Có thể tốn tiền nhiều lần.
  • Có thể lưu kết quả khác nhau.
  • Có thể vượt rate limit.

Timeout phải đi cùng chính sách xử lý sau timeout.

---

26.18. Idempotency trong AI Judge

Với AI Judge, job có thể retry.

Ta cần đảm bảo:

RunGradingJob(job_123)

chạy lại không làm sai.

Một cách:

GradingJob
- id
- status
- attempt_count
- result_id
- completed_at

Worker bắt đầu:

update grading_jobs
set status = 'RUNNING'
where id = job_123 and status in ('PENDING', 'RETRYING')

Nếu update được 0 row, nghĩa là job không còn ở trạng thái được chạy.

Khi complete:

Nếu job đã SUCCEEDED:
  không ghi lại kết quả khác.

Có thể lưu attempt riêng:

grading_attempts
- job_id
- attempt_number
- status
- raw_response

Nhưng kết quả cuối phải được quyết định rõ.

Không để retry tạo nhiều kết quả mâu thuẫn.

---

26.19. Idempotency trong email

Email có nhiều mức.

Email marketing gửi trùng một lần có thể khó chịu nhưng không thảm họa.

Email reset password hoặc OTP thì nhạy hơn.

Cách làm:

EmailMessage
- id
- recipient
- template
- status
- provider_message_id
- sent_at

Job:

SendEmail(email_id)

Nếu email_id đã SENT, retry có thể bỏ qua.

Nếu provider hỗ trợ idempotency hoặc custom header, dùng thêm.

Nếu không chắc email đã gửi chưa, cần quyết định theo nghiệp vụ:

  • Gửi lại có chấp nhận không?
  • Hay mark uncertain và xử lý thủ công?

Không phải email nào cũng cần chặt như payment, nhưng vẫn nên có trạng thái rõ.

---

26.20. Idempotency trong payment/refund

Payment và refund phải rất chặt.

Ví dụ:

RefundPayment(refund_id)

Không được hoàn tiền hai lần.

Cần:

  • refund_id duy nhất nội bộ.
  • Idempotency key gửi đến provider.
  • Unique constraint.
  • State machine.
  • Lưu provider transaction id.
  • Webhook handler idempotent.
  • Reconciliation.

Luồng:

Tạo Refund = PENDING
Gọi provider với idempotency_key = refund_id
Nếu success -> REFUNDED
Nếu timeout -> UNKNOWN hoặc PENDING_VERIFICATION
Query provider hoặc chờ webhook

Không nên:

Timeout -> retry tạo refund mới với key mới

Đó là đường dẫn đến hoàn tiền trùng.

---

26.21. Idempotency trong webhook

Webhook thường gửi trùng.

Provider có thể gửi cùng event nhiều lần.

Cần lưu:

processed_webhook_events
- provider
- event_id
- processed_at

Khi nhận webhook:

verify signature
if event_id processed:
  return 200
save event
enqueue processing

Nhưng lưu event_id thôi chưa đủ nếu provider có event khác nhau cho cùng payment.

Payment state vẫn cần state machine.

Ví dụ:

payment_succeeded
refund_issued
chargeback_created

Mỗi event phải chuyển trạng thái hợp lệ.

---

26.22. Idempotency trong event consumer

Pub/Sub hoặc event streaming consumer cũng cần idempotent.

Ví dụ:

GradingCompleted(event_id=evt_123)

Notification consumer nhận hai lần.

Không nên tạo hai notification giống nhau nếu nghiệp vụ không muốn.

Cách làm:

  • Lưu event đã xử lý theo consumer.
  • Unique constraint theo đối tượng nghiệp vụ.
  • Kiểm tra trạng thái trước khi cập nhật.

Ví dụ:

unique(notification.type, notification.user_id, notification.source_id)

Nếu event replay, consumer không tạo trùng.

---

26.23. Idempotency bằng database constraint

Database constraint là bạn rất tốt của idempotency.

Ví dụ:

unique(refund_id)
unique(provider_event_id)
unique(grading_job_id)
unique(order_id, email_type)

Nếu code bị gọi lại, database chặn dữ liệu trùng.

Đừng chỉ dựa vào:

if not exists:
  create

trong code nếu có concurrency.

Hai worker có thể cùng kiểm tra "chưa tồn tại" rồi cùng tạo.

Unique constraint là lớp bảo vệ cuối.

Code tốt + constraint tốt mới bền.

---

26.24. Idempotency bằng state machine

State machine cũng giúp idempotency.

Ví dụ GradingJob:

PENDING -> RUNNING -> SUCCEEDED
PENDING -> RUNNING -> FAILED
FAILED -> RETRYING -> RUNNING

Nếu job đã SUCCEEDED, không cho chuyển lại RUNNING trừ khi có use case regrade rõ ràng.

Ví dụ Payment:

PENDING -> SUCCEEDED
PENDING -> FAILED
SUCCEEDED -> REFUNDED

Không cho event cũ kéo trạng thái đi lùi sai.

State machine giúp mỗi retry/event chỉ được tác động khi trạng thái hiện tại phù hợp.

---

26.25. Exactly-once có giải quyết hết không?

Một số broker/công cụ nói có exactly-once.

Nhưng trong nghiệp vụ, vẫn nên thiết kế idempotent.

Vì side effect có thể nằm ngoài broker:

  • Gọi payment provider.
  • Gửi email.
  • Ghi database khác.
  • Gọi API bên ngoài.
  • User nhận thông báo.

Broker có thể đảm bảo trong phạm vi của nó.

Nhưng toàn bộ luồng end-to-end hiếm khi thật sự exactly-once đơn giản.

Quy tắc thực dụng:

> Hãy giả định message có thể xử lý hơn một lần, rồi thiết kế consumer/job chịu được điều đó.

---

26.26. Retry và user experience

Retry không chỉ là chuyện backend.

User cũng cần thấy trạng thái hợp lý.

Ví dụ AI Judge:

RUNNING
FAILED_TEMPORARY, retrying
SUCCEEDED
FAILED_PERMANENT

Không nhất thiết hiển thị thuật ngữ kỹ thuật.

Nhưng UI nên nói thật:

Hệ thống đang chấm bài.
Lần chấm gặp lỗi tạm thời, đang thử lại.
Không thể chấm bài lúc này, vui lòng thử lại sau.

Nếu retry âm thầm quá lâu, user chỉ thấy hệ thống kẹt.

Job status nên phục vụ cả vận hành và trải nghiệm người dùng.

---

26.27. Retry thủ công

Không phải retry nào cũng tự động.

Một số job sau khi vào DLQ có thể cần người xem rồi retry thủ công.

Ví dụ:

  • Import file lỗi do dữ liệu.
  • Payment cần kiểm tra.
  • Report lỗi do query.
  • AI grading lỗi do rubric quá dài.

Cần công cụ:

  • Xem job lỗi.
  • Xem lý do.
  • Sửa dữ liệu nếu cần.
  • Retry.
  • Mark ignored/failed.

Hệ thống trưởng thành không chỉ tự retry.

Nó còn giúp con người xử lý những việc máy không tự quyết được.

---

26.28. Một mẫu job an toàn

Một job an toàn thường có dạng:

RunJob(job_id):
  load job

  if job already succeeded:
    return

  try to transition PENDING/RETRYING -> RUNNING
  if transition failed:
    return

  try:
    call external API with timeout
    save result in transaction
    mark SUCCEEDED
    save outbox event

  except temporary error:
    increase attempt_count
    if attempts left:
      schedule retry with backoff
    else:
      mark FAILED
      send to DLQ / alert

  except permanent error:
    mark FAILED
    do not retry

Đây không phải code cụ thể.

Đây là hình dạng tư duy.

---

26.29. Những lỗi phổ biến

Lỗi 1: Không có timeout

Job treo mãi, worker bị giữ.

Lỗi 2: Retry mọi lỗi

Input sai cũng retry, làm đầy queue.

Lỗi 3: Retry không backoff

Provider lỗi bị đánh dồn dập hơn.

Lỗi 4: Không có jitter

Nhiều job retry cùng lúc, tạo retry storm.

Lỗi 5: Không idempotent

Retry tạo dữ liệu trùng hoặc side effect trùng.

Lỗi 6: Không có DLQ

Job lỗi biến mất hoặc retry vô hạn.

Lỗi 7: Timeout nhưng không biết trạng thái ngoài

Payment/refund timeout bị xử lý như thất bại chắc chắn.

Lỗi 8: Chỉ check trong code, không có database constraint

Race condition vẫn tạo trùng.

Lỗi 9: Không log attempt

Không biết job đã thử mấy lần và lỗi gì.

---

26.30. Checklist thiết kế retry/timeout/idempotency

Khi thiết kế job hoặc webhook, hãy hỏi:

  • Việc này có side effect gì?
  • Nếu chạy hai lần thì sao?
  • Idempotency key là gì?
  • Có unique constraint nào bảo vệ không?
  • Có state machine không?
  • Timeout từng bước là bao lâu?
  • Lỗi nào retry?
  • Lỗi nào không retry?
  • Retry tối đa mấy lần?
  • Backoff thế nào?
  • Có jitter không?
  • Sau retry hết thì đi đâu?
  • Có DLQ không?
  • Có alert không?
  • Có thể retry thủ công không?
  • Timeout có nghĩa là thất bại hay chưa rõ?
  • Có cần query provider để xác minh không?
  • Có log từng attempt không?

Nếu chưa trả lời được, job đó chưa thật sự an toàn.

---

26.31. Tóm tắt bằng AI Judge

Với AI Judge:

RunGradingJob(job_123)

Cần:

  • Timeout khi gọi Gemini.
  • Retry nếu lỗi tạm thời.
  • Không retry nếu input/rubric sai không thể sửa.
  • Attempt count.
  • Backoff và jitter.
  • Job status rõ.
  • Không chạy lại nếu job đã succeeded.
  • Không ghi hai kết quả cuối khác nhau.
  • DLQ nếu retry hết vẫn lỗi.

Luồng:

PENDING
-> RUNNING
-> SUCCEEDED

hoặc:

RUNNING
-> FAILED_TEMPORARY
-> RETRYING
-> RUNNING

hoặc:

RUNNING
-> FAILED_PERMANENT

Điểm quan trọng:

> Retry giúp job có cơ hội thành công. Idempotency đảm bảo retry không phá kết quả.

---

26.32. Kết luận của chương

Job nền không thể được thiết kế như một hàm chạy một lần rồi chắc chắn xong.

Trong hệ thống thật:

  • API có thể timeout.
  • Worker có thể chết.
  • Queue có thể deliver lại.
  • Webhook có thể gửi trùng.
  • Network có thể lỗi.
  • Provider có thể chậm.

Vì vậy ta cần:

  • Timeout để không chờ mãi.
  • Retry để phục hồi lỗi tạm thời.
  • Backoff và jitter để retry không gây bão tải.
  • Idempotency để retry không tạo dữ liệu sai.
  • DLQ để giữ việc không xử lý được.
  • State machine và constraint để bảo vệ nghiệp vụ.

Thông điệp quan trọng nhất:

> Retry mà không idempotent là nguy hiểm. Timeout mà không hiểu trạng thái thật cũng nguy hiểm. Job nền tốt là job có thể lỗi, thử lại, và vẫn giữ dữ liệu đúng.

Ở chương tiếp theo, ta sẽ nói về rate limit và quota: vì khi concurrency và retry tăng lên, hệ thống rất dễ vượt giới hạn của API bên ngoài nếu không có cơ chế kiểm soát.