Guzzle jogando RejectionException em vez de ConnectionException no processo em segundo plano

9

Tenho trabalhos executados em vários trabalhadores de fila, que contêm algumas solicitações HTTP usando o Guzzle. No entanto, o bloco try-catch dentro deste trabalho não parece ser ativado GuzzleHttp\Exception\RequestExceptionquando estou executando esses trabalhos no processo em segundo plano. O processo em execução é um php artisan queue:worktrabalhador do sistema de filas do Laravel que monitora a fila e seleciona os trabalhos.

Em vez disso, a exceção lançada é uma das GuzzleHttp\Promise\RejectionExceptioncom a mensagem:

A promessa foi rejeitada pelo motivo: erro cURL 28: A operação atingiu o tempo limite após 30001 milissegundos com 0 bytes recebidos (consulte https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Na verdade, isso é um disfarçado GuzzleHttp\Exception\ConnectException(consulte https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), porque se eu executar um trabalho semelhante em um processo PHP regular que é acionado visitando um URL, recebo o ConnectExceptionpretendido com a mensagem:

erro cURL 28: A operação atingiu o tempo limite após 100 milissegundos com 0 de 0 bytes recebidos (consulte https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Código de exemplo que acionaria esse tempo limite:

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.
}

O código acima gera um RejectionExceptionou ConnectExceptionquando executado no processo de trabalho, mas sempre um ConnectExceptionquando testado manualmente através do navegador (pelo que sei).

Então, basicamente, o que deduzo é que isso RejectionExceptionestá envolvendo a mensagem doConnectException , no entanto, não estou usando os recursos assíncronos do Guzzle. Meus pedidos são simplesmente feitos em série. A única coisa que difere é que vários processos PHP podem estar fazendo chamadas HTTP do Guzzle ou que os trabalhos em si estão atingindo o tempo limite (o que deve resultar em uma exceção diferente da do Laravel Illuminate\Queue\MaxAttemptsExceededException), mas não vejo como isso faz com que o código se comporte de maneira diferente.

Não consegui encontrar nenhum código dentro dos pacotes Guzzle que esteja usando php_sapi_name()/PHP_SAPI (que determina a interface usada) para executar coisas diferentes ao executar a partir da CLI em vez de um gatilho do navegador.

tl; dr

Por que o Guzzle me joga RejectionExceptionnos processos de trabalho, mas ConnectExceptionnos scripts PHP regulares acionados pelo navegador?

Editar 1

Infelizmente, não posso criar um exemplo mínimo reproduzível. Vejo muitas mensagens de erro no rastreador de problemas do Sentry, com a exceção exata mostrada acima. A fonte é declarada como Starting Artisan command: horizon:work(que é o Laravel Horizon, supervisiona as filas do Laravel). Eu verifiquei novamente para ver se há uma discrepância entre as versões do PHP, mas os processos do site e do trabalhador executam o mesmo PHP 7.3.14que está correto:

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
  • A versão cURL é cURL 7.58.0.
  • A versão Guzzle é guzzlehttp/guzzle 6.5.2
  • A versão do Laravel é laravel/framework 6.12.0

Editar 2 (rastreamento de pilha)

    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

A Client::callRequest()função contém simplesmente um Guzzle Client no qual eu chamo $client->request($request['method'], $request['url'], $request['options']);(então não estou usando requestAsync()). Eu acho que tem algo a ver com a execução de trabalhos em paralelo que causa esse problema.

Editar 3 (solução encontrada)

Considere o seguinte caso de teste que faz uma solicitação HTTP (que deve retornar uma resposta 200 regular):

        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);
        }

Agora, o que eu fiz originalmente foi chamar, rejection_for($e->getMessage())que cria o seu próprio com RejectionExceptionbase na cadeia de mensagens. Ligar rejection_for($e)foi a solução correta aqui. A única coisa que resta a responder é se essa rejection_forfunção é igual a uma simples throw $e.

Chama
fonte
Qual versão do Guzzle você usa?
Vladimir
11
Qual driver de fila você usa para o laravel? Quantos trabalhadores estão executando paralelamente na instância / por instância? Você possui um middleware personalizado do guzzle (dica:) HandlerStack?
Christoph Kluge
Você pode fornecer um rastreamento de pilha do Sentry?
Vladimir
@Vladimir ive adicionou o rastreamento de pilha. Eu não acho que isso vai te ajudar muito. A maneira como as promessas são implementadas no Guzzle (e no PHP em geral) é difícil de ler.
Chama
11
@Flame, você pode compartilhar o middleware que executa a solicitação de sub-quebra-cabeça? Eu acho que o problema estará lá. Enquanto isso, adicionarei uma resposta reproduzível em minha tese.
Christoph Kluge

Respostas:

3

Olá Gostaria de saber se você está tendo o erro 4xx ou o erro 5xx

Mas mesmo assim vou colocar algumas alternativas para soluções encontradas que se assemelham ao seu problema

alternativa 1

Gostaria de dar uma olhada nisso, tive esse problema com um novo servidor de produção retornando 400 respostas inesperadas em comparação com o ambiente de desenvolvimento e teste funcionando conforme o esperado; simplesmente instalar o apt install php7.0-curl o corrigiu.

Foi uma nova instalação do Ubuntu 16.04 LTS com php instalado via ppa: ondrej / php, durante a depuração notei que os cabeçalhos eram diferentes. Ambos estavam enviando um formulário de várias partes com dados agrupados, no entanto, sem o php7.0-curl, estavam enviando um cabeçalho Connection: close ao invés do Expect: 100-Continue; os dois pedidos tiveram Transfer-Encoding: chunked.

  alternativa 2

Talvez você deva tentar isso

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());

Guzzle precisa cactching se o código de resposta não for 200

alternativa 3

No meu caso, foi porque eu havia passado uma matriz vazia nas opções $ da solicitação ['json'] e não consegui reproduzir o 500 no servidor usando Postman ou cURL, mesmo ao passar o cabeçalho Content-Type: application / json request.

De qualquer forma, remover a chave json da matriz de opções da solicitação resolveu o problema.

Passei 30 minutos tentando descobrir o que havia de errado, porque esse comportamento é muito inconsistente. Para todas as outras solicitações que estou fazendo, passar $ options ['json'] = [] não causou nenhum problema. Pode ser um problema do servidor, não controlo o servidor.

envie feedback sobre os detalhes obtidos

PauloBoaventura
fonte
bom ... Para ter uma resposta mais rápida e precisa. Tomei a iniciativa de postar a pergunta na página do projeto no GitHub. Espero que você não se importe github.com/guzzle/guzzle/issues/2599
PauloBoaventura 27/02
11
a ConnectExceptionnão tem uma resposta associada; portanto, não há erros de 400 ou 500 até onde sei. Parece que você realmente deveria estar pegando BadResponseException(ou ClientException(4xx) / ServerException(5xx), que são ambos filhos dele)
Flame
github.com/guzzle/guzzle/pull/2541
PauloBoaventura 27/02
2

O Guzzle usa o Promises para solicitações síncronas e assíncronas. A única diferença é que, quando você usa uma solicitação síncrona (seu caso) - ela é atendida imediatamente chamando um wait() método . Observe esta parte:

Invocar waituma promessa que foi rejeitada lançará uma exceção. Se o motivo da rejeição \Exceptionfor lançada, uma instância do motivo. Caso contrário, a GuzzleHttp\Promise\RejectionException é lançada e o motivo pode ser obtido chamando o getReason método da exceção.

Portanto, ele lança RequestExceptionqual é uma instância \Exceptione sempre ocorre nos erros HTTP 4xx e 5xx, a menos que o lançamento de exceções seja desativado por meio de opções. Como você vê, também pode gerar a RejectionExceptionse o motivo não for uma instância de, \Exceptionpor exemplo, se o motivo for uma string que parece acontecer no seu caso. O estranho é que você recebe, em RejectExceptionvez de RequestExceptionGuzzle, lançar um ConnectExceptionerro de tempo limite de conexão. De qualquer forma, você pode encontrar um motivo se passar pelo RejectExceptionrastreamento da pilha no Sentry e descobrir onde o reject()método é chamado no Promise.

Vladimir
fonte
1

Discussão com o autor dentro da seção de comentários como entrada para minha resposta:

Questão:

Você possui um middleware personalizado do guzzle (dica: HandlerStack)?

Resposta do autor:

Sim vários. Mas o middleware é basicamente um modificador de solicitação / resposta, mesmo os pedidos de guzzle que faço lá são feitos de forma síncrona.


De acordo com isso, aqui está minha tese:

Você tem um tempo limite dentro de um dos seus middlewares que chama guzzle. Então, vamos tentar implementar um caso reproduzível.

Aqui, temos um middleware personalizado que chama guzzle e retorna uma falha de rejeição com a mensagem de exceção da sub-chamada. É bem complicado, porque devido ao tratamento interno de erros, ele fica invisível dentro do rastreamento de pilha.

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);
        };
    };
}

Este é um exemplo de teste de como você pode usá-lo:

$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());
}

Assim que eu realizar um teste contra isso, estou recebendo

$ 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)"

Parece que a sua chamada principal do guzzle falhou, mas na realidade é a sub-chamada que falhou.

Deixe-me saber se isso ajuda você a identificar seu problema específico. Também agradeceria se você pudesse compartilhar seus middlewares para depurar isso um pouco mais.

Christoph Kluge
fonte
Parece que você está certo! Eu estava chamando um em rejection_for($e->getMessage())vez de rejection_for($e)algum lugar nesse middleware. Eu estava olhando para a fonte original para middleware padrão (como aqui: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), mas não poderia muito dizer por que havia rejection_for($e)em vez de throw $e. Parece cascata da mesma maneira, de acordo com o meu caso de teste. Veja a postagem original para um caso de teste simplificado.
Chama
11
@ Chama feliz que eu poderia ajudá-lo :) De acordo com sua segunda pergunta: se houver uma diferença entre eles. Bem, é realmente até o caso de uso. No seu cenário específico, não fará diferença (exceto a classe de exceção usada), porque você tem apenas chamadas únicas. Se você considerar mudar para várias chamadas assíncronas e ao mesmo tempo, considere usar a promessa para evitar interrupções de código enquanto outras solicitações ainda estiverem em execução. Caso precise de mais informações para que minha resposta seja aceita, avise-me :)
Christoph Kluge
0

Olá eu não entendi se você acabou resolvendo seu problema ou não.

Bem, eu gostaria que você publicasse qual é o log de erros. Pesquise no PHP e no log de erros do seu servidor

Aguardo seu feedback

PauloBoaventura
fonte
11
A exceção já foi postada acima, não há mais nada a postar do que o resultado de um processo em segundo plano e a linha que o lança $client->request('GET', ...)(apenas um cliente guzzle regular).
Chama
0

Como isso acontece esporadicamente no seu ambiente e é difícil replicar o lançamento do RejectionException(pelo menos eu não poderia), você pode simplesmente adicionar outro catchbloco ao seu código, veja abaixo:

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.
}

Ele deve nos dar algumas idéias sobre por que e quando isso acontece.

Vladimir
fonte
infelizmente isso não acontece. Consegui o stacktrace no Sentry porque, sem pegá-lo, ele finalmente alcança o manipulador do Laravel Exception (e é enviado ao Sentry). O rastreamento da pilha apenas me aponta profundamente na biblioteca Guzzle, mas não consigo entender por que está assumindo uma promessa.
Chama
Veja minha outra resposta sobre por que está assumindo uma promessa: stackoverflow.com/a/60498078/1568963
Vladimir