Жрет бросая RejectionException вместо ConnectionException в фоновом процессе

9

У меня есть задания, которые выполняются на нескольких работниках очереди, которые содержат некоторые HTTP-запросы с использованием Guzzle. Тем не менее, блок try-catch внутри этого задания, похоже, не срабатывает, GuzzleHttp\Exception\RequestExceptionкогда я запускаю это задание в фоновом процессе. php artisan queue:workРабочий процесс - это работник системы очереди Laravel, который следит за очередью и выбирает задания.

Вместо этого генерируется исключение GuzzleHttp\Promise\RejectionExceptionс сообщением:

Обещание было отклонено по причине: ошибка cURL 28: Тайм-аут операции после 30001 миллисекунды с получением 0 байтов (см. Https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Это на самом деле замаскированный GuzzleHttp\Exception\ConnectException(см. Https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), потому что если я запускаю аналогичную работу в обычном процессе PHP, который запускается при посещении URL, я получаю, ConnectExceptionкак и предполагалось с сообщением:

ошибка cURL 28: истекло время ожидания операции через 100 миллисекунд с получением 0 байтов из 0 (см. https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Пример кода, который вызовет этот тайм-аут:

try {
    $c = new \GuzzleHttp\Client([
        'timeout' => 0.1
    ]);
    $response = (string) $c->get('https://example.com')->getBody();
} catch(GuzzleHttp\Exception\RequestException $e) {
    // This occasionally gets catched when a ConnectException (child) is thrown,
    // but it doesnt happen with RejectionException because it is not a child
    // of RequestException.
}

Приведенный выше код выдает « RejectionExceptionили» ConnectExceptionпри запуске в рабочем процессе, но всегда ConnectExceptionпри проверке вручную через браузер (насколько я могу судить).

Итак, в основном я извлекаю из этого RejectionExceptionсообщение ConnectException, что я не использую асинхронные функции Guzzle. Мои запросы просто выполняются последовательно. Единственное, что отличается, заключается в том, что несколько процессов PHP могут выполнять HTTP-вызовы Guzzle или что сами задания задерживаются (что должно привести к другому исключению, являющемуся Laravel Illuminate\Queue\MaxAttemptsExceededException), но я не вижу, как это приводит к тому, что код ведет себя по-разному.

Я не смог найти какой-либо код внутри пакетов Guzzle, который использует php_sapi_name()/ PHP_SAPI(который определяет используемый интерфейс) для выполнения разных вещей при запуске из CLI, в отличие от триггера браузера.

ТЛ; др

Почему Guzzle запускает меня RejectionExceptionна моих рабочих процессах, а ConnectExceptionна обычных PHP-скриптах, запускаемых через браузер?

Редактировать 1

К сожалению, я не могу создать минимальный воспроизводимый пример. Я вижу много сообщений об ошибках в моем трекере ошибок Sentry, с точным исключением, показанным выше. Источник указан как Starting Artisan command: horizon:work(то есть Laravel Horizon, он контролирует очереди Laravel). Я еще раз проверил, есть ли расхождения между версиями PHP, но и веб-сайт, и рабочие процессы работают на одном и том же PHP, 7.3.14что правильно:

PHP 7.3.14-1+ubuntu18.04.1+deb.sury.org+1 (cli) (built: Jan 23 2020 13:59:16) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.14, Copyright (c) 1998-2018 Zend Technologies
    with Zend OPcache v7.3.14-1+ubuntu18.04.1+deb.sury.org+1, Copyright (c) 1999-2018, by Zend Technologies
  • Версия cURL есть cURL 7.58.0.
  • Версия жрет guzzlehttp/guzzle 6.5.2
  • Laravel версия laravel/framework 6.12.0

Редактировать 2 (трассировка стека)

    GuzzleHttp\Promise\RejectionException: The promise was rejected with reason: cURL error 28: Operation timed out after 30000 milliseconds with 0 bytes received (see https://curl.haxx.se/libcurl/c/libcurl-errors.html)
    #44 /vendor/guzzlehttp/promises/src/functions.php(112): GuzzleHttp\Promise\exception_for
    #43 /vendor/guzzlehttp/promises/src/Promise.php(75): GuzzleHttp\Promise\Promise::wait
    #42 /vendor/guzzlehttp/guzzle/src/Client.php(183): GuzzleHttp\Client::request
    #41 /app/Bumpers/Client.php(333): App\Bumpers\Client::callRequest
    #40 /app/Bumpers/Client.php(291): App\Bumpers\Client::callFunction
    #39 /app/Bumpers/Client.php(232): App\Bumpers\Client::bumpThread
    #38 /app/Models/Bumper.php(206): App\Models\Bumper::post
    #37 /app/Jobs/PostBumper.php(59): App\Jobs\PostBumper::handle
    #36 [internal](0): call_user_func_array
    #35 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(32): Illuminate\Container\BoundMethod::Illuminate\Container\{closure}
    #34 /vendor/laravel/framework/src/Illuminate/Container/Util.php(36): Illuminate\Container\Util::unwrapIfClosure
    #33 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(90): Illuminate\Container\BoundMethod::callBoundMethod
    #32 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(34): Illuminate\Container\BoundMethod::call
    #31 /vendor/laravel/framework/src/Illuminate/Container/Container.php(590): Illuminate\Container\Container::call
    #30 /vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php(94): Illuminate\Bus\Dispatcher::Illuminate\Bus\{closure}
    #29 /vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(130): Illuminate\Pipeline\Pipeline::Illuminate\Pipeline\{closure}
    #28 /vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(105): Illuminate\Pipeline\Pipeline::then
    #27 /vendor/laravel/framework/src/Illuminate/Bus/Dispatcher.php(98): Illuminate\Bus\Dispatcher::dispatchNow
    #26 /vendor/laravel/framework/src/Illuminate/Queue/CallQueuedHandler.php(83): Illuminate\Queue\CallQueuedHandler::Illuminate\Queue\{closure}
    #25 /vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(130): Illuminate\Pipeline\Pipeline::Illuminate\Pipeline\{closure}
    #24 /vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(105): Illuminate\Pipeline\Pipeline::then
    #23 /vendor/laravel/framework/src/Illuminate/Queue/CallQueuedHandler.php(85): Illuminate\Queue\CallQueuedHandler::dispatchThroughMiddleware
    #22 /vendor/laravel/framework/src/Illuminate/Queue/CallQueuedHandler.php(59): Illuminate\Queue\CallQueuedHandler::call
    #21 /vendor/laravel/framework/src/Illuminate/Queue/Jobs/Job.php(88): Illuminate\Queue\Jobs\Job::fire
    #20 /vendor/laravel/framework/src/Illuminate/Queue/Worker.php(354): Illuminate\Queue\Worker::process
    #19 /vendor/laravel/framework/src/Illuminate/Queue/Worker.php(300): Illuminate\Queue\Worker::runJob
    #18 /vendor/laravel/framework/src/Illuminate/Queue/Worker.php(134): Illuminate\Queue\Worker::daemon
    #17 /vendor/laravel/framework/src/Illuminate/Queue/Console/WorkCommand.php(112): Illuminate\Queue\Console\WorkCommand::runWorker
    #16 /vendor/laravel/framework/src/Illuminate/Queue/Console/WorkCommand.php(96): Illuminate\Queue\Console\WorkCommand::handle
    #15 /vendor/laravel/horizon/src/Console/WorkCommand.php(46): Laravel\Horizon\Console\WorkCommand::handle
    #14 [internal](0): call_user_func_array
    #13 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(32): Illuminate\Container\BoundMethod::Illuminate\Container\{closure}
    #12 /vendor/laravel/framework/src/Illuminate/Container/Util.php(36): Illuminate\Container\Util::unwrapIfClosure
    #11 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(90): Illuminate\Container\BoundMethod::callBoundMethod
    #10 /vendor/laravel/framework/src/Illuminate/Container/BoundMethod.php(34): Illuminate\Container\BoundMethod::call
    #9 /vendor/laravel/framework/src/Illuminate/Container/Container.php(590): Illuminate\Container\Container::call
    #8 /vendor/laravel/framework/src/Illuminate/Console/Command.php(201): Illuminate\Console\Command::execute
    #7 /vendor/symfony/console/Command/Command.php(255): Symfony\Component\Console\Command\Command::run
    #6 /vendor/laravel/framework/src/Illuminate/Console/Command.php(188): Illuminate\Console\Command::run
    #5 /vendor/symfony/console/Application.php(1012): Symfony\Component\Console\Application::doRunCommand
    #4 /vendor/symfony/console/Application.php(272): Symfony\Component\Console\Application::doRun
    #3 /vendor/symfony/console/Application.php(148): Symfony\Component\Console\Application::run
    #2 /vendor/laravel/framework/src/Illuminate/Console/Application.php(93): Illuminate\Console\Application::run
    #1 /vendor/laravel/framework/src/Illuminate/Foundation/Console/Kernel.php(131): Illuminate\Foundation\Console\Kernel::handle
    #0 /artisan(37): null

Client::callRequest()Функция содержит только клиент жрать , на котором я называю $client->request($request['method'], $request['url'], $request['options']);(так им не используется requestAsync()). Я думаю, что это как-то связано с параллельным выполнением заданий, что вызывает эту проблему.

Редактировать 3 (решение найдено)

Рассмотрим следующий тестовый пример, который делает HTTP-запрос (который должен возвращать обычный ответ 200):

        try {
            $c = new \GuzzleHttp\Client([
                'base_uri' => 'https://example.com'
            ]);
            $handler = $c->getConfig('handler');
            $handler->push(\GuzzleHttp\Middleware::mapResponse(function(ResponseInterface $response) {
                // Create a fake connection exception:
                $e = new \GuzzleHttp\Exception\ConnectException('abc', new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/2'));

                // These 2 lines both cascade as `ConnectException`:
                throw $e;
                return \GuzzleHttp\Promise\rejection_for($e);

                // This line cascades as a `RejectionException`:                
                return \GuzzleHttp\Promise\rejection_for($e->getMessage());
            }));
            $c->get('');
        } catch(\Exception $e) {
            var_dump($e);
        }

Теперь то, что я первоначально сделал, было вызвать, rejection_for($e->getMessage())который создает свой собственный RejectionExceptionна основе строки сообщения. Звонок rejection_for($e)был правильным решением здесь. Осталось ответить только, если эта rejection_forфункция такая же, как и простая throw $e.

пламя
источник
Какую версию Guzzle вы используете?
Владимир
1
Какой драйвер очереди вы используете для laravel? Сколько рабочих работает параллельно на экземпляре / на экземпляр? У вас есть пользовательское промежуточное программное обеспечение жвачки на месте (подсказка HandlerStack)?
Кристоф Клюге
Можете ли вы предоставить трассировку стека от Sentry?
Владимир
@Vladimir ive добавил трассировку стека. Я не думаю, что это сильно вам поможет. Способ реализации обещаний в Guzzle (и PHP в целом) сложен для чтения.
Пламя
1
@Flame Можете ли вы поделиться промежуточным программным обеспечением, которое выполняет запрос sub-guzzle? Я думаю, что проблема будет там. А пока я добавлю воспроизводимый ответ своим тезисом.
Кристоф Клюге

Ответы:

3

Здравствуйте, я хотел бы знать, если у вас ошибка 4xx или ошибка 5xx

Но даже в этом случае я приведу несколько альтернатив для найденных решений, которые напоминают вашу проблему

альтернатива 1

Я хотел бы это устранить, у меня была проблема с новым рабочим сервером, который возвращал неожиданные 400 ответов по сравнению со средой разработки и тестирования, работающей как ожидалось; простая установка apt install php7.0-curl это исправило.

Это была новая установка Ubuntu 16.04 LTS с php, установленным через ppa: ondrej / php, во время отладки я заметил, что заголовки были разными. Оба отправляли форму, состоящую из нескольких частей, с проверенными данными, однако без php7.0-curl она отправляла заголовок Connection: close, а не Expect: 100-Continue; оба запроса которых имели Transfer-Encoding: chunked.

  альтернатива 2

Может быть, вы должны попробовать это

try {
$client = new Client();
$guzzleResult = $client->put($url, [
    'body' => $postString
]);
} catch (\GuzzleHttp\Exception\RequestException $e) {
$guzzleResult = $e->getResponse();
}

var_export($guzzleResult->getStatusCode());
var_export($guzzleResult->getBody());

Жрать нужно кактус, если код ответа не 200

альтернатива 3

В моем случае это было потому, что я передал пустой массив в параметрах $ option ['json'], я не мог воспроизвести 500 на сервере, используя Postman или cURL, даже когда передавал заголовок запроса Content-Type: application / json.

В любом случае, удаление ключа json из массива параметров запроса решило проблему.

Я потратил около 30 минут, пытаясь понять, что не так, потому что это поведение очень противоречиво. Для всех других запросов, которые я делаю, передача $ options ['json'] = [] не вызывала проблем. Это может быть проблема с сервером, но я не контролирую сервер.

отправить отзыв о полученных данных

PauloBoaventura
источник
хорошо ... Чтобы получить более быстрый и точный ответ. Я взял на себя инициативу опубликовать вопрос на странице проекта на GitHub. Я надеюсь, что вы не против github.com/guzzle/guzzle/issues/2599
PauloBoaventura
1
У a ConnectExceptionнет связанного ответа, поэтому, насколько я знаю, нет ошибки 400 или 500. Похоже, что вы должны ловить BadResponseException(или ClientException(4xx) / ServerException(5xx), которые оба являются его детьми)
Flame
2

Guzzle использует Promises для синхронных и асинхронных запросов. Разница лишь в том, что когда вы используете синхронный запрос (ваш случай) - он выполняется сразу же, вызывая wait() метод . Обратите внимание на эту часть:

При вызове waitобещания, которое было отклонено, возникнет исключение. Если причина отклонения является экземпляром \Exceptionпричины выбрасывается. В противном случае генерируется a, GuzzleHttp\Promise\RejectionException и причина может быть получена путем вызова getReason метода исключения.

Таким образом, он выбрасывает, RequestExceptionкоторый является экземпляром, \Exceptionи это всегда происходит при ошибках HTTP 4xx и 5xx, если исключение не исключено с помощью опций. Как вы видите, он также может выдать, RejectionExceptionесли причина не является примером, \Exceptionнапример, если причина - строка, которая, кажется, происходит в вашем случае. Странная вещь заключается в том, что вы получаете, RejectExceptionа не RequestExceptionкак Guzzle бросает ConnectExceptionна ошибку времени ожидания подключения. В любом случае, вы можете найти причину, если просмотрите RejectExceptionтрассировку стека в Sentry и узнаете, где reject()метод вызывается в Promise.

Владимир
источник
1

Обсуждение с автором в разделе комментариев как начало моего ответа:

Вопрос:

У вас есть собственное промежуточное программное обеспечение для жретки (подсказка: HandlerStack)?

Ответ автора:

Да разные. Но промежуточное ПО - это, по сути, модификатор запроса / ответа, даже те жадные запросы, которые я выполняю, выполняются синхронно.


В соответствии с этим вот мой тезис:

У вас есть тайм-аут в одном из ваших промежуточных программ, который вызывает жрет. Итак, давайте попробуем реализовать воспроизводимый случай.

Здесь у нас есть специальное промежуточное программное обеспечение, которое вызывает guzzle и возвращает ошибку отказа с сообщением об исключении из дополнительного вызова. Это довольно сложно, потому что из-за внутренней обработки ошибок он становится невидимым внутри трассировки стека.

function custom_middleware(string $baseUri = 'http://127.0.0.1:8099', float $timeout = 0.2)
{
    return function (callable $handler) use ($baseUri, $timeout) {
        return function ($request, array $options) use ($handler, $baseUri, $timeout) {
            try {
                $client = new GuzzleHttp\Client(['base_uri' => $baseUri, 'timeout' => $timeout,]);
                $client->get('/a');
            } catch (Exception $exception) {
                return \GuzzleHttp\Promise\rejection_for($exception->getMessage());
            }
            return $handler($request, $options);
        };
    };
}

Это тестовый пример, как вы можете использовать его:

$baseUri = 'http://127.0.0.1:8099'; // php -S 127.0.0.1:8099 test.php << includes a simple sleep(10); statement
$timeout = 0.2;

$handler = \GuzzleHttp\HandlerStack::create();
$handler->push(custom_middleware($baseUri, $timeout));

$client = new Client([
    'handler' => $handler,
    'base_uri' => $baseUri,
]);

try {
    $response = $client->get('/b');
} catch (Exception $exception) {
    var_dump(get_class($exception), $exception->getMessage());
}

Как только я выполню тест против этого, я получаю

$ php test2.php 
string(37) "GuzzleHttp\Promise\RejectionException"
string(174) "The promise was rejected with reason: cURL error 28: Operation timed out after 202 milliseconds with 0 bytes received (see https://curl.haxx.se/libcurl/c/libcurl-errors.html)"

Таким образом, похоже, что ваш основной вызов не прошел, но на самом деле это дополнительный вызов.

Дайте мне знать, если это поможет вам определить вашу конкретную проблему. Я также был бы очень признателен, если бы вы могли поделиться своим промежуточным программным обеспечением, чтобы отладить это немного дальше.

Кристоф Клюге
источник
Похоже, ты прав! Я звонил, а rejection_for($e->getMessage())не rejection_for($e)где-то в этом промежуточном программном обеспечении. Я искал исходный источник для промежуточного ПО по умолчанию (например, здесь: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), но не мог сказать, почему они были rejection_for($e)вместо throw $e. Похоже, так же, как в моем тестовом примере. Смотрите оригинальный пост для упрощенного теста.
Пламя
1
@ Flame рад, что смог помочь вам :) По второму вопросу: если между ними есть разница. Ну, это действительно зависит от варианта использования. В вашем конкретном сценарии это не будет иметь никакого значения (кроме используемого класса исключений), потому что у вас есть только одиночные вызовы. Если вы решите переключиться на множественные и асинхронные вызовы одновременно, то вам следует рассмотреть возможность использования обещания, чтобы избежать прерываний кода, пока другие запросы еще выполняются. Если вам нужно больше информации, чтобы мой ответ был принят, пожалуйста, дайте мне знать :)
Кристоф Клюге
0

Здравствуйте, я не понял, в конечном итоге вы решили свою проблему или нет.

Ну, я хотел бы, чтобы вы опубликовали, что такое журнал ошибок. Поиск как в PHP, так и в журнале ошибок вашего сервера

Я жду вашего отзыва

PauloBoaventura
источник
1
Исключение уже опубликовано выше, больше ничего не нужно публиковать, кроме того, что оно исходит из фонового процесса и строки, которая его выдает $client->request('GET', ...)(просто обычный клиент-жрет).
Пламя
0

Поскольку это происходит спорадически в вашей среде, и его трудно воспроизвести, бросая RejectionException(по крайней мере, я не мог), вы можете просто добавить еще один catchблок в ваш код, см. Ниже:

try {
    $c = new \GuzzleHttp\Client([
        'timeout' => 0.1
    ]);
    $response = (string) $c->get('https://example.com')->getBody();
} catch (GuzzleHttp\Promise\RejectionException $e) {
    // Log the output of $e->getTraceAsString();
} catch(GuzzleHttp\Exception\RequestException $e) {
    // This occasionally gets catched when a ConnectException (child) is thrown,
    // but it doesnt happen with RejectionException because it is not a child
    // of RequestException.
}

Это должно дать вам и нам несколько идей о том, почему и когда это происходит.

Владимир
источник
к сожалению это не так. Я получил стековую трассировку в Sentry, потому что, не перехватывая ее, в конце концов она достигает обработчика Laravel Exception (и отправляется в Sentry). Трассировка стека только указывает мне глубоко в библиотеку Guzzle, но я не могу понять, почему она предполагает обещание.
Пламя
Посмотрите мой другой ответ относительно того, почему он принимает обещание: stackoverflow.com/a/60498078/1568963
Владимир