Guzzle lancia RejectionException invece di ConnectionException sul processo in background


9

Ho lavori eseguiti su più operatori di coda, che contengono alcune richieste HTTP tramite Guzzle. Tuttavia, il blocco try-catch all'interno di questo lavoro non sembra sollevarsi GuzzleHttp\Exception\RequestExceptionquando eseguo questi lavori nel processo in background. Il processo in esecuzione è un php artisan queue:worklavoratore del sistema di coda Laravel che controlla la coda e preleva i lavori.

Invece, l'eccezione generata è una delle seguenti GuzzleHttp\Promise\RejectionExceptioncon il messaggio:

La promessa è stata respinta con motivo: errore cURL 28: operazione scaduta dopo 30001 millisecondi con 0 byte ricevuti (vedere https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Questo è in realtà un travestimento GuzzleHttp\Exception\ConnectException(vedi https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), perché se eseguo un lavoro simile in un normale processo PHP che viene attivato visitando un URL, ottengo ConnectExceptioncome previsto con il messaggio:

Errore cURL 28: Operazione scaduta dopo 100 millisecondi con 0 byte su 0 ricevuti (vedere https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Codice di esempio che attiva questo timeout:

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

Il codice sopra genera un RejectionExceptiono ConnectExceptionquando viene eseguito nel processo di lavoro, ma sempre un ConnectExceptionquando testato manualmente attraverso il browser (da quello che posso dire).

Quindi, in sostanza, ciò che desidero è che questo RejectionExceptionsta avvolgendo il messaggio dal ConnectException, tuttavia non sto usando le funzionalità asincrone di Guzzle. Le mie richieste sono semplicemente fatte in serie. L'unica cosa che differisce è che più processi PHP potrebbero effettuare chiamate Guzzle HTTP o che i lavori stessi stanno scadendo (il che dovrebbe comportare un'eccezione diversa da quella di Laravel Illuminate\Queue\MaxAttemptsExceededException), ma non vedo come ciò causi un comportamento diverso del codice.

Non sono riuscito a trovare alcun codice all'interno dei pacchetti Guzzle che utilizza php_sapi_name()/ PHP_SAPI(che determina l'interfaccia utilizzata) per eseguire cose diverse quando si esegue dalla CLI anziché un trigger del browser.

tl; dr

Perché Guzzle mi lancia RejectionExceptionsui miei processi di lavoro, ma ConnectExceptionsu normali script PHP attivati ​​dal browser?

Modifica 1

Purtroppo non riesco a creare un esempio riproducibile minimo. Vedo molti messaggi di errore nel mio tracker dei problemi di Sentry, con l'esatta eccezione mostrata sopra. La fonte è dichiarata come Starting Artisan command: horizon:work(che è Laravel Horizon, supervisiona le code di Laravel). Ho controllato di nuovo per vedere se c'è una discrepanza tra le versioni di PHP, ma sia il sito Web che i processi di lavoro eseguono lo stesso PHP 7.3.14che è corretto:

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
  • La versione cURL è cURL 7.58.0.
  • La versione di Guzzle è guzzlehttp/guzzle 6.5.2
  • La versione di Laravel è laravel/framework 6.12.0

Modifica 2 (traccia stack)

    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

La Client::callRequest()funzione contiene semplicemente un client Guzzle su cui chiamo $client->request($request['method'], $request['url'], $request['options']);(quindi non sto usando requestAsync()). Penso che abbia qualcosa a che fare con l'esecuzione di lavori in parallelo che causa questo problema.

Modifica 3 (soluzione trovata)

Considera il seguente testcase che effettua una richiesta HTTP (che dovrebbe restituire una risposta 200 regolare):

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

Ora quello che ho fatto inizialmente era chiamare rejection_for($e->getMessage())che creava il proprio RejectionExceptionsulla base della stringa di messaggio. Chiamare rejection_for($e)era la soluzione corretta qui. L'unica cosa che resta da rispondere è se questa rejection_forfunzione è uguale a una semplice throw $e.


Quale versione di Guzzle usi?
Vladimir

1
Quale driver di coda usi per laravel? Quanti lavoratori corrono in parallelo sull'istanza / per istanza? Sono presenti dei middleware personalizzati per guzzle (suggerimento:) HandlerStack?
Christoph Kluge

Potete fornire una traccia dello stack da Sentry?
Vladimir

@Vladimir ive ha aggiunto la traccia dello stack. Non credo che ti aiuterà molto. Il modo in cui le promesse sono implementate in Guzzle (e PHP in generale) è difficile da leggere.
Fiamma

1
@Flame puoi condividere il middleware che esegue la richiesta del sub-guzzle? Immagino che il problema sarà lì. Nel frattempo aggiungerò una risposta riproducibile con la mia tesi.
Christoph Kluge

Risposte:


3

Ciao, vorrei sapere se si verificano errori 4xx o errori 5xx

Ma anche così metterò alcune alternative per soluzioni trovate che assomigliano al tuo problema

alternativa 1

Vorrei risolverlo, ho riscontrato questo problema con un nuovo server di produzione che restituiva 400 risposte inaspettate rispetto all'ambiente di sviluppo e test che funzionava come previsto; semplicemente installando apt install php7.0-curl è stato risolto.

Era una nuovissima installazione di Ubuntu 16.04 LTS con php installato tramite ppa: ondrej / php, durante il debug ho notato che le intestazioni erano diverse. Entrambi stavano inviando un modulo multiparte con dati ridimensionati, tuttavia senza php7.0-curl stava inviando una connessione: intestazione piuttosto che attesa: 100-Continua; entrambe le richieste di cui aveva Transfer-Encoding: chunked.

  alternativa 2

Forse dovresti provare questo

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

Il guzzle deve essere cactching se il codice di risposta non è 200

alternativa 3

Nel mio caso era perché avevo passato un array vuoto nell'opzione $ $ ['json'] della richiesta. Non riuscivo a riprodurre la 500 sul server usando Postman o cURL anche quando passavo l'intestazione della richiesta Content-Type: application / json.

In ogni caso, la rimozione della chiave json dall'array di opzioni della richiesta ha risolto il problema.

Ho impiegato circa 30 minuti a cercare di capire cosa c'è che non va perché questo comportamento è molto incoerente. Per tutte le altre richieste che sto facendo, passare $ options ['json'] = [] non ha causato problemi. Potrebbe essere un problema del server, non controllo il server.

inviare feedback sui dettagli ottenuti


bene ... per avere una risposta più rapida e accurata. Ho preso l'iniziativa di pubblicare la domanda nella pagina del progetto su GitHub. Spero non ti dispiaccia github.com/guzzle/guzzle/issues/2599
PauloBoaventura

1
a ConnectExceptionnon ha una risposta associata, quindi non ci sono errori 400 o 500 per quanto ne so. Sembra che dovresti effettivamente catturare BadResponseException(o ClientException(4xx) / ServerException(5xx) che sono entrambi figli di esso)
Fiamma


2

Guzzle utilizza Promises sia per richieste sincrone che asincrone. L'unica differenza è che quando usi la richiesta sincrona (il tuo caso), viene soddisfatta immediatamente chiamando un wait() metodo . Nota questa parte:

Chiamare waituna promessa che è stata respinta genererà un'eccezione. Se il motivo del rifiuto è un'istanza del \Exceptionmotivo viene generata. Altrimenti, GuzzleHttp\Promise\RejectionException viene generata una a e il motivo può essere ottenuto chiamando il getReason metodo dell'eccezione.

Quindi, genera RequestExceptionun'istanza di \Exceptione succede sempre su errori HTTP 4xx e 5xx, a meno che non vengano disabilitate eccezioni tramite opzioni. Come vedi, potrebbe anche lanciare un RejectionExceptionse il motivo non è un'istanza, \Exceptionad esempio se il motivo è una stringa che sembra accadere nel tuo caso. La cosa strana è che ottieni RejectExceptionpiuttosto che RequestExceptionquando Guzzle genera un ConnectExceptionerrore di timeout della connessione. Ad ogni modo, potresti trovare un motivo se RejectExceptionesegui la traccia dello stack in Sentry e scopri dove reject()viene chiamato il metodo su Promise.


1

Discussione con l'autore all'interno della sezione commenti come antipasto alla mia risposta:

Domanda:

Sono presenti dei middleware per guzzle personalizzati (suggerimento: HandlerStack)?

Risposta dell'autore:

Sì vari. Ma il middleware è fondamentalmente un modificatore di richiesta / risposta, anche le richieste guzzle che faccio lì vengono fatte in modo sincrono.


Secondo questo ecco la mia tesi:

Hai un timeout all'interno di uno dei tuoi middleware che chiama guzzle. Quindi proviamo a implementare un caso riproducibile.

Qui abbiamo un middleware personalizzato che chiama guzzle e restituisce un errore di rifiuto con il messaggio di eccezione della chiamata secondaria. È piuttosto complicato, perché a causa della gestione degli errori interni diventa invisibile all'interno dello stack-trace.

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

Questo è un esempio di test su come utilizzarlo:

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

Non appena eseguo un test contro questo, mi sto riprendendo

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

Quindi sembra che la tua chiamata al guzzle principale sia fallita, ma in realtà è la chiamata secondaria che non è riuscita.

Fammi sapere se questo ti aiuta a identificare il tuo problema specifico. Gradirei anche molto se tu potessi condividere i tuoi middleware per eseguire il debug un po 'più lontano.


Sembra che tu abbia ragione! Stavo chiamando un rejection_for($e->getMessage())anziché da rejection_for($e)qualche parte in quel middleware. Stavo guardando la fonte originale per il middleware predefinito (come qui: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), ma non riuscivo a capire perché ci fossero rejection_for($e)invece throw $e. Sembra in cascata allo stesso modo secondo il mio testcase. Vedi il post originale per un testcase semplificato.
Fiamma

1
@Flame contento di poterti aiutare :) Secondo la tua seconda domanda: se c'è una differenza tra di loro. Bene, dipende davvero dal caso d'uso. Nel tuo scenario specifico non farà alcuna differenza (tranne la classe di eccezione utilizzata) perché hai solo chiamate singole. Se si considera di passare a chiamate multiple e asincrone contemporaneamente, è consigliabile utilizzare la promessa per evitare interruzioni del codice mentre altre richieste sono ancora in esecuzione. Nel caso abbiate bisogno di maggiori informazioni per ottenere la mia risposta accettata, per favore fatemi sapere :)
Christoph Kluge

0

Ciao non ho capito se alla fine hai risolto il tuo problema o no.

Bene, vorrei che pubblichi qual è il registro degli errori. Cerca sia in PHP che nel registro degli errori del tuo server

Attendo tuoi commenti


1
L'eccezione è già pubblicata sopra, non c'è niente di più da postare che proviene da un processo in background e la linea che la lancia è $client->request('GET', ...)(solo un normale client guzzle).
Fiamma

0

Dato che ciò accade sporadicamente nel tuo ambiente ed è difficile replicarlo lanciando RejectionException(almeno non potrei), puoi semplicemente aggiungere un altro catchblocco al tuo codice, vedi sotto:

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

Deve dare a te e a noi alcune idee sul perché e quando ciò accade.


purtroppo no. Ho ricevuto lo stacktrace in Sentry perché senza prenderlo, alla fine raggiunge il gestore di eccezioni Laravel (e viene inviato a Sentry). La traccia dello stack mi indica solo in profondità nella libreria Guzzle ma non riesco a capire perché stia assumendo una promessa.
Fiamma

Vedi la mia altra risposta sul perché sta assumendo una promessa: stackoverflow.com/a/60498078/1568963
Vladimir
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.