Guzzle lançant RejectionException au lieu de ConnectionException sur le processus d'arrière-plan

9

J'ai des travaux qui s'exécutent sur plusieurs travailleurs de file d'attente, qui contiennent des requêtes HTTP utilisant Guzzle. Cependant, le bloc try-catch à l'intérieur de ce travail ne semble pas reprendre GuzzleHttp\Exception\RequestExceptionlorsque j'exécute ce travail en arrière-plan. Le processus en cours d'exécution est un php artisan queue:workemployé du système de file d'attente Laravel qui surveille la file d'attente et récupère les travaux.

Au lieu de cela, l'exception levée est l'une des GuzzleHttp\Promise\RejectionExceptionavec le message:

La promesse a été rejetée pour la raison suivante: erreur cURL 28: l'opération a expiré après 30001 millisecondes avec 0 octet reçu (voir https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Il s'agit en fait d'un déguisement GuzzleHttp\Exception\ConnectException(voir https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), car si j'exécute un travail similaire dans un processus PHP normal qui est déclenché par la visite d'un URL, je reçois le ConnectExceptioncomme prévu avec le message:

Erreur cURL 28: l'opération a expiré après 100 millisecondes avec 0 octet sur 0 reçu (voir https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Exemple de code qui déclencherait ce délai:

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

Le code ci-dessus renvoie un RejectionExceptionou ConnectExceptionlorsqu'il est exécuté dans le processus de travail, mais toujours un ConnectExceptionlorsqu'il est testé manuellement via le navigateur (d'après ce que je peux dire).

Donc, fondamentalement, ce que je dérive, c'est que cela RejectionExceptionenveloppe le message du ConnectException, mais je n'utilise pas les fonctionnalités asynchrones de Guzzle. Mes demandes se font simplement en série. La seule chose qui diffère est que plusieurs processus PHP peuvent effectuer des appels HTTP Guzzle ou que les travaux eux-mêmes expirent (ce qui devrait entraîner une exception différente pour Laravel Illuminate\Queue\MaxAttemptsExceededException), mais je ne vois pas comment cela provoque un comportement différent du code.

Je n'ai pas pu trouver de code à l'intérieur des packages Guzzle qui utilise php_sapi_name()/ PHP_SAPI(qui détermine l'interface utilisée) pour exécuter différentes choses lors de l'exécution à partir de la CLI par opposition à un déclencheur de navigateur.

tl; dr

Pourquoi Guzzle me lance-t-il RejectionExceptionsur mes processus de travail, mais ConnectExceptionsur des scripts PHP réguliers déclenchés par le navigateur?

Modifier 1

Malheureusement, je ne peux pas créer d'exemple reproductible minimal. Je vois de nombreux messages d'erreur dans mon outil de suivi des problèmes Sentry, à l'exception exacte indiquée ci-dessus. La source est indiquée comme Starting Artisan command: horizon:work(qui est Laravel Horizon, il supervise les files d'attente Laravel). J'ai vérifié à nouveau pour voir s'il y a une différence entre les versions de PHP, mais le site Web et les processus de travail exécutent le même PHP, 7.3.14ce qui est correct:

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

Edit 2 (trace de pile)

    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()fonction contient simplement un client Guzzle sur lequel j'appelle $client->request($request['method'], $request['url'], $request['options']);(donc je n'utilise pas requestAsync()). Je pense que cela a quelque chose à voir avec l'exécution de travaux en parallèle qui provoque ce problème.

Edit 3 (solution trouvée)

Considérez le testcase suivant qui fait une requête HTTP (qui devrait retourner une réponse 200 régulière):

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

Maintenant, ce que j'ai fait à l'origine était d'appeler, rejection_for($e->getMessage())ce qui crée le sien en RejectionExceptionfonction de la chaîne de message. Appeler rejection_for($e)était la bonne solution ici. Il ne reste plus qu'à répondre si cette rejection_forfonction est la même qu'une simple throw $e.

Flamme
la source
Quelle version de Guzzle utilisez-vous?
Vladimir
1
Quel pilote de file d'attente utilisez-vous pour laravel? Combien de travailleurs s'exécutent en parallèle sur l'instance / par instance? Avez-vous un middleware guzzle personnalisé en place (indice:) HandlerStack?
Christoph Kluge
Pouvez-vous fournir une trace de pile depuis Sentry?
Vladimir
@Vladimir ive a ajouté la trace de la pile. Je ne pense pas que cela vous aidera beaucoup. La façon dont les promesses sont implémentées dans Guzzle (et PHP en général) est difficile à lire.
Flame
1
@Flame pouvez-vous partager le middleware qui exécute la demande de sous-guzzle? Je suppose que le problème sera là. En attendant j'ajouterai une réponse reproductible à ma thèse.
Christoph Kluge

Réponses:

3

Bonjour, je voudrais savoir si vous rencontrez une erreur 4xx ou une erreur 5xx

Mais même ainsi, je vais mettre quelques alternatives pour les solutions trouvées qui ressemblent à votre problème

alternative 1

J'aimerais contourner cela, j'ai eu ce problème avec un nouveau serveur de production retournant 400 réponses inattendues par rapport à l'environnement de développement et de test fonctionnant comme prévu; simplement installer apt install php7.0-curl l'a corrigé.

C'était une toute nouvelle installation Ubuntu 16.04 LTS avec php installé via ppa: ondrej / php, pendant le débogage, j'ai remarqué que les en-têtes étaient différents. Tous deux envoyaient un formulaire en plusieurs parties avec des données bloquées, mais sans php7.0-curl, il envoyait un en-tête Connection: close plutôt que Expect: 100-Continue; dont les deux demandes avaient Transfer-Encoding: chunked.

  alternative 2

Vous devriez peut-être essayer ceci

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 besoin de cactching si le code de réponse n'est pas 200

alternative 3

Dans mon cas, c'était parce que j'avais passé un tableau vide dans les options $ de la requête ['json'] Je n'ai pas pu reproduire le 500 sur le serveur en utilisant Postman ou cURL même en passant l'en-tête de requête Content-Type: application / json.

Quoi qu'il en soit, la suppression de la clé json du tableau d'options de la requête a résolu le problème.

J'ai passé environ 30 minutes à essayer de comprendre ce qui ne va pas car ce comportement est très incohérent. Pour toutes les autres demandes que je fais, le passage de $ options ['json'] = [] n'a causé aucun problème. Ce pourrait être un problème de serveur, je ne contrôle pas le serveur.

envoyer des commentaires sur les détails obtenus

PauloBoaventura
la source
bon ... Pour avoir une réponse plus rapide et plus précise. J'ai pris l'initiative de poster la question sur la page du projet sur GitHub. J'espère que cela ne vous dérange pas github.com/guzzle/guzzle/issues/2599
PauloBoaventura
1
a ConnectExceptionn'a pas de réponse associée, donc il n'y a donc pas d'erreur 400 ou 500 à ma connaissance. Il semble que vous devriez réellement attraper BadResponseException(ou ClientException(4xx) / ServerException(5xx) qui sont tous les deux des enfants)
Flame
2

Guzzle utilise Promises pour les demandes synchrones et asynchrones. La seule différence est que lorsque vous utilisez une demande synchrone (votre cas) - elle est satisfaite immédiatement en appelant une wait() méthode . Notez cette partie:

Faire appel waità une promesse qui a été rejetée lèvera une exception. Si la raison du rejet est une instance de \Exceptionla raison est levée. Sinon, un GuzzleHttp\Promise\RejectionException est levé et la raison peut être obtenue en appelant la getReason méthode de l'exception.

Ainsi, il lance RequestExceptionce qui est une instance de \Exceptionet cela se produit toujours sur les erreurs HTTP 4xx et 5xx, sauf si le lancement d'exceptions est désactivé via les options. Comme vous le voyez, il peut également lancer un RejectionExceptionsi la raison n'est pas une instance de \Exceptionpar exemple si la raison est une chaîne qui semble se produire dans votre cas. La chose étrange est que vous obtenez RejectExceptionplutôt que RequestExceptionlorsque Guzzle lance une ConnectExceptionerreur de dépassement de délai de connexion. Quoi qu'il en soit, vous pouvez trouver une raison si vous parcourez votre RejectExceptiontrace de pile dans Sentry et trouvez où la reject()méthode est appelée sur Promise.

Vladimir
la source
1

Discussion avec l'auteur à l'intérieur de la section des commentaires pour commencer ma réponse:

Question:

Avez-vous un middleware guzzle personnalisé en place (indice: HandlerStack)?

Réponse de l'auteur:

Oui divers. Mais le middleware est fondamentalement un modificateur de demande / réponse, même les demandes de guzzle que j'y fais se font de manière synchrone.


Selon ceci voici ma thèse:

Vous avez un délai d'attente à l'intérieur de l'un de vos middleware qui appelle guzzle. Essayons donc d'implémenter un cas reproductible.

Ici, nous avons un middleware personnalisé qui appelle guzzle et renvoie un échec de rejet avec le message d'exception du sous-appel. C'est assez délicat, car en raison de la gestion interne des erreurs, il devient invisible à l'intérieur du 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);
        };
    };
}

Voici un exemple de test sur la façon dont vous pouvez l'utiliser:

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

Dès que je fais un test contre cela, je reçois

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

Il semble donc que votre appel principal ait échoué, mais en réalité, c'est le sous-appel qui a échoué.

Faites-moi savoir si cela vous aide à identifier votre problème spécifique. J'apprécierais également beaucoup que vous puissiez partager vos middlewares afin de déboguer un peu plus loin.

Christoph Kluge
la source
On dirait que tu as raison! J'appelais un rejection_for($e->getMessage())au lieu de rejection_for($e)quelque part dans ce middleware. Je cherchais la source d'origine du middleware par défaut (comme ici: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), mais je ne pouvais pas vraiment dire pourquoi il y en avait rejection_for($e)au lieu de throw $e. Il semble en cascade de la même manière selon mon testcase. Voir le post d'origine pour un testcase simplifié.
Flame
1
@Flame heureux d'avoir pu vous aider :) Selon votre deuxième question: s'il y a une différence entre eux. Eh bien, cela dépend vraiment du cas d'utilisation. Dans votre scénario spécifique, cela ne fera aucune différence (à l'exception de la classe d'exception utilisée) car vous n'avez que des appels uniques. Si vous envisagez de passer simultanément à des appels multiples et asynchrones, vous devriez envisager d'utiliser la promesse d'éviter les interruptions de code pendant que d'autres demandes sont toujours en cours d'exécution. Dans le cas où vous avez besoin de plus d'informations pour que ma réponse soit acceptée, faites-le moi savoir :)
Christoph Kluge
0

Bonjour, je n'ai pas compris si vous avez fini par résoudre votre problème ou non.

Eh bien, j'aimerais que vous publiez le journal des erreurs. Recherchez à la fois en PHP et dans le journal des erreurs de votre serveur

J'attends vos commentaires

PauloBoaventura
la source
1
L'exception est déjà publiée ci-dessus, il n'y a rien de plus à publier que le fait qu'elle provient d'un processus d'arrière-plan et de la ligne qui la lance $client->request('GET', ...)(juste un client habituel).
Flame
0

Comme cela se produit sporadiquement sur votre environnement et qu'il est difficile de répliquer en lançant le RejectionException(du moins je ne pouvais pas), pouvez-vous simplement ajouter un autre catchbloc à votre code, voir ci-dessous:

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

Il doit vous donner, ainsi qu'à nous, quelques idées sur les raisons et le moment où cela se produit.

Vladimir
la source
malheureusement non. J'ai obtenu le stacktrace dans Sentry parce que sans l'attraper, il atteint finalement le gestionnaire d'exception Laravel (et est envoyé à Sentry). La trace de la pile ne me pointe qu'au plus profond de la bibliothèque Guzzle mais je ne peux pas comprendre pourquoi elle suppose une promesse.
Flame
Voir ma autre réponse concernant les raisons pour lesquelles elle assume une promesse: stackoverflow.com/a/60498078/1568963
Vladimir