解密下經常讓新人抓狂的 ThrottleRequests::addHeaders () 異常

Kamicloud發表於2019-10-22
[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 中介軟體完整的名稱空間是 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吧,知道返回資料就大概能猜到是哪裡來的資料了

為碼農摸魚事業而奮鬥

相關文章