Guzzle löst RejectionException anstelle von ConnectionException im Hintergrundprozess aus

9

Ich habe Jobs, die auf mehreren Warteschlangenarbeitern ausgeführt werden und einige HTTP-Anforderungen mit Guzzle enthalten. Der Try-Catch-Block in diesem Job scheint jedoch nicht zu funktionieren, GuzzleHttp\Exception\RequestExceptionwenn ich diesen Job im Hintergrund ausführe. Der laufende Prozess ist ein php artisan queue:workLaravel-Warteschlangensystemarbeiter, der die Warteschlange überwacht und die Jobs aufnimmt.

Stattdessen wird eine Ausnahme GuzzleHttp\Promise\RejectionExceptionmit der folgenden Meldung ausgelöst :

Das Versprechen wurde mit folgendem Grund abgelehnt: cURL-Fehler 28: Zeitüberschreitung nach 30001 Millisekunden mit 0 empfangenen Bytes (siehe https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Dies ist eigentlich eine Verkleidung GuzzleHttp\Exception\ConnectException(siehe https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), denn wenn ich einen ähnlichen Job in einem regulären PHP-Prozess ausführe, der durch den Besuch eines ausgelöst wird URL, ich bekomme die ConnectExceptionwie vorgesehen mit der Nachricht:

cURL-Fehler 28: Zeitüberschreitung des Vorgangs nach 100 Millisekunden mit 0 von 0 empfangenen Bytes (siehe https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Beispielcode, der dieses Timeout auslösen würde:

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

Der obige Code löst entweder ein RejectionExceptionoder ein, ConnectExceptionwenn es im Worker-Prozess ausgeführt wird, aber immer ein, ConnectExceptionwenn es manuell über den Browser getestet wird (soweit ich das beurteilen kann).

Im Grunde genommen leite ich daraus ab, dass dies RejectionExceptiondie Nachricht von der ConnectExceptionumschließt, ich jedoch nicht die asynchronen Funktionen von Guzzle verwende. Meine Anfragen werden einfach in Serie gemacht. Das einzige, was sich unterscheidet, ist, dass mehrere PHP-Prozesse möglicherweise Guzzle-HTTP-Aufrufe ausführen oder dass die Jobs selbst eine Zeitüberschreitung aufweisen (was zu einer anderen Ausnahme führen sollte als bei Laravel Illuminate\Queue\MaxAttemptsExceededException), aber ich sehe nicht, wie sich der Code dadurch anders verhält.

Ich konnte keinen Code in den Guzzle-Paketen finden, der php_sapi_name()/ PHP_SAPI(der die verwendete Schnittstelle bestimmt) verwendet, um andere Dinge auszuführen, wenn er über die CLI ausgeführt wird, im Gegensatz zu einem Browser-Trigger.

tl; dr

Warum wirft Guzzle mich RejectionExceptionauf meine Arbeitsprozesse, aber ConnectExceptionauf normale PHP-Skripte, die über den Browser ausgelöst werden?

Bearbeiten 1

Leider kann ich kein minimal reproduzierbares Beispiel erstellen. Ich sehe viele Fehlermeldungen in meinem Sentry Issue Tracker, mit der genauen Ausnahme, die oben gezeigt wird. Die Quelle wird angegeben als Starting Artisan command: horizon:work(Laravel Horizon überwacht die Laravel-Warteschlangen). Ich habe erneut überprüft, ob zwischen den PHP-Versionen eine Diskrepanz besteht, aber sowohl auf der Website als auch in den Arbeitsprozessen wird dasselbe PHP ausgeführt, 7.3.14was korrekt ist:

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
  • Die cURL-Version ist cURL 7.58.0.
  • Guzzle-Version ist guzzlehttp/guzzle 6.5.2
  • Laravel Version ist laravel/framework 6.12.0

Bearbeiten 2 (Stack-Trace)

    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

Die Client::callRequest()Funktion enthält einfach einen Guzzle Client, auf dem ich anrufe $client->request($request['method'], $request['url'], $request['options']);(also benutze ich nicht requestAsync()). Ich denke, es hat etwas mit dem parallelen Ausführen von Jobs zu tun, das dieses Problem verursacht.

Edit 3 (Lösung gefunden)

Betrachten Sie den folgenden Testfall, der eine HTTP-Anfrage stellt (die eine reguläre 200-Antwort zurückgeben sollte):

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

Was ich ursprünglich getan habe, war ein Aufruf, rejection_for($e->getMessage())der RejectionExceptionbasierend auf der Nachrichtenzeichenfolge eine eigene erstellt . Anrufen rejection_for($e)war hier die richtige Lösung. Es bleibt nur zu antworten, ob diese rejection_forFunktion mit einer einfachen identisch ist throw $e.

Flamme
quelle
Welche Guzzle-Version verwenden Sie?
Vladimir
1
Welchen Warteschlangentreiber verwenden Sie für Laravel? Wie viele Worker werden parallel auf der Instanz / pro Instanz ausgeführt? Haben Sie benutzerdefinierte Guzzle-Middleware installiert (Hinweis :) HandlerStack?
Christoph Kluge
Können Sie eine Stapelverfolgung von Sentry bereitstellen?
Vladimir
@Vladimir ive hat den Stack-Trace hinzugefügt. Ich glaube nicht, dass es dir viel helfen wird. Die Art und Weise, wie Versprechen in Guzzle (und PHP im Allgemeinen) implementiert werden, ist schwer zu lesen.
Flamme
1
@Flame können Sie die Middleware freigeben, die die Sub-Guzzle-Anforderung ausführt? Ich denke, das Problem wird da sein. In der Zwischenzeit werde ich meiner These eine reproduzierbare Antwort hinzufügen.
Christoph Kluge

Antworten:

3

Hallo, ich würde gerne wissen, ob Sie Fehler 4xx oder Fehler 5xx haben

Trotzdem werde ich einige Alternativen für Lösungen nennen, die Ihrem Problem ähneln

Alternative 1

Ich möchte dieses Problem lösen. Ich hatte dieses Problem mit einem neuen Produktionsserver, der unerwartete 400 Antworten zurückgab, verglichen mit der Entwicklungs- und Testumgebung, die wie erwartet funktioniert. einfach install apt installieren php7.0-curl behoben.

Es war eine brandneue Ubuntu 16.04 LTS-Installation mit PHP, die über ppa: ondrej / php installiert wurde. Beim Debuggen stellte ich fest, dass die Header unterschiedlich waren. Beide sendeten ein mehrteiliges Formular mit eingespannten Daten, aber ohne php7.0-curl sendete es einen Connection: close-Header anstelle des Expect: 100-Continue; Beide Anforderungen hatten Transfer-Encoding: Chunked.

  Alternative 2

Vielleicht solltest du das versuchen

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

Die Guzzle muss geschaltet werden, wenn der Antwortcode nicht 200 ist

Alternative 3

In meinem Fall, weil ich ein leeres Array in den $ options ['json'] der Anfrage übergeben hatte, konnte ich die 500 auf dem Server nicht mit Postman oder cURL reproduzieren, selbst wenn der Header Content-Type: application / json request übergeben wurde.

Das Entfernen des JSON-Schlüssels aus dem Optionsarray der Anforderung löste das Problem.

Ich habe ungefähr 30 Minuten damit verbracht herauszufinden, was falsch ist, weil dieses Verhalten sehr inkonsistent ist. Bei allen anderen Anfragen, die ich mache, verursachte das Übergeben von $ options ['json'] = [] keine Probleme. Es könnte ein Serverproblem sein, aber ich kontrolliere den Server nicht.

Senden Sie Feedback zu den erhaltenen Details

PauloBoaventura
quelle
gut ... Um eine schnellere und genauere Antwort zu haben. Ich habe die Initiative ergriffen, um die Frage auf der Projektseite auf GitHub zu veröffentlichen. Ich hoffe, es macht Ihnen nichts aus, github.com/guzzle/guzzle/issues/2599
PauloBoaventura
1
a ConnectExceptionist keine Antwort zugeordnet, daher gibt es meines Wissens keinen 400- oder 500-Fehler. Es sieht so aus, als ob Sie tatsächlich fangen sollten BadResponseException(oder ClientException(4xx) / ServerException(5xx), die beide Kinder davon sind)
Flame
2

Guzzle verwendet Versprechen sowohl für synchrone als auch für asynchrone Anforderungen. Der einzige Unterschied besteht darin, dass bei Verwendung einer synchronen Anforderung (Ihrem Fall) diese sofort durch Aufrufen einer wait() Methode erfüllt wird . Beachten Sie diesen Teil:

Wenn Sie waitein abgelehntes Versprechen einholen, wird eine Ausnahme ausgelöst. Wenn der Ablehnungsgrund eine Instanz des \ExceptionGrundes ist, wird geworfen. Andernfalls wird a GuzzleHttp\Promise\RejectionException ausgelöst und der Grund kann durch Aufrufen der getReason Methode der Ausnahme ermittelt werden.

Es wird also ausgelöst, RequestExceptionwas eine Instanz von ist, \Exceptionund es tritt immer bei 4xx- und 5xx-HTTP-Fehlern auf, es sei denn, das Auslösen von Ausnahmen ist über Optionen deaktiviert. Wie Sie sehen, kann es auch ein werfen, RejectionExceptionwenn der Grund keine Instanz ist, \Exceptionz. B. wenn der Grund eine Zeichenfolge ist, die in Ihrem Fall vorkommt. Das Seltsame ist, dass Sie RejectExceptioneher als RequestExceptionGuzzle ConnectExceptionauf Verbindungs-Timeout-Fehler werfen . Auf jeden Fall können Sie einen Grund finden, wenn Sie Ihre RejectExceptionStapelverfolgung in Sentry durchgehen und herausfinden, wo die reject()Methode in Promise aufgerufen wird.

Vladimir
quelle
1

Diskussion mit dem Autor im Kommentarbereich als Einstieg in meine Antwort:

Frage:

Haben Sie benutzerdefinierte Guzzle-Middleware installiert (Hinweis: HandlerStack)?

Antwort des Autors:

Ja verschieden. Aber Middleware ist im Grunde ein Anforderungs- / Antwortmodifikator, selbst die Verzehranforderungen, die ich dort stelle, werden synchron ausgeführt.


Demnach ist hier meine These:

Sie haben eine Zeitüberschreitung in einer Ihrer Middleware, die guzzle ruft. Versuchen wir also, einen reproduzierbaren Fall zu implementieren.

Hier haben wir eine benutzerdefinierte Middleware, die guzzle aufruft und einen Ablehnungsfehler mit der Ausnahmemeldung des Unteraufrufs zurückgibt. Es ist ziemlich knifflig, weil es aufgrund der internen Fehlerbehandlung im Stack-Trace unsichtbar wird.

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

Dies ist ein Testbeispiel, wie Sie es verwenden können:

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

Sobald ich einen Test dagegen durchführe, erhalte ich

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

Es sieht also so aus, als ob Ihr Hauptaufruf fehlgeschlagen ist, aber in Wirklichkeit ist es der Unteraufruf, der fehlgeschlagen ist.

Lassen Sie mich wissen, ob dies Ihnen hilft, Ihr spezifisches Problem zu identifizieren. Ich würde mich auch sehr freuen, wenn Sie Ihre Middlewares teilen können, um dies ein wenig weiter zu debuggen.

Christoph Kluge
quelle
Es sieht so aus, als hättest du recht! Ich habe ein rejection_for($e->getMessage())statt rejection_for($e)irgendwo in dieser Middleware angerufen . Ich habe in der Originalquelle nach Standard-Middleware gesucht (wie hier: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), konnte aber nicht genau sagen, warum es rejection_for($e)stattdessen gab throw $e. Laut meinem Testfall scheint es genauso zu kaskadieren. Im vereinfachten Test finden Sie einen vereinfachten Testfall.
Flamme
1
@Flame froh, dass ich dir helfen konnte :) Nach deiner zweiten Frage: Wenn es einen Unterschied zwischen ihnen gibt. Nun, es liegt wirklich am Anwendungsfall. In Ihrem speziellen Szenario macht dies keinen Unterschied (mit Ausnahme der verwendeten Ausnahmeklasse), da Sie nur einzelne Aufrufe haben. Wenn Sie in Betracht ziehen, zu mehreren und asynchronen Aufrufen gleichzeitig zu wechseln, sollten Sie das Versprechen verwenden, um Codeunterbrechungen zu vermeiden, während andere Anforderungen noch ausgeführt werden. Falls Sie weitere Informationen benötigen, um meine Antwort zu akzeptieren, lassen Sie es mich bitte wissen :)
Christoph Kluge
0

Hallo, ich habe nicht verstanden, ob Sie Ihr Problem gelöst haben oder nicht.

Nun, ich möchte, dass Sie das Fehlerprotokoll veröffentlichen. Suchen Sie sowohl in PHP als auch im Fehlerprotokoll Ihres Servers

Ich erwarte Ihr Feedback

PauloBoaventura
quelle
1
Die Ausnahme ist bereits oben veröffentlicht. Es gibt nichts weiter zu posten, als dass sie aus einem Hintergrundprozess stammt und die Zeile, die sie $client->request('GET', ...)auslöst, ist (nur ein normaler Guzzle-Client).
Flamme
0

Da dies in Ihrer Umgebung sporadisch vorkommt und es schwierig ist, das Auslösen des RejectionException(zumindest konnte ich nicht) zu replizieren , können Sie catchIhrem Code einfach einen weiteren Block hinzufügen , siehe unten:

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

Es muss Ihnen und uns einige Ideen geben, warum und wann dies geschieht.

Vladimir
quelle
leider nicht. Ich habe die Stapelverfolgung in Sentry erhalten, weil sie, ohne sie zu fangen, schließlich den Laravel Exception-Handler erreicht (und an Sentry gesendet wird). Die Stapelverfolgung zeigt mich nur tief in die Guzzle-Bibliothek, aber ich kann nicht herausfinden, warum sie ein Versprechen annimmt.
Flamme
Siehe meine andere Antwort, warum es ein Versprechen annimmt: stackoverflow.com/a/60498078/1568963
Vladimir