解密下经常让新人抓狂的 ThrottleRequests::addHeaders () 异常

问题来源

[2019-10-22 15:32:09] testing.ERROR: Argument 1 passed to Illuminate\Routing\Middleware\ThrottleRequests::addHeaders() must be an instance of Symfony\Component\HttpFoundation\Response, array given, called in /var/www/vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php on line 62 

以前在社区里也回答过新人提出过关于这个异常的问题,但是当时都是根据以前处理问题的经验给了些问题的可能原因和解决思路,没有从根本上解决这个问题。这次调接口工具时正好遇到了这个问题,所以些一篇文章来讲解下问题原因和解决方法,希望不要有新人再被这个问题困扰。

以前别人提出的问题:

问答:Laravel5.8 API 接口请求,出现问题,烦请指教

什么是 throttle 中间件

throttle 中间件完整的命名空间是 Illuminate\Routing\Middleware\ThrottleRequests,功能是限制接口调用次数和频率,避免客户端错误或恶意攻击导致服务过载,也可以称作限流中间件

在laravel默认生成的项目中,该中间件以'throttle:60,1',的形式注册在api路由组中,也就是默认只会在api中使用限流的功能。

从默认的使用中就能看出来,这个中间件接收两个参数。第一个表示最大请求次数,第二个表示最大请求次数统计的时间长度。

    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1)
    {
        $key = $this->resolveRequestSignature($request);

        $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts);

        if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
            throw $this->buildException($key, $maxAttempts);
        }

        $this->limiter->hit($key, $decayMinutes * 60);

        $response = $next($request);

        return $this->addHeaders(
            $response, $maxAttempts,
            $this->calculateRemainingAttempts($key, $maxAttempts)
        );
    }

在第一次请求时会根据用户或ip进行一次sha1的哈希,以获得限流缓存的key,并为key增加一个timer缓存,缓存过期时间为开始时间+统计长度,在缓存过期前,访问的累计次数超过最大次数将被异常拒绝。

异常的来源

很显然throttle只是一个普通得不能再不同的中间件,即使认为他可能导致错误,也只能认为是触发限流规则导致的,但为什么会报错在addHeaders这个方法上呢?


    /**
     * Add the limit header information to the given response.
     *
     * @param  \Symfony\Component\HttpFoundation\Response  $response
     * @param  int  $maxAttempts
     * @param  int  $remainingAttempts
     * @param  int|null  $retryAfter
     * @return \Symfony\Component\HttpFoundation\Response
     */
    protected function addHeaders(Response $response, $maxAttempts, $remainingAttempts, $retryAfter = null)
    {
        $response->headers->add(
            $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter)
        );

        return $response;
    }

我们先来看下addHeaders,它的功能是给相应加上X-RateLimit-LimitX-RateLimit-Remaining两个响应头。要注意的是,如果我们想在这个方法中断点分析问题,是无法中断的。因为这个异常是因为传入参数和方法参数显式声明不服导致的。

Illuminate\Routing\Middleware\ThrottleRequests::addHeaders() must be an instance of Symfony\Component\HttpFoundation\Response, array given

也就是前边的handler中调用addHeaders时产生的错误。现在问题逐渐清晰了,也就是在 $response = $next($request); 中得到的$response不是Response类型,但throttle要求必须是Response类型。

这行代码是在几乎所有中间件中都出现过的代码,他和pipeline组合使handler既可以当前置中间件也可以当后置中间件。其他中间件没有出现这个问题是因为不需要限制返回类型为Response,而throttle这样做,是因为需要Response才能设置header。也就是说,要保证在throttle下一步中返回结果为Response类型。


        $response = $next($request);

        return $this->addHeaders(
            $response, $maxAttempts,
            $this->calculateRemainingAttempts($key, $maxAttempts)
        );

如何解决此类问题

既然我们需要响应结果为Response,难道把Controller return的数据都用response()->json($response) 包裹下就能解决吗?答案当然是无法解决,因为我们在正常请求时不会遇到这个问题。

正常的返回结果在pipeline中会prepareResponse,也就是会把任意返回结果推断为对应的Response

    /**
     * Static version of prepareResponse.
     *
     * @param  \Symfony\Component\HttpFoundation\Request  $request
     * @param  mixed  $response
     * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
     */
    public static function toResponse($request, $response)
    {
        if ($response instanceof Responsable) {
            $response = $response->toResponse($request);
        }

        if ($response instanceof PsrResponseInterface) {
            $response = (new HttpFoundationFactory)->createResponse($response);
        } elseif ($response instanceof Model && $response->wasRecentlyCreated) {
            $response = new JsonResponse($response, 201);
        } elseif (! $response instanceof SymfonyResponse &&
                   ($response instanceof Arrayable ||
                    $response instanceof Jsonable ||
                    $response instanceof ArrayObject ||
                    $response instanceof JsonSerializable ||
                    is_array($response))) {
            $response = new JsonResponse($response);
        } elseif (! $response instanceof SymfonyResponse) {
            $response = new Response($response);
        }

        if ($response->getStatusCode() === Response::HTTP_NOT_MODIFIED) {
            $response->setNotModified();
        }

        return $response->prepare($request);
    }

请留意上边函数的前三行,Responsable这个接口。

<?php

namespace Illuminate\Contracts\Support;

interface Responsable
{
    /**
     * Create an HTTP response that represents the object.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function toResponse($request);
}

我在异常基类中实现了这个接口,目的是抛出的异常可以直接以固定的格式返回,但是没有返回Response类型,导致遇到了throttle的错误,如果使用response->json包裹则可以修复异常。

要注意的是,异常响应和普通路由执行完成的响应流程是不同的,异常如果不实现Responsable接口,会被laravel默认的异常显式接管,也就是渲染出一个异常页面。如果实现了Responsable接口,返回类型要是Response类型,因为他不会被Router包裹响应再进入中间件的pipeline(普通路由进pipeline和出pipeline都会prepareResponse包裹一次)。

<?php

namespace Kamicloud\StubApi\Exceptions;

use Exception;
use Illuminate\Contracts\Support\Responsable;

class BaseException extends Exception implements Responsable
{
    protected $status;
    protected $message;

    public function __construct($message, $status)
    {
        $this->status = $status;
        $this->message = $message;
        parent::__construct($message, 0, $this);
    }

    public function getStatus()
    {
        return $this->status;
    }

    public function toResponse($request)
    {
        // 这里是需要修正的地方
        return [
            config('generator.keys.status', 'status') => $this->getStatus(),
            config('generator.keys.message', 'message') => $this->getMessage(),
            config('generator.keys.data', 'data') => null,
        ];
    }
}

直接看结论

所以遇到此类问题,我们大概可以从以下几个方面入手排查问题。

  1. 中间件返回类型是否保持Response,是否在中间件步骤中像Controller一样直接返回一个数组,可以从中间件执行顺序和优先级缩小排查范围。
  2. 是否已经跳出常规的路由处理,主要是异常抛出,并且返回的响应类型不是Response。
  3. 直接断点在throttle的handler吧,知道返回数据就大概能猜到是哪里来的数据了
本作品采用《CC 协议》,转载必须注明作者和本文链接
为码农摸鱼事业而奋斗
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 1

我还以为那个 60,1 都能用上呢,不过好像自己本地测试没被限制过,后面用 Nginx 限流了。

现在还比较菜,看不是很懂高级点的文章 😭

4年前 评论
Kamicloud (楼主) 4年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!