TL;DR

Retry một endpoint đã chết hẳn là chuyện bình thường. Retry một endpoint treo 30 giây lại là một resource leak rõ rệt — vì mỗi lần thử pin một worker trong pool dùng chung. Enes Akar, co-founder của Upstash, vừa hé lộ cách QStash xử lý: một cơ chế HostBlocker — nếu endpoint của bạn ngừng phản hồi, họ ngừng gọi nó trong một khoảng thời gian. Lỗi của một user không còn kéo theo cả hệ thống. Đây là circuit breaker + bulkhead áp dụng đúng chỗ, và mọi backend team chạy outbound HTTP ở quy mô lớn nên tự hỏi: HostBlocker của mình ở đâu?

Điều gì vừa được chia sẻ

Trong một post ngắn trên X, Enes Akar đóng khung vấn đề gọn đến mức ai làm queue hay webhook delivery đều gật đầu:

Retrying a dead endpoint is fine. Retrying an endpoint that hangs for 30s is a resource leak. We implemented a HostBlocker. If you stop responding, we stop calling.

Không phải một tính năng mới hào nhoáng, không có giá mới, cũng chưa có trang docs riêng. Nhưng đằng sau hai câu đó là một mental model flip mà nhiều team bỏ sót khi thiết kế hệ thống retry.

Vì sao đây là vấn đề lớn hơn bạn nghĩ

Khi tính chi phí retry, kỹ sư hay ngầm giả định mỗi lần thử tốn O(1): một request đi, một response về, xong. Giả định này đúng với endpoint chết hẳn — DNS NXDOMAIN, ECONNREFUSED, hoặc 5xx trong vài mili-giây — vì TCP fail nhanh.

Giả định này sai hoàn toàn với endpoint treo. Endpoint accept TCP, giữ connection, rồi không bao giờ trả lời. Mỗi attempt tốn đúng bằng Max HTTP Response Duration của plan (ví dụ 30 giây) cho tới khi timeout. Trong một worker pool dùng chung, N delivery treo đồng thời = N worker bị pin đứng im 30 giây.

Công thức thật ra là:

  • Chi phí retry endpoint chết: O(1) — vài ms cho TCP handshake fail
  • Chi phí retry endpoint treo: O(timeout) — toàn bộ thời gian chờ tối đa

Chênh lệch có thể lên tới hàng nghìn lần. Và trong một hệ đa tenant, webhook của một customer bị treo là đủ để ăn sạch worker pool, kéo chậm delivery cho tất cả khách hàng khác. Đây chính là noisy neighbor antipattern kinh điển mà Azure Architecture Center từng mô tả.

Vì sao exponential backoff thôi là không đủ

Nhiều team nghĩ backoff giải quyết được retry storm, và đúng — với endpoint chết. QStash mặc định dùng công thức delay = min(86400, e^(2.5 * n)), tức là lần retry sau cách ~12s, 2m28s, 30m8s, 6h7m6s. Backoff giúp giãn các attempt ra theo thời gian.

Nhưng với endpoint treo, giãn thời gian không giải phóng worker slot trong lúc một attempt đang chạy. Mỗi khi cửa sổ retry mở, một worker lại đi vào trạng thái treo 30 giây. Backoff trì hoãn lần hỏng tiếp theo; nó không bảo vệ bạn khỏi lần hỏng hiện tại.

Muốn đóng lỗ hổng này bạn cần một pre-dispatch check: trước khi worker nhấc job ra khỏi queue, hỏi: "host này dạo này có ngoan không?". Nếu không, park job lại, đi làm việc khác. Đó đúng là hình dáng của HostBlocker.

So sánh với các pattern liên quan

PatternÁp dụng ởCái bảo vệ được
Circuit breaker (Hystrix, Resilience4j)Client-side, mỗi dependencyBảo vệ chính client khỏi cascade failure
BulkheadPartition static cho mỗi tenantCô lập lỗi nhưng lãng phí tài nguyên khi idle
Exponential backoffGiữa các lần retryChống retry storm cho endpoint chết
HostBlockerServer-side, pre-dispatch, per-hostCô lập endpoint treo trong worker pool dùng chung

HostBlocker gần nhất với circuit breaker, nhưng đặt ở phía platform thay vì phía ứng dụng — key theo host của customer, không phải theo client library của developer. Cách làm tương tự cũng được Inngest mô tả khi họ giải bài toán concurrency đa tenant của mình.

Khi nào bạn cần HostBlocker của riêng mình

  • Hệ thống job queue và webhook delivery (QStash, Trigger.dev, Hookdeck, Sidekiq, Celery cho webhook): use case gốc.
  • API gateway đứng trước nhiều upstream — một downstream chậm đủ sức ăn hết connection pool.
  • Hệ thống fan-out notification (email, SMS, push) gọi callback URL do customer cung cấp.
  • Uptime / monitoring checker — target treo nên bị park thay vì retry nóng.
  • Bất kỳ nền tảng nào chạy N worker phục vụ M customer với M > N (tức là mọi hệ ở quy mô).

Giới hạn và đánh đổi

  • Đây là tweet chia sẻ pattern, không phải announcement chính thức. Chưa có API public để tune ngưỡng chặn, cửa sổ cool-down, hay logic recovery.
  • Chặn quá aggressive thì endpoint vừa phục hồi vẫn phải chờ hết cửa sổ — tăng latency của job hợp lệ.
  • Chưa rõ QStash key theo host, URL path, hay customer ID. Mỗi lựa chọn có trade-off khác nhau về false positive.
  • Nếu muốn opt-out khỏi retry luôn (ví dụ lỗi logic, không phải lỗi transient), QStash có sẵn cơ chế: response 489 kèm header Upstash-NonRetryable-Error: true để đẩy thẳng vào Dead Letter Queue.

Điều đáng mang đi

Nếu team bạn vận hành bất kỳ hệ nào gọi outbound HTTP cho nhiều tenant, hãy audit retry path trong tuần này và hỏi ba câu:

  1. Một endpoint treo 30 giây của một customer có thể pin bao nhiêu worker slot trong pool chung?
  2. Code của chúng ta có phân biệt được endpoint chết và endpoint treo không — hay đang đối xử cả hai như nhau với exponential backoff?
  3. Có pre-dispatch check nào nói "skip host này trong X giây" — hay worker cứ nhấc job và treo?

Câu trả lời thường khiến các team không vui. Nhưng đó chính là phần giá trị nhất của bài note ngắn từ Enes: nó đặt câu hỏi đúng.

Nguồn: Enes Akar trên X, QStash Retry Docs, Microsoft Learn — Noisy Neighbor Antipattern, Inngest — Multi-tenant queueing.