Membuang membuang RejectionException alih-alih ConnectionException pada proses latar belakang

9

Saya memiliki pekerjaan yang berjalan di beberapa pekerja antrian, yang berisi beberapa permintaan HTTP menggunakan Guzzle. Namun, blok coba-tangkap di dalam pekerjaan ini tampaknya tidak mengambil GuzzleHttp\Exception\RequestExceptionketika saya menjalankan pekerjaan ini dalam proses latar belakang. Proses yang sedang berjalan adalah php artisan queue:workyang merupakan pekerja sistem antrian Laravel yang memantau antrian dan mengambil pekerjaan.

Sebaliknya, pengecualian yang dilontarkan adalah salah satu dari GuzzleHttp\Promise\RejectionExceptionpesan tersebut:

Janji itu ditolak dengan alasan: kesalahan CURL 28: Operasi habis setelah 30001 milidetik dengan 0 byte diterima (lihat https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Ini sebenarnya disamarkan GuzzleHttp\Exception\ConnectException(lihat https://github.com/guzzle/promises/blob/master/src/RejectionException.php#L22 ), karena jika saya menjalankan pekerjaan serupa dalam proses PHP biasa yang dipicu dengan mengunjungi sebuah URL, saya mendapatkan yang ConnectExceptiondimaksud dengan pesan:

kesalahan CURL 28: Waktu operasi habis setelah 100 milidetik dengan 0 dari 0 byte diterima (lihat https://curl.haxx.se/libcurl/c/libcurl-errors.html )

Kode contoh yang akan memicu batas waktu ini:

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

Kode di atas melempar baik RejectionExceptionatau ConnectExceptionketika berlari dalam proses pekerja, tetapi selalu ConnectExceptionketika diuji secara manual melalui browser (dari apa yang saya tahu).

Jadi pada dasarnya yang saya dapatkan, adalah bahwa ini RejectionExceptionadalah pembungkus pesan dari ConnectException, namun saya tidak menggunakan fitur asinkron dari Guzzle. Permintaan saya hanya dilakukan secara seri. Satu-satunya hal yang berbeda adalah bahwa beberapa proses PHP mungkin membuat panggilan HTTP Guzzle atau bahwa pekerjaan itu sendiri adalah waktu habis (yang seharusnya menghasilkan pengecualian berbeda menjadi Laravel Illuminate\Queue\MaxAttemptsExceededException), tetapi saya tidak melihat bagaimana ini menyebabkan kode berperilaku berbeda.

Saya tidak bisa menemukan kode apa pun di dalam paket Guzzle yang menggunakan php_sapi_name()/ PHP_SAPI(yang menentukan antarmuka yang digunakan) untuk mengeksekusi hal-hal yang berbeda ketika menjalankan dari CLI sebagai lawan dari pemicu browser.

tl; dr

Mengapa Guzzle melempar saya RejectionExceptionke proses pekerja saya, tetapi ConnectExceptionpada skrip PHP biasa yang dipicu melalui browser?

Edit 1

Sedihnya saya tidak bisa membuat contoh minimal yang bisa direproduksi. Saya melihat banyak pesan kesalahan dalam pelacak masalah Sentry saya, dengan pengecualian tepat seperti yang ditunjukkan di atas. Sumber dinyatakan sebagai Starting Artisan command: horizon:work(yaitu Laravel Horizon, mengawasi antrian Laravel). Saya telah memeriksa lagi untuk melihat apakah ada perbedaan antara versi PHP, tetapi baik situs web dan proses pekerja menjalankan PHP 7.3.14yang sama yang benar:

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
  • Versi CURL adalah cURL 7.58.0.
  • Versi guzzle adalah guzzlehttp/guzzle 6.5.2
  • Versi Laravel adalah laravel/framework 6.12.0

Edit 2 (tumpukan jejak)

    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

The Client::callRequest()Fungsi berisi hanya membuang waktu Client yang saya sebut $client->request($request['method'], $request['url'], $request['options']);(jadi im tidak menggunakan requestAsync()). Saya pikir itu ada hubungannya dengan menjalankan pekerjaan secara paralel yang menyebabkan masalah ini.

Edit 3 (solusi ditemukan)

Pertimbangkan testcase berikut yang membuat permintaan HTTP (yang seharusnya mengembalikan 200 respons reguler):

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

Sekarang apa yang saya lakukan awalnya adalah panggilan rejection_for($e->getMessage())yang membuat sendiri RejectionExceptionberdasarkan pada string pesan. Memanggil rejection_for($e)adalah solusi yang tepat di sini. Satu-satunya yang tersisa untuk dijawab adalah jika rejection_forfungsi ini sama dengan yang sederhana throw $e.

Api
sumber
Versi Guzzle apa yang Anda gunakan?
Vladimir
1
Driver antrian mana yang Anda gunakan untuk laravel? Berapa banyak pekerja yang berjalan secara paralel pada instance / per instance? Apakah Anda memiliki middleware kustom guzzle di tempat (petunjuk:) HandlerStack?
Christoph Kluge
Bisakah Anda memberikan jejak stack dari Sentry?
Vladimir
@Vladimir ive menambahkan jejak stack. Saya tidak berpikir itu akan banyak membantu Anda. Cara janji diimplementasikan dalam Guzzle (dan PHP pada umumnya) sulit dibaca.
Flame
1
@ Api dapatkah Anda membagikan middleware yang melakukan permintaan sub-guzzle? Saya kira masalahnya akan ada di sana. Sementara itu saya akan menambahkan jawaban yang dapat direproduksi dengan tesis saya.
Christoph Kluge

Jawaban:

3

Halo Saya ingin tahu apakah Anda mengalami kesalahan 4xx atau kesalahan 5xx

Namun meski begitu saya akan menempatkan beberapa alternatif untuk solusi yang menyerupai masalah Anda

alternatif 1

Saya ingin menabrak ini, saya memiliki masalah ini dengan server produksi baru yang mengembalikan 400 tanggapan yang tidak terduga dibandingkan dengan pengembangan dan lingkungan pengujian yang bekerja seperti yang diharapkan; cukup menginstal apt install php7.0-curl memperbaikinya.

Itu adalah Ubuntu baru 16,04 LTS instal dengan php diinstal melalui ppa: ondrej / php, selama debugging saya perhatikan bahwa header berbeda. Keduanya mengirim formulir multi-bagian dengan data yang dibuang, namun tanpa php7.0-curl ia mengirim Koneksi: tutup header daripada Harapan: 100-Lanjutkan; kedua permintaan yang memiliki Transfer-Pengkodean: chunked.

  alternatif 2

Mungkin Anda harus mencoba ini

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 perlu cactching jika kode respons tidak 200

alternatif 3

Dalam kasus saya itu karena saya telah melewati array kosong di $ options permintaan ['json'] saya tidak bisa mereproduksi 500 di server menggunakan Postman atau cURL bahkan ketika melewati Content-Type: application / json request header.

Bagaimanapun, menghapus kunci json dari array opsi permintaan memecahkan masalah.

Saya menghabiskan waktu 30 menit untuk mencari tahu apa yang salah karena perilaku ini sangat tidak konsisten. Untuk semua permintaan lain yang saya buat, meneruskan $ options ['json'] = [] tidak menyebabkan masalah. Ini bisa jadi masalah server, saya tidak mengontrol server.

kirim umpan balik tentang perincian yang diperoleh

PauloBoaventura
sumber
bagus ... Untuk mendapatkan jawaban yang lebih cepat dan lebih akurat. Saya mengambil inisiatif untuk mengirimkan pertanyaan pada Halaman Proyek di GitHub. Saya harap Anda tidak keberatan github.com/guzzle/guzzle/issues/2599
PauloBoaventura
1
a ConnectExceptiontidak memiliki respons terkait, jadi oleh karena itu tidak ada kesalahan 400 atau 500 sejauh yang saya ketahui. Sepertinya Anda seharusnya menangkap BadResponseException(atau ClientException(4xx) / ServerException(5xx) yang sama-sama anak-anak itu)
Flame
2

Guzzle menggunakan Janji untuk permintaan sinkron dan asinkron. Satu-satunya perbedaan adalah bahwa ketika Anda menggunakan permintaan sinkron (kasing Anda) - itu dipenuhi segera dengan memanggil wait() metode . Perhatikan bagian ini:

Memanggil waitjanji yang telah ditolak akan memberikan pengecualian. Jika alasan penolakan adalah contoh \Exceptionalasan dilemparkan. Jika tidak, a GuzzleHttp\Promise\RejectionException dilemparkan dan alasannya dapat diperoleh dengan memanggil getReason metode pengecualian.

Jadi, ia melempar RequestExceptionyang merupakan contoh \Exceptiondan selalu terjadi pada kesalahan HTTP 4xx dan 5xx, kecuali melempar pengecualian dinonaktifkan melalui opsi. Seperti yang Anda lihat, itu juga bisa melempar RejectionExceptionjika alasannya bukan merupakan contoh \Exceptionmisalnya jika alasannya adalah string yang tampaknya terjadi dalam kasus Anda. Yang aneh adalah bahwa Anda mendapatkan RejectExceptiondaripada RequestExceptionGuzzle melempar ConnectExceptionkesalahan koneksi habis. Ngomong-ngomong, Anda dapat menemukan alasan jika Anda RejectExceptionmenelusuri jejak tumpukan di Sentry dan menemukan di mana reject()metode ini dipanggil pada Janji.

Vladimir
sumber
1

Diskusi dengan penulis di dalam bagian komentar sebagai starter untuk jawaban saya:

Pertanyaan:

Apakah Anda memiliki middleware kustom guzzle di tempat (petunjuk: HandlerStack)?

Jawaban penulis:

Ya beragam. Tapi middleware pada dasarnya adalah pengubah permintaan / respons, bahkan permintaan guzzle yang saya buat di sana dilakukan secara serempak.


Menurut ini di sini adalah tesis saya:

Anda memiliki batas waktu di dalam salah satu middleware Anda yang memanggilnya membuang waktu. Jadi mari kita coba menerapkan kasing yang dapat direproduksi.

Di sini kita memiliki middleware khusus yang memanggil guzzle dan mengembalikan kegagalan penolakan dengan pesan pengecualian dari sub-panggilan. Ini cukup sulit, karena karena penanganan kesalahan internal itu tidak terlihat di dalam 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);
        };
    };
}

Ini adalah contoh uji bagaimana Anda dapat menggunakannya:

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

Segera setelah saya melakukan tes terhadap ini saya menerima

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

Jadi sepertinya panggilan guzzle utama Anda gagal tetapi pada kenyataannya itu adalah sub-panggilan yang gagal.

Beri tahu saya jika ini membantu Anda mengidentifikasi masalah spesifik Anda. Saya juga akan sangat menghargai jika Anda dapat membagikan middlewares Anda untuk men-debug ini sedikit lebih jauh.

Christoph Kluge
sumber
Sepertinya kamu benar! Saya menelepon rejection_for($e->getMessage())bukan rejection_for($e)di suatu tempat di middleware itu. Saya sedang mencari sumber asli untuk middleware default (seperti di sini: github.com/guzzle/guzzle/blob/master/src/Middleware.php#L106 ), tetapi tidak bisa mengetahui mengapa ada rejection_for($e)alih - alih throw $e. Tampaknya mengalir dengan cara yang sama menurut testcase saya. Lihat posting asli untuk testcase yang disederhanakan.
Nyala
1
@Flame senang saya bisa membantu Anda :) Menurut pertanyaan kedua Anda: Jika ada perbedaan di antara mereka. Yah itu benar-benar terserah kasus penggunaan. Dalam skenario spesifik Anda, itu tidak akan membuat perbedaan (kecuali kelas pengecualian yang digunakan) karena Anda hanya memiliki satu panggilan. Jika Anda mempertimbangkan untuk beralih ke beberapa panggilan dan panggilan sekaligus sekaligus maka Anda harus mempertimbangkan untuk menggunakan janji untuk menghindari gangguan kode saat permintaan lain masih berjalan. Jika Anda membutuhkan info lebih lanjut agar jawaban saya diterima, harap beri tahu saya :)
Christoph Kluge
0

Halo, saya tidak mengerti apakah Anda akhirnya memecahkan masalah Anda atau tidak.

Yah saya ingin Anda memposting apa itu log kesalahan. Cari baik dalam PHP dan di dalam log kesalahan server Anda

Saya menunggu tanggapan Anda

PauloBoaventura
sumber
1
Pengecualian sudah diposting di atas, tidak ada yang lebih dari memposting selain itu berasal dari proses latar belakang dan garis yang melemparnya adalah $client->request('GET', ...)(hanya klien membuang waktu biasa).
Nyala
0

Karena ini terjadi secara sporadis pada lingkungan Anda dan sulit untuk mereplikasi melempar RejectionException(setidaknya saya tidak bisa), dapatkah Anda menambahkan catchblok lain ke kode Anda, lihat di bawah:

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

Itu harus memberi Anda dan kami beberapa ide tentang mengapa dan kapan ini terjadi.

Vladimir
sumber
sayangnya tidak. Saya mendapat stacktrace di Sentry karena tanpa menangkapnya, akhirnya mencapai pengendali Laravel Exception (dan dikirim ke Sentry). Jejak tumpukan hanya menunjukkan saya jauh di dalam perpustakaan Guzzle tapi saya tidak bisa mencari tahu mengapa asumsi itu menjanjikan.
Nyala
Lihat jawaban saya yang lain tentang mengapa ia menganggap sebuah janji: stackoverflow.com/a/60498078/1568963
Vladimir