Guzzle lanzando RejectionException en lugar de ConnectionException en el proceso en segundo plano


9

Tengo trabajos que se ejecutan en varios trabajadores de cola, que contienen algunas solicitudes HTTP que usan Guzzle. Sin embargo, el bloque try-catch dentro de este trabajo no parece recuperarse GuzzleHttp\Exception\RequestExceptioncuando ejecuto este trabajo en el proceso en segundo plano. El proceso en ejecución es un php artisan queue:worktrabajador del sistema de colas Laravel que monitorea la cola y recoge los trabajos.

En cambio, la excepción que se produce es una GuzzleHttp\Promise\RejectionExceptioncon el mensaje:

La promesa fue rechazada con razón: error 28 de cURL: La operación expiró después de 30001 milisegundos con 0 bytes recibidos (consulte https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Esto es realmente un disfraz GuzzleHttp\Exception\ConnectException(ver https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), porque si ejecuto un trabajo similar en un proceso PHP normal que se activa al visitar un URL, obtengo lo ConnectExceptionque pretendía con el mensaje:

Error 28 de cURL: La operación expiró después de 100 milisegundos con 0 de 0 bytes recibidos (consulte https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Código de muestra que desencadenaría este tiempo de espera:

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

El código anterior arroja una RejectionExceptiono ConnectExceptioncuando se ejecuta en el proceso de trabajo, pero siempre una ConnectExceptioncuando se prueba manualmente a través del navegador (por lo que puedo decir).

Básicamente, lo que deduzco es que esto RejectionExceptionestá envolviendo el mensaje del ConnectException, sin embargo, no estoy usando las características asincrónicas de Guzzle. Mis solicitudes simplemente se hacen en serie. Lo único que difiere es que múltiples procesos de PHP podrían estar haciendo llamadas HTTP de Guzzle o que los trabajos en sí mismos están agotando el tiempo (lo que debería resultar en una excepción diferente de Laravel Illuminate\Queue\MaxAttemptsExceededException), pero no veo cómo esto hace que el código se comporte de manera diferente.

No pude encontrar ningún código dentro de los paquetes de Guzzle que está usando php_sapi_name()/ PHP_SAPI(que determina la interfaz utilizada) para ejecutar diferentes cosas cuando se ejecuta desde la CLI en lugar de un disparador del navegador.

tl; dr

¿Por qué Guzzle me lanza RejectionExceptions en mis procesos de trabajo, pero ConnectExceptions en scripts PHP normales activados a través del navegador?

Editar 1

Lamentablemente, no puedo crear un ejemplo mínimo reproducible. Veo muchos mensajes de error en mi rastreador de problemas de Sentry, con la excepción exacta que se muestra arriba. La fuente se indica como Starting Artisan command: horizon:work(que es Laravel Horizon, supervisa las colas de Laravel). He revisado nuevamente para ver si hay una discrepancia entre las versiones de PHP, pero tanto el sitio web como los procesos de trabajo ejecutan el mismo PHP 7.3.14que es correcto:

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 versión de cURL es cURL 7.58.0.
  • La versión de Guzzle es guzzlehttp/guzzle 6.5.2
  • La versión de Laravel es laravel/framework 6.12.0

Editar 2 (seguimiento de pila)

    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()función contiene simplemente un Guzzle Client en el que llamo $client->request($request['method'], $request['url'], $request['options']);(así que no estoy usando requestAsync()). Creo que tiene algo que ver con ejecutar trabajos en paralelo que causa este problema.

Edición 3 (solución encontrada)

Considere el siguiente caso de prueba que realiza una solicitud HTTP (que debería devolver una respuesta 200 normal):

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

Ahora, lo que hice originalmente fue llamar, rejection_for($e->getMessage())que crea el suyo en RejectionExceptionfunción de la cadena del mensaje. Llamar rejection_for($e)fue la solución correcta aquí. Lo único que queda por responder es si esta rejection_forfunción es igual a una simple throw $e.


¿Qué versión de Guzzle usas?
Vladimir

1
¿Qué controlador de cola utilizas para laravel? ¿Cuántos trabajadores se ejecutan en paralelo en la instancia / por instancia? ¿Tienes middleware personalizado de guzzle en su lugar (pista:) HandlerStack?
Christoph Kluge

¿Puede proporcionar un seguimiento de pila de Sentry?
Vladimir

@Vladimir ive agregó el seguimiento de la pila. No creo que te ayude mucho. La forma en que se implementan las promesas en Guzzle (y PHP en general) es difícil de leer.
Llama

1
@Flame, ¿puede compartir el middleware que realiza la solicitud de sub-guzzle? Supongo que el problema estará ahí. Mientras tanto, agregaré una respuesta reproducible con mi tesis.
Christoph Kluge

Respuestas:


3

Hola, me gustaría saber si tienes el error 4xx o el error 5xx

Pero aun así, pondré algunas alternativas para las soluciones encontradas que se parecen a su problema

alternativa 1

Me gustaría resolver esto, tuve este problema con un nuevo servidor de producción que devolvió 400 respuestas inesperadas en comparación con el entorno de desarrollo y prueba que funciona como se esperaba; simplemente instalando apt install php7.0-curl lo arregló.

Fue una nueva instalación de Ubuntu 16.04 LTS con php instalado a través de ppa: ondrej / php, durante la depuración noté que los encabezados eran diferentes. Ambos enviaban un formulario de varias partes con datos arrojados, sin embargo, sin php7.0-curl estaba enviando una conexión: cerrar encabezado en lugar de esperar: 100-continuar; ambas solicitudes tenían Transfer-Encoding: fragmentado.

  alternativa 2

Tal vez deberías probar esto

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 necesita cactching si el código de respuesta no es 200

alternativa 3

En mi caso fue porque había pasado una matriz vacía en la opción $ options ['json'] de la solicitud. No pude reproducir las 500 en el servidor usando Postman o cURL, incluso al pasar el encabezado de solicitud Content-Type: application / json.

De todos modos, eliminar la clave json de la matriz de opciones de la solicitud resolvió el problema.

Pasé como 30 minutos tratando de descubrir qué está mal porque este comportamiento es muy inconsistente. Para todas las demás solicitudes que estoy haciendo, pasar $ options ['json'] = [] no causó ningún problema. Podría ser un problema del servidor, aunque no controlo el servidor.

enviar comentarios sobre los detalles obtenidos


bueno ... Para tener una respuesta más rápida y precisa. Tomé la iniciativa de publicar la pregunta en la página del proyecto en GitHub. Espero que no te importe github.com/guzzle/guzzle/issues/2599
PauloBoaventura

1
a ConnectExceptionno tiene una respuesta asociada, por lo que no hay un error de 400 o 500 hasta donde yo sé. Parece que en realidad deberías estar atrapando BadResponseException(o ClientException(4xx) / ServerException(5xx) que son hijos de ella)
Llama


2

Guzzle utiliza Promesas para solicitudes sincrónicas y asincrónicas. La única diferencia es que cuando usa una solicitud síncrona (su caso), se cumple de inmediato llamando a un wait() método . Tenga en cuenta esta parte:

Llamar waita una promesa que ha sido rechazada arrojará una excepción. Si la razón de rechazo es una instancia de \Exceptionla razón se arroja. De lo contrario, GuzzleHttp\Promise\RejectionException se lanza a y se puede obtener el motivo llamando al getReason método de la excepción.

Por lo tanto, arroja RequestExceptionuna instancia de \Exceptiony siempre ocurre en errores HTTP 4xx y 5xx, a menos que se desactiven las excepciones a través de las opciones. Como puede ver, también puede arrojar un RejectionExceptionsi el motivo no es una instancia de, \Exceptionpor ejemplo, si el motivo es una cadena que parece suceder en su caso. Lo extraño es que obtienes RejectExceptionmás que RequestExceptioncuando Guzzle lanza ConnectExceptionun error de tiempo de espera de conexión. De todos modos, puede encontrar una razón si revisa su RejectExceptionseguimiento de pila en Sentry y encuentra dónde reject()se llama al método en Promise.


1

Discusión con el autor dentro de la sección de comentarios como iniciador de mi respuesta:

Pregunta:

¿Tiene un middleware personalizado de guzzle en su lugar (pista: HandlerStack)?

Respuesta del autor:

Si varios. Pero el middleware es básicamente un modificador de solicitud / respuesta, incluso las solicitudes guzzle que hago allí se realizan de forma sincrónica.


De acuerdo con esto aquí está mi tesis:

Tienes un tiempo de espera dentro de uno de tus middleware que llama guzzle. Así que intentemos implementar un caso reproducible.

Aquí tenemos un middleware personalizado que llama a guzzle y devuelve un error de rechazo con el mensaje de excepción de la sub-llamada. Es bastante complicado, porque debido al manejo interno de errores se vuelve invisible dentro del seguimiento de la pila.

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 es un ejemplo de prueba de cómo puedes usarlo:

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

Tan pronto como realizo una prueba contra esto, estoy recibiendo

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

Por lo tanto, parece que su llamada principal ha fallado, pero en realidad es la sub-llamada que falló.

Avíseme si esto le ayuda a identificar su problema específico. También agradecería mucho que pueda compartir sus middlewares para depurar esto un poco más.


¡Parece que tienes razón! Estaba llamando a un rejection_for($e->getMessage())lugar en rejection_for($e)lugar de en ese middleware. Estaba buscando la fuente original para el middleware predeterminado (como aquí: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), pero no podía decir por qué había en rejection_for($e)lugar de throw $e. Parece en cascada de la misma manera según mi caso de prueba. Vea la publicación original para un caso de prueba simplificado.
Llama

1
@Flame me alegro de poder ayudarte :) Según tu segunda pregunta: si hay una diferencia entre ellos. Bueno, realmente depende del caso de uso. En su escenario específico, no hará ninguna diferencia (excepto la clase de excepción utilizada) porque solo tiene llamadas individuales. Si considera cambiar a llamadas múltiples y asíncronas a la vez, entonces debería considerar usar la promesa de evitar interrupciones de código mientras otras solicitudes aún se están ejecutando. En caso de que necesite más información para que mi respuesta sea aceptada, hágamelo saber :)
Christoph Kluge

0

Hola, no entendí si terminaste resolviendo tu problema o no.

Bueno, me gustaría que publicaras cuál es el registro de errores. Busque tanto en PHP como en el registro de errores de su servidor

Espero sus comentarios


1
La excepción ya se publicó anteriormente, no hay nada más que publicar que proviene de un proceso en segundo plano y la línea que lo arroja es $client->request('GET', ...)(solo un cliente habitual).
Llama

0

Como esto sucede esporádicamente en su entorno y es difícil de replicar arrojando el RejectionException(al menos no pude), ¿puede agregar otro catchbloque a su código?

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

Debe darles a usted y a nosotros algunas ideas sobre por qué y cuándo sucede esto.


lamentablemente no lo hace. Obtuve el stacktrace en Sentry porque sin atraparlo, eventualmente llega al controlador Laravel Exception (y se envía a Sentry). El seguimiento de la pila solo me señala en lo profundo de la biblioteca Guzzle, pero no puedo entender por qué está asumiendo una promesa.
Llama

Vea mi otra respuesta sobre por qué está asumiendo una promesa: stackoverflow.com/a/60498078/1568963
Vladimir
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.