其他 Webhook 发送方都只给你一个数字,然后指望你接受它。Shopify 的五秒、GitHub 的十秒、Slack 的三秒,都收录在我们的 Webhook 平台参考里。你可以抱怨,但改不了。
Twilio 是个例外。它把超时作为参数直接暴露在 Webhook 上,还有重试次数,以及哪些失败值得重试的策略。五个旋钮,出厂都带默认值,而在大多数集成里,从来没人碰过它们。
这在两个方向上都错过了机会,因为对最常见的两种情况来说,默认值都不对。
TL;DR
ct连接超时:默认 5000 毫秒,范围 100 到 10000rt读取超时:默认 15000 毫秒,范围 100 到 15000tt包括重试在内的总时长:默认 15000 毫秒,范围 100 到 15000rc重试次数:0 到 5,默认 1rp重试策略:哪些类型的失败值得重试- 上限是硬性的。读取超时超过 15 秒,花多少钱都买不到
三个超时,不是一个
大多数人对 Webhook 超时只有一个心智模型:发送方等响应等多久。Twilio 把它拆成了三个阶段,知道你撞上的是哪一个,诊断方向就完全不同。
ct 是“Twilio 等待与你的服务器建立 TCP 连接的时间,以毫秒为单位”。默认 5000 毫秒,最大 10000 毫秒。撞上它,意味着 Twilio 根本没能打开套接字。问题出在 DNS、TLS、防火墙,或者源站完全宕机。绝不会是你的处理程序太慢,因为你的处理程序还没运行。
rt 是“Twilio 发出 Webhook 请求后,等待每个 HTTP 响应数据包的时间,以毫秒为单位”。默认 15000 毫秒,最大 15000 毫秒。人们说“超时”时指的就是它,它也是你的处理程序实际可用的时间。
tt 是“包括重试在内、所有超时加起来允许的总时间”。默认 15000 毫秒,最大 15000 毫秒。这是整个尝试序列的上限,也是最容易让人栽跟头的参数,因为它连重试也一起限制了。
注意 tt 和 rt 的默认值和最大值都一样。按出厂默认值,一次读取超时就会耗光全部总时长,你以为配置好了的重试根本没有机会运行。如果想让重试有意义,就必须调低 rt,让两次或更多次尝试能放进 tt 里。
这种相互作用是这里最值得理解的一点,而逐个阅读参数时并不容易看出来。
让重试真正发生的设置方法
假设你的处理程序通常 400 毫秒就能应答,而你希望放弃之前重试两次。
rt = 3000 // 3s per attempt, generous against a 400ms handler
rc = 2 // two retries
tt = 12000 // room for three attempts plus connect overhead
ct = 3000 // fail fast if the socket will not open
rp = ct,rt,5xx
现在一个卡住的处理程序会在三秒时被放弃,而不是十五秒,重试会在同一次事故还没结束时到达。对比默认值:一个卡住的请求就会烧光全部十五秒,配置好的重试静默地永远不会触发。
重试策略值得认真考虑。重试 4xx 通常是错的,因为 400 或 404 意味着请求本身不可接受,再发一次也会得到同样的回答。要重试的是连接超时、读取超时和 5xx,这些才是可能只是暂时性的失败。这和 Webhook 重试的指数退避与随机抖动是同一个道理,只不过这个发送方允许你用配置来表达它。
备用 URL 是另一半
Twilio 的文档建议设置更短的超时,“以便在你的服务器宕机时,更快地故障转移到备用 URL(fallback URL)”。
备用 URL 是主端点失败时 Twilio 调用的第二个端点,也是主流发送方提供的最接近内置熔断器的东西。只有超时足够短、在来电者还没挂断时就能切过去,它才有用;这正是 rt 和 ct 对语音和短信很重要、对支付类 Webhook 却没那么重要的原因。
把它指向一个不会因为同样原因失败的地方。和主端点在同一台主机、同一个负载均衡器后面、共用同一个数据库的备用端点,算不上备用。从对象存储提供的静态 TwiML 才算。
旋钮解决不了的情况
有一类情况 Twilio 的配置救不了,而且它越来越常见。
收到一条短信,你想让大模型(LLM)来写回复。这次调用要二十到六十秒。读取超时的上限是十五秒,而且你没法调高。rt、tt 和 rc 怎么组合,都没法让一个要六十秒的处理程序塞进十五秒的上限里。
答案和 Slack 的一样:应答和回复分开。立即返回空的 TwiML,让 Twilio 停止计时,等模型完成后再通过 Messages API 发送真正的回复。
export async function POST(req: Request) {
const raw = await req.text(); // raw body, for the signature
if (!verifyTwilioSignature(req.url, raw, req.headers.get("x-twilio-signature"))) {
return new Response("unauthorized", { status: 403 });
}
await queue.publish(raw); // durable, ~10ms
// Empty TwiML: acknowledged, nothing to say yet
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
headers: { "content-type": "text/xml" },
});
}
随后,工作进程在拿到回答时调用 messages.create。用户会在一分钟内看到回复,这对短信对话来说很正常。而 Twilio 在 40 毫秒内就看到了响应。这个论点更完整的版本在面向 AI 智能体(AI Agent)的 Webhook里。
语音场景的对应做法,是在真正的工作进行时,返回一段播放语音消息或让来电者排队等待的 TwiML,因为让来电者听着一片沉默,比一条慢吞吞的短信糟糕得多。
这里的签名验证不一样
X-Twilio-Signature 不是对请求体做的普通 HMAC。Twilio 用完整的请求 URL 来构造被签名的字符串;对于表单编码的请求,还要加上按键名排序后拼接起来的 POST 参数。然后用你的 auth token 做 HMAC-SHA1,再做 base64 编码。
这带来两个实际后果。
URL 是签名的一部分,所以任何改写 URL 的东西都会让验证失败。去掉端口号的代理、把协议从 https 改成 http 的负载均衡器、根据请求头重建 URL 的框架,都会产生一个看起来正常、却通不过检查的请求。如果验证在生产环境失败、在本地通过,就把你的代码签名用的确切 URL 和 Twilio 实际调用的 URL 对比一下。
它用的是 SHA1,不是 SHA256,而且对表单编码的请求,输入也不是原始请求体。用 Twilio 自己的 validateRequest 辅助函数,不要改造 Stripe 风格的验证函数,因为两者的构造方式确实不同。各平台的对比见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。
AnyHook 能做什么
这些旋钮很有用,但有一个硬上限。十五秒是 Twilio 最多愿意等的时间,而很多正当的处理程序需要更久,调用大模型的尤其如此。超时检测工具会拿这十五秒来给你的处理程序计时,你的 Webhook 处理程序调用大模型给出了每个平台上先应答的修复办法。
把 Webhook 指向 in.anyhook.net/you/twilio,这个上限就不再是你的上限。AnyHook 在边缘节点约 1 秒应答,然后投递到你的处理程序,投递超时根据套餐从 60 秒到 300 秒不等,并以计算额度为限。
- 对你端点的重试按指数退避进行,持续时间远远超过
tt允许的范围 - 每个事件都在投递前持久化,所以处理程序失败了,只需要一次重放,而不是丢失一条消息
- 连续失败 1 次、5 次和 20 次时发出失败告警,并在 20 次时自动暂停
如果你需要在响应里返回 TwiML,就把这部分留在你自己的处理程序里。当 Webhook 是一条通知、而不是在向你要指令时,转发服务才是合适的形态。
总结
在 Webhook 计时上,Twilio 给你的控制权比任何其他主流发送方都多,而它的默认值是为一个立刻就能应答的处理程序设定的。调低 rt,让重试能放进 tt;把 rp 设成跳过 4xx;把备用 URL 指向一个故障模式不同的地方。
然后坦然接受这个上限。十五秒就是最大值,如果你的处理程序需要一分钟,任何配置都帮不了你。先应答,再通过另一条通道回复。