Wyrzucanie pyska RejectionException zamiast ConnectionException w procesie w tle

9

Mam zadania, które działają na wielu pracownikach kolejki, które zawierają niektóre żądania HTTP przy użyciu Guzzle. Jednak blok try-catch w tym zadaniu nie wydaje się wychwytywać, GuzzleHttp\Exception\RequestExceptiongdy uruchamiam je w tle. Działający proces to proces php artisan queue:workroboczy systemu kolejek Laravel, który monitoruje kolejkę i odbiera zadania.

Zamiast tego zgłaszany jest wyjątek związany GuzzleHttp\Promise\RejectionExceptionz komunikatem:

Obietnica została odrzucona z uzasadnieniem: błąd cURL 28: Upłynął limit czasu operacji po 30001 milisekundach z otrzymaniem 0 bajtów (patrz https://curl.haxx.se/libcurl/c/libcurl-errors.html )

W rzeczywistości jest to ukryte GuzzleHttp\Exception\ConnectException(patrz https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), ponieważ jeśli uruchomię podobne zadanie w zwykłym procesie PHP, który jest wywoływany przez odwiedziny w URL, otrzymuję ConnectExceptionzgodnie z przeznaczeniem z komunikatem:

błąd cURL 28: Upłynął limit czasu operacji po 100 milisekundach z otrzymaniem 0 z 0 bajtów (patrz https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Przykładowy kod, który wyzwoli ten limit czasu:

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

Powyższy kod wyrzuca albo a RejectionExceptionlub ConnectExceptiongdy jest uruchamiany w procesie roboczym, ale zawsze a, ConnectExceptiongdy jest testowany ręcznie przez przeglądarkę (z tego, co mogę powiedzieć).

Więc w zasadzie czerpię to, że zawijam RejectionExceptionwiadomość od ConnectException, jednak nie używam asynchronicznych funkcji Guzzle. Moje prośby są po prostu realizowane szeregowo. Jedyną różnicą jest to, że wiele procesów PHP może wykonywać połączenia Guzzle HTTP lub że same zadania wygasają (co powinno skutkować innym wyjątkiem należącym do Laravela Illuminate\Queue\MaxAttemptsExceededException), ale nie rozumiem, jak to powoduje, że kod zachowuje się inaczej.

Nie mogłem znaleźć żadnego kodu w pakietach Guzzle, który używa php_sapi_name()/ PHP_SAPI(który określa używany interfejs) do wykonywania różnych rzeczy podczas uruchamiania z CLI, w przeciwieństwie do wyzwalacza przeglądarki.

tl; dr

Dlaczego Guzzle rzuca mi RejectionExceptionprocesy robocze , ConnectExceptiona zwykłe skrypty PHP uruchamiane przez przeglądarkę?

Edytuj 1

Niestety nie mogę stworzyć minimalnego odtwarzalnego przykładu. Widzę wiele komunikatów o błędach w moim narzędziu do śledzenia problemów Sentry, z dokładnym wyjątkiem pokazanym powyżej. Źródło jest określone jako Starting Artisan command: horizon:work(którym jest Laravel Horizon, nadzoruje kolejki Laravel). Ponownie sprawdziłem, czy istnieje rozbieżność między wersjami PHP, ale zarówno witryna, jak i procesy robocze działają w tym samym PHP, 7.3.14co jest poprawne:

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
  • Wersja cURL to cURL 7.58.0.
  • Wersja z wylotem jest guzzlehttp/guzzle 6.5.2
  • Wersja Laravela to laravel/framework 6.12.0

Edycja 2 (ślad stosu)

    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

Ta Client::callRequest()funkcja zawiera po prostu klienta Guzzle, do którego dzwonię $client->request($request['method'], $request['url'], $request['options']);(więc nie używam requestAsync()). Myślę, że ma to związek z równoległym wykonywaniem zadań, które powodują ten problem.

Edytuj 3 (znaleziono rozwiązanie)

Rozważ następującą walizkę testową, która wysyła żądanie HTTP (które powinno zwrócić regularną odpowiedź 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);
        }

Teraz to, co pierwotnie zrobiłem, to wywołanie, rejection_for($e->getMessage())które tworzy własne RejectionExceptionna podstawie ciągu komunikatu. Dzwonienie rejection_for($e)było tutaj poprawnym rozwiązaniem. Pozostaje tylko odpowiedzieć, jeśli ta rejection_forfunkcja jest taka sama jak prosta throw $e.

Płomień
źródło
Jakiej wersji Guzzle używasz?
Vladimir
1
Jakiego sterownika kolejki używasz dla laravel? Ilu pracowników pracuje równolegle na instancji / na instancję? Czy masz niestandardowe oprogramowanie pośredniczące żonglerki (wskazówka:) HandlerStack?
Christoph Kluge
Czy możesz podać ślad stosu z Sentry?
Vladimir
@Vladimir ive dodał ślad stosu. Nie sądzę, żeby ci to bardzo pomogło. Sposób, w jaki obietnice są realizowane w Guzzle (i PHP w ogóle) jest trudny do odczytania.
Płomień
1
@Flame czy możesz udostępnić oprogramowanie pośrednie, które wykonuje żądanie podżegania? Chyba problem będzie. W międzyczasie dodam powtarzalną odpowiedź z moją tezą.
Christoph Kluge

Odpowiedzi:

3

Witam Chciałbym wiedzieć, czy masz błąd 4xx lub błąd 5xx

Ale i tak przedstawię alternatywne rozwiązania, które będą podobne do twojego problemu

alternatywa 1

Chciałbym to podnieść, miałem ten problem z nowym serwerem produkcyjnym zwracającym nieoczekiwane 400 odpowiedzi w porównaniu ze środowiskiem programistycznym i testowym działającym zgodnie z oczekiwaniami; po prostu zainstalowanie apt install php7.0-curl to naprawiło.

Była to zupełnie nowa instalacja Ubuntu 16.04 LTS z php zainstalowanym przez ppa: ondrej / php, podczas debugowania zauważyłem, że nagłówki są różne. Obaj wysyłali wieloczęściowy formularz z obrzuconymi danymi, jednak bez php7.0-curl wysyłał nagłówek Connection: close zamiast oczekiwanego: 100-Kontynuuj; oba żądania miały kodowanie transferowe: porcje.

  alternatywa 2

Może powinieneś tego spróbować

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

Kaganiec wymaga kaktusa, jeśli kod odpowiedzi nie jest równy 200

alternatywa 3

W moim przypadku było to spowodowane tym, że przekazałem pustą tablicę w opcjach $ żądania [„json”] Nie mogłem odtworzyć 500 na serwerze za pomocą Postmana lub cURL, nawet po przekazaniu nagłówka żądania Content-Type: application / json.

W każdym razie usunięcie klucza json z tablicy opcji żądania rozwiązało problem.

Spędziłem około 30 minut, próbując dowiedzieć się, co jest nie tak, ponieważ takie zachowanie jest bardzo niespójne. W przypadku wszystkich innych żądań przekazanie $ options ['json'] = [] nie spowodowało żadnych problemów. Może to być problem z serwerem, chociaż nie kontroluję serwera.

wyślij opinię na temat uzyskanych szczegółów

PauloBoaventura
źródło
dobrze ... Aby uzyskać szybszą i dokładniejszą odpowiedź. Podjąłem inicjatywę, aby opublikować pytanie na stronie projektu w serwisie GitHub. Mam nadzieję, że nie masz nic przeciwko github.com/guzzle/guzzle/issues/2599
PauloBoaventura
1
a ConnectExceptionnie ma powiązanej odpowiedzi, więc o ile mi wiadomo, nie ma błędu 400 ani 500. Wygląda na to, że powinieneś łapać BadResponseException(lub ClientException(4xx) / ServerException(5xx), które są oboje jego dziećmi)
Flame
2

Guzzle używa obietnic zarówno dla żądań synchronicznych, jak i asynchronicznych. Jedyna różnica polega na tym, że gdy korzystasz z żądania synchronicznego (swojego przypadku) - jest ono realizowane od razu przez wywołanie wait() metody . Zwróć uwagę na tę część:

Przywołanie waitodrzuconej obietnicy spowoduje wyjątek. Jeśli przyczyną odrzucenia jest instancja, \Exceptionpowód jest zgłaszany. W przeciwnym razie GuzzleHttp\Promise\RejectionException generowane jest a, a przyczynę można uzyskać, wywołując getReason metodę wyjątku.

Zgłasza to, RequestExceptionco jest wystąpieniem \Exceptioni zawsze dzieje się na błędach HTTP 4xx i 5xx, chyba że wyjątki zgłaszania są wyłączone przez opcje. Jak widzisz, może również rzucić a, RejectionExceptionjeśli przyczyna nie jest instancją, \Exceptionnp. Jeśli przyczyną jest ciąg znaków, który wydaje się zdarzać w twoim przypadku. Dziwną rzeczą jest to, że dostajesz, RejectExceptiona nie RequestExceptionGuzzle rzuca ConnectExceptionbłąd przekroczenia limitu czasu połączenia. W każdym razie możesz znaleźć powód, jeśli przejrzysz RejectExceptionślad stosu w Sentry i dowiesz się, gdzie reject()metoda jest wywoływana w Promise.

Vladimir
źródło
1

Dyskusja z autorem w sekcji komentarzy na początek mojej odpowiedzi:

Pytanie:

Czy masz własne niestandardowe oprogramowanie pośredniczące (wskazówka: HandlerStack)?

Odpowiedź autora:

Tak różne. Ale oprogramowanie pośrednie jest w zasadzie modyfikatorem żądania / odpowiedzi, nawet żądania żużla, które tam wysyłam, są wykonywane synchronicznie.


Zgodnie z tym oto moja teza:

Masz limit czasu w jednym z oprogramowania pośredniego, które nazywa się żubrem. Spróbujmy więc zaimplementować odtwarzalny przypadek.

Tutaj mamy niestandardowe oprogramowanie pośrednie, które wywołuje guzzle i zwraca błąd odrzucenia z komunikatem wyjątku pod-wywołania. Jest to dość trudne, ponieważ ze względu na wewnętrzną obsługę błędów staje się niewidoczne wewnątrz śledzenia stosu.

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

To jest przykładowy sposób, w jaki możesz go użyć:

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

Jak tylko przeprowadzę test na to, otrzymuję

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

Wygląda więc na to, że twoje główne połączenie nie powiodło się, ale w rzeczywistości jest to pod-połączenie, które się nie powiodło.

Daj mi znać, jeśli to pomoże Ci zidentyfikować konkretny problem. Byłbym również bardzo wdzięczny, jeśli możesz udostępnić swoje oprogramowanie pośrednie w celu dalszego debugowania tego.

Christoph Kluge
źródło
Wygląda na to, że masz rację! Dzwoniłem rejection_for($e->getMessage())zamiast rejection_for($e)gdzieś w tym oprogramowaniu pośrednim. Szukałem na oryginalnego źródła dla domyślnego oprogramowania pośredniczącego (jak tutaj: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), ale nie mogliśmy dość powiedzieć, dlaczego tam rejection_for($e)zamiast throw $e. Zdaje się, że kaskada przebiega w ten sam sposób, zgodnie z moim testem. Zobacz oryginalny post dla uproszczonej skrzynki testowej.
Płomień
1
@Flame cieszę się, że mogłem ci pomóc :) Zgodnie z twoim drugim pytaniem: czy istnieje różnica między nimi. Cóż, to naprawdę zależy od przypadku użycia. W twoim konkretnym scenariuszu nie zrobi to żadnej różnicy (z wyjątkiem użytej klasy wyjątków), ponieważ masz tylko pojedyncze połączenia. Jeśli zastanawiasz się nad przejściem na wiele wywołań asynchronicznych jednocześnie, powinieneś rozważyć skorzystanie z obietnicy, aby uniknąć przerwania kodu podczas działania innych żądań. Jeśli potrzebujesz więcej informacji, aby moja odpowiedź została zaakceptowana, daj mi znać :)
Christoph Kluge
0

Witaj Nie rozumiem, czy udało Ci się rozwiązać problem, czy nie.

Chciałbym, abyś opublikował dziennik błędów. Wyszukaj zarówno w PHP, jak i w dzienniku błędów serwera

Czekam na twoją opinię

PauloBoaventura
źródło
1
Wyjątek jest już opublikowany powyżej, nie ma nic więcej do dodania niż to, że pochodzi z procesu w tle i linii, która go rzuca $client->request('GET', ...)(tylko zwykły klient żubra).
Płomień
0

Ponieważ zdarza się to sporadycznie w twoim środowisku i trudno jest replikować rzucanie RejectionException(przynajmniej nie mogłem), możesz po prostu dodać kolejny catchblok do swojego kodu, patrz poniżej:

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

Musi dać nam i tobie kilka pomysłów na to, dlaczego i kiedy to się dzieje.

Vladimir
źródło
niestety nie. Dostałem stacktrace w Sentry, ponieważ bez jego złapania, w końcu dociera do modułu obsługi wyjątków Laravel (i zostaje wysłany do Sentry). Ślad stosu wskazuje tylko głęboko w bibliotece Guzzle, ale nie mogę zrozumieć, dlaczego to obiecuje.
Płomień
Zobacz moją kolejną odpowiedź dotyczącą tego, dlaczego obiecuje: stackoverflow.com/a/60498078/1568963
Vladimir