ThinkPHP 的 throttle 中间件不自动设置 Retry-After 响应头,因其仅抛出429异常而不处理响应头;需在异常处理器中手动注入 Retry-After(基于缓存剩余过期时间)、X-RateLimit-Limit 和 X-RateLimit-Remaining,且须确保限流中间件生效、缓存驱动支持 getExpire 并合理设计限流 key。

ThinkPHP 默认的 throttle 中间件不会自动设置 Retry-After 响应头,即使触发了 429 状态码;必须手动在异常处理器中注入该头,否则前端无法知道要等几秒再重试。
为什么 throttle 不返回 Retry-After?
ThinkPHP 6 的 throttle 中间件底层调用的是框架的限流器(think\cache\driver\Redis 或 File),它只负责计数和抛出 think\exception\HttpException(状态码 429),但不干预响应头。框架默认的异常处理器只设了 Status 和 Content-Type,漏掉了标准限流头。
- 标准限流头包括:
X-RateLimit-Limit、X-RateLimit-Remaining、Retry-After - 只有
Retry-After是 HTTP/1.1 规范强制要求在 429 响应中返回的(单位:秒) - 若不返回,前端重试逻辑只能靠猜或固定退避,容易引发雪崩
在 AppExceptionHandle 中手动添加 Retry-After
打开 app/exception/Handler.php(或你自定义的异常处理器),找到 render 方法,在检测到 429 时插入头:
public function render($request, Throwable $e): Response
{
if ($e instanceof HttpException && $e->getStatusCode() === 429) {
// 从缓存 Key 中提取剩余等待秒数(以 Redis 驱动为例)
$key = 'throttle:ip:' . $request->ip();
$expire = \think\Cache::store('redis')->getExpire($key);
$retryAfter = max(1, (int)$expire); // 至少 1 秒
return response()
->json(['message' => 'Too Many Requests'])
->code(429)
->header('Retry-After', $retryAfter)
->header('X-RateLimit-Limit', config('throttle.max_attempts', 60))
->header('X-RateLimit-Remaining', 0);
}
return parent::render($request, $e);
}
-
\think\Cache::store('redis')必须与你的限流驱动一致,若用memcached则需换对应 store -
getExpire()返回剩余 TTL(秒),但部分驱动(如 file)不支持,务必确认缓存驱动为redis或memcached - 不要硬编码
Retry-After值——它应动态反映当前窗口剩余时间,否则前端可能过早重试
闭包路由下 throttle 失效导致 429 根本不触发
如果你的限流中间件没生效,Retry-After 就无从谈起。常见原因是路由未绑定控制器方法:
立即学习“PHP免费学习笔记(深入)”;
-
Route::get('/api/test', function () { ... });→throttle不触发 -
Route::get('/api/test', 'Api/TestController@index');→ 正常触发 - 资源路由(
Route::resource())若未显式调用->middleware('throttle:60,1'),也不会启用 - 测试时别用
curl http://localhost/api/test,要加可信 IP 头:curl -H "X-Real-IP: 192.168.1.100" http://localhost/api/test
自定义限流中间件才能控制 key 粒度和 Retry-After 计算逻辑
默认 throttle:60,1 的 key 是 throttle:ip:123.123.123.123,所有接口共享计数——这会导致用户调用 /login 失败后,连 /health 都被锁。真正可用的方案是重写中间件:
- key 应含路由名:
throttle:route:api.login:123.123.123.123 - key 还可含用户 ID(登录后):
throttle:user:1001:api.order.create -
Retry-After应基于当前窗口到期时间计算,而非简单返回 TTL —— 因为 Redis 的EXPIRE是相对时间,而窗口是绝对时间(如每分钟重置) - 建议直接继承
think\middleware\Throttle并覆盖getCacheKey()和handle(),避免从零造轮子
限流不是加个中间件就完事;key 设计不合理、缓存驱动选错、异常头漏写,三者任一出问题,Retry-After 就只是个摆设。



















