Bot của bạn chạy hoàn hảo lúc phát triển. Trên production, lúc nó trả lời lúc không. Người dùng báo rằng nó “thường thì” có trả lời, hoặc nó trả lời ba lần cho một tin nhắn, hoặc nó im lặng cả buổi chiều rồi tự quay lại.
Cả ba đều là cùng một bug. Slack cho bạn ba giây, handler (đoạn code xử lý webhook trên server của bạn) mất bốn giây, và mọi thứ sau đó là chính sách thử lại (retry) của Slack va chạm với một handler không được viết để lường trước nó.
TL;DR
- “App của bạn cần phản hồi request sự kiện bằng một mã HTTP 2xx trong vòng ba giây”
- Slack thử lại ba lần: gần như ngay, sau 1 phút, rồi sau 5 phút
- Một handler không idempotent (xử lý nhiều lần vẫn cho cùng một kết quả) biến các lần thử lại đó thành tin nhắn trùng lặp mà mọi người đều thấy
x-slack-no-retry: 1trên một response không phải 200 sẽ chặn các lần thử lại cho những sự kiện bạn biết chắc không xử lý được- Thất bại hơn 95% lượt gửi trong khoảng 60 phút, Slack sẽ vô hiệu hóa event subscription của bạn và gửi email cho chủ sở hữu app
Ba giây chẳng đủ cho việc gì
Hãy xem một handler điển hình làm gì từ lúc nhận message.channels đến lúc sẵn sàng trả lời. Nó xác minh chữ ký. Nó tra xem đây là workspace nào. Nó đọc một ít trạng thái từ database. Rồi, nếu bot làm điều gì đó thú vị vào năm 2026, nó gọi một mô hình.
Ba bước đầu có lẽ mất 200 ms vào hôm thuận lợi. Bước thứ tư mất 30 giây.
Đây cũng là sự lệch pha về thang thời gian thường gặp ở webhook thanh toán, chỉ khác là ở đây thời hạn phản hồi là ba giây được công bố rõ ràng (thời hạn của mọi bên gửi có trong tài liệu tham khảo về các nhà cung cấp webhook của chúng tôi) và hậu quả thì ai cũng thấy chứ không âm thầm. Không ai ngồi nhìn một payment_intent.succeeded thất bại. Nhưng ai cũng thấy một bot không trả lời.
Hướng sửa tổng quát, cho mọi bên gửi và mọi nền tảng bạn có thể host, nằm trong bài Handler webhook của bạn gọi LLM, và công cụ kiểm tra timeout sẽ đo handler của bạn so với ba giây của Slack. Slack còn có một lối thoát riêng giúp chuyện này đỡ khổ hơn nhiều so với phần lớn các bên khác.
Mẹo Slack cho bạn mà Stripe thì không
Slack tách việc xác nhận (ack) sự kiện khỏi việc trả lời người dùng. Bạn trả 200 ngay, rồi đăng câu trả lời thật sau đó bằng chat.postMessage, hoặc gọi tới response_url mà các payload tương tác mang theo.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySlackSignature(raw, req.headers)) {
return new Response("bad signature", { status: 401 });
}
const body = JSON.parse(raw);
// URL verification handshake, only during setup
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
await queue.publish(body); // durable, ~10ms
return new Response(null, { status: 200 }); // well inside 3s
}
Sau đó worker cần bao lâu cũng được và gọi chat.postMessage khi đã có điều để nói. Từ phía người dùng, trông như bot đang suy nghĩ, một việc bình thường với một bot. Từ phía Slack, sự kiện đã được xác nhận trong 40 mili giây.
Nếu công việc mất hơn một hai giây, hãy đăng ngay một tin nhắn tạm rồi cập nhật nó. Một tin nhắn ghi “đang xử lý” rồi tự sửa lại cho trải nghiệm tốt hơn nhiều so với ba giây im lặng rồi timeout.
Các lần thử lại thực sự gây ra gì cho bạn
Lịch của Slack ngắn và cụ thể: “Lần thử lại đầu tiên sẽ được gửi gần như ngay. Lần thử lại thứ hai sẽ diễn ra sau 1 phút. Lần thử lại thứ ba, cũng là lần cuối, sẽ được gửi sau 5 phút”.
Giờ hãy xét một handler làm việc trước rồi mới trả 200 sau cùng, và mất bốn giây. Slack timeout ở giây thứ ba. Handler của bạn xong ở giây thứ tư và đăng một tin nhắn. Slack thử lại gần như ngay, handler chạy lại, đăng thêm lần nữa. Một phút sau, tin nhắn thứ ba. Năm phút sau, tin nhắn thứ tư.
Người dùng hỏi một câu và nhận bốn câu trả lời, rải rác trong sáu phút, trông không giống một bot chậm mà giống một bot hỏng.
Cách sửa vẫn là mẫu unique constraint như mọi nơi khác, với khóa là event_id của Slack:
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'slack') ON CONFLICT (event_id) DO NOTHING`,
[body.event_id],
);
if (rowCount === 0) return new Response(null, { status: 200 });
Để ý mã 200 ở nhánh bản trùng. Trả lỗi ở đó là bảo Slack thử lại đúng thứ bạn vừa bỏ qua thành công. Trường hợp đồng thời, khi hai lần thử lại rơi vào hai instance trong cùng một mili giây, được bàn thêm trong bài Bạn nhận cùng một webhook hai lần.
Header không ai dùng
Có những sự kiện sẽ không bao giờ thành công. Workspace đã gỡ app của bạn, kênh đã được lưu trữ, payload tham chiếu tới một object bạn đã xóa. Thử lại những sự kiện đó lãng phí ba lượt gửi và, quan trọng hơn, đẩy tỷ lệ thất bại của bạn về phía ngưỡng khiến subscription bị tắt.
Slack cho phép bạn tắt thử lại theo từng response: “Gửi cho chúng tôi header HTTP và giá trị này trong response không phải 200 OK của bạn: x-slack-no-retry: 1”.
if (!workspace) {
return new Response("unknown workspace", {
status: 410,
headers: { "x-slack-no-retry": "1" },
});
}
Chỉ dùng nó cho lỗi vĩnh viễn. Timeout của database không phải lỗi vĩnh viễn, và bạn muốn có lần thử lại đó.
Ngưỡng thất bại
Đây là phần biến một buổi chiều chậm chạp thành một sự cố ngừng hoạt động.
“Nếu app của bạn rơi vào bất kỳ tổ hợp nào của các điều kiện thất bại này ở hơn 95% số lần gửi trong vòng 60 phút, event subscription của app sẽ tạm thời bị vô hiệu hóa”.
Khi chuyện này xảy ra, Slack gửi email cho người tạo và chủ sở hữu app. Điều đó chỉ có ích nếu địa chỉ ấy thuộc về một người vẫn còn ở công ty và vẫn đọc nó. Đây cũng là cái bẫy giống email liên hệ khẩn cấp cho developer của Shopify, được mô tả trong bài Shopify webhook bị xóa: vì sao subscription biến mất: cảnh báo duy nhất bạn nhận được lại đi tới một địa chỉ được chọn lúc thiết lập và không bao giờ được xem lại.
Điều cần ghi nhớ: 95% trong một giờ không phải một sự suy giảm từ từ mà bạn sẽ kịp nhận ra. Một lần deploy làm hỏng bước xác minh chữ ký sẽ chạm 100% ngay từ đầu, và một giờ sau subscription bị tắt. Bot không báo lỗi. Nó chỉ đơn giản là không nhận được gì nữa, và nhìn từ bên trong thì y hệt một kênh đang yên ắng.
Debug một bot lúc trả lời lúc không
Kiểm tra theo thứ tự sau.
Thời gian phản hồi, không phải tỷ lệ lỗi. Một handler mất 2,8 giây sẽ qua mọi test bạn viết và thất bại mỗi khi database hơi bận. Đo p99 thay vì giá trị trung bình, vì p99 mới là thứ khiến Slack timeout.
Cold start, nếu bạn chạy serverless. Hai giây cold start trên thời hạn ba giây chỉ còn lại một giây cho mọi thứ khác. Bài Nhận webhook trên Vercel mà không bị mất hướng dẫn giữ import graph của route nhỏ gọn, cách giành lại thời gian hiệu quả hơn bất cứ điều gì khác bạn có thể làm ở đây.
Tin nhắn trùng có phải là các lần thử lại của Slack không. Một tin nhắn trùng sáu phút sau tin đầu tiên là lần thử lại thứ ba của Slack, không phải bug trong logic đăng tin của bạn. Thời điểm sẽ cho bạn biết là trường hợp nào.
Subscription có còn được bật không. Nếu mọi thứ đột nhiên im bặt và log không có gì thay đổi, hãy kiểm tra trang Event Subscriptions của app trước khi debug bất cứ thứ gì khác.
AnyHook giúp được gì
Ba giây là một ràng buộc ở tầng truyền tải, và tầng truyền tải chính là thứ mà một dịch vụ trung chuyển webhook (webhook relay) thay thế.
Trỏ Request URL của Slack tới in.anyhook.net/you/slack. AnyHook xác minh và lưu sự kiện tại edge (lớp máy chủ biên của Cloudflare) và trả 200 sau khoảng một giây, dù handler của bạn đang ấm, đang nguội hay đang cháy. Đồng hồ ba giây của Slack được đáp ứng bởi một edge worker chỉ làm đúng hai việc, nên chuỗi thử lại gần như không bao giờ bắt đầu và ngưỡng 95% không bao giờ đến gần.
- Handler của bạn có từ 60 đến 300 giây thay vì ba giây, tùy gói và trong phạm vi hạn mức compute (tài nguyên tính toán đi kèm gói)
- Mọi sự kiện được lưu trước khi gửi, nên một khoảng thời gian deploy chỉ cần một lần phát lại (replay), thay vì một loạt câu hỏi từ người dùng
- Lượt gửi tới endpoint của bạn được thử lại theo exponential backoff (khoảng chờ tăng theo cấp số nhân) trong thời gian dài hơn nhiều so với chuỗi sáu phút của Slack
- Cảnh báo lỗi được gửi khi có 1, 5 và 20 lần thất bại liên tiếp, từ rất lâu trước khi bất cứ thứ gì chạm tới ngưỡng mà Slack hành động
AnyHook trả lời handshake url_verification ngay tại edge, sau khi xác minh chữ ký của Slack nếu bạn đã cung cấp signing secret (khóa bí mật dùng để ký) cho AnyHook, và không chuyển tiếp gì tới handler của bạn.
Điều cần nhớ
Ba giây của Slack là thời hạn phản hồi chặt nhất mà một bên gửi lớn áp dụng, và là một trong số ít trường hợp mà trễ hạn thì người dùng sản phẩm của bạn nhìn thấy. Luôn xác nhận trước, trả lời sau. Chống trùng lặp theo event_id trước khi làm bất cứ điều gì mà người dùng có thể thấy hai lần. Gửi x-slack-no-retry: 1 khi bạn biết sự kiện đó không thể thành công. Và kiểm tra xem địa chỉ nào đang được đặt làm chủ sở hữu app, vì đó là nơi thông báo sẽ đến khi subscription của bạn bị tắt.