From ec43b4767988a5972f3189e0a8c2a637efbfa2c5 Mon Sep 17 00:00:00 2001 From: ascriver <34779562+AScriver@users.noreply.github.com> Date: Sun, 3 May 2026 16:59:16 -0700 Subject: [PATCH 1/2] Add retry middleware for handling connection errors in API client --- README.md | 6 ++ lib/RetryMiddlewareFactory.php | 46 ++++++++++++++ tests/Unit/RetryMiddlewareFactoryTest.php | 74 +++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 tests/Unit/RetryMiddlewareFactoryTest.php diff --git a/README.md b/README.md index 2f057647c..99bd5bc14 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ $handlerStack->push( ) ); +$handlerStack->push( + \HubSpot\RetryMiddlewareFactory::createConnectionErrorsMiddleware( + \HubSpot\Delay::getExponentialDelayFunction(2) + ) +); + $client = new \GuzzleHttp\Client(['handler' => $handlerStack]); $hubspot = \HubSpot\Factory::createWithAccessToken('your-access-token', $client); diff --git a/lib/RetryMiddlewareFactory.php b/lib/RetryMiddlewareFactory.php index ce132a518..8a624df82 100644 --- a/lib/RetryMiddlewareFactory.php +++ b/lib/RetryMiddlewareFactory.php @@ -2,6 +2,7 @@ namespace HubSpot; +use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -9,11 +10,23 @@ class RetryMiddlewareFactory { public const DEFAULT_MAX_RETRIES = 5; + public const TRANSIENT_CURL_ERROR_CODES = [52, 55, 56]; public const INTERNAL_ERROR_RANGES = [ ['from' => 500, 'to' => 503], ['from' => 520, 'to' => 599], ]; + public static function createConnectionErrorsMiddleware( + ?callable $delayFunction = null, + int $maxRetries = self::DEFAULT_MAX_RETRIES, + array $curlErrorCodes = self::TRANSIENT_CURL_ERROR_CODES + ): callable { + return Middleware::retry( + static::getRetryFunctionByConnectionErrors($maxRetries, $curlErrorCodes), + $delayFunction + ); + } + public static function createInternalErrorsMiddleware( ?callable $delayFunction = null, int $maxRetries = self::DEFAULT_MAX_RETRIES @@ -131,4 +144,37 @@ public static function getRetryFunction( return false; }; } + + public static function getRetryFunctionByConnectionErrors( + int $maxRetries = self::DEFAULT_MAX_RETRIES, + array $curlErrorCodes = self::TRANSIENT_CURL_ERROR_CODES + ): callable { + return function ( + $retries, + Request $request, + ?Response $response = null, + ?RequestException $exception = null + ) use ($maxRetries, $curlErrorCodes) { + if ($retries >= $maxRetries) { + return false; + } + + if (!$exception instanceof RequestException) { + return false; + } + + $handlerContext = $exception->getHandlerContext(); + $errno = $handlerContext['errno'] ?? null; + + if (is_numeric($errno) && in_array((int) $errno, $curlErrorCodes, true)) { + return true; + } + + if (preg_match('/cURL error\s+(\d+):/i', $exception->getMessage(), $matches) === 1) { + return in_array((int) $matches[1], $curlErrorCodes, true); + } + + return false; + }; + } } diff --git a/tests/Unit/RetryMiddlewareFactoryTest.php b/tests/Unit/RetryMiddlewareFactoryTest.php new file mode 100644 index 000000000..a4d249dc9 --- /dev/null +++ b/tests/Unit/RetryMiddlewareFactoryTest.php @@ -0,0 +1,74 @@ + 56] + ); + + $this->assertTrue($retry(0, $request, null, $exception)); + } + + /** @test */ + public function itRetriesRetriableConnectionErrorsByMessageWhenErrnoMissing(): void + { + $retry = RetryMiddlewareFactory::getRetryFunctionByConnectionErrors(3); + $request = new Request('GET', 'https://api.hubapi.com/test'); + $exception = new ConnectException( + 'cURL error 55: Send failure: Broken pipe', + $request + ); + + $this->assertTrue($retry(0, $request, null, $exception)); + } + + /** @test */ + public function itDoesNotRetryNonRetriableConnectionErrors(): void + { + $retry = RetryMiddlewareFactory::getRetryFunctionByConnectionErrors(3); + $request = new Request('GET', 'https://api.hubapi.com/test'); + $exception = new ConnectException( + 'cURL error 60: SSL certificate problem', + $request, + null, + ['errno' => 60] + ); + + $this->assertFalse($retry(0, $request, null, $exception)); + } + + /** @test */ + public function itStopsRetryingWhenMaxRetriesReached(): void + { + $retry = RetryMiddlewareFactory::getRetryFunctionByConnectionErrors(1); + $request = new Request('GET', 'https://api.hubapi.com/test'); + $exception = new ConnectException( + 'cURL error 56: OpenSSL SSL_read unexpected eof while reading', + $request, + null, + ['errno' => 56] + ); + + $this->assertFalse($retry(1, $request, null, $exception)); + } +} From 49eaa58076fefe8b34d485b5a1ec3a395acb452a Mon Sep 17 00:00:00 2001 From: ascriver <34779562+AScriver@users.noreply.github.com> Date: Fri, 8 May 2026 07:37:33 -0700 Subject: [PATCH 2/2] Fix retry decider for connection exceptions --- lib/RetryMiddlewareFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/RetryMiddlewareFactory.php b/lib/RetryMiddlewareFactory.php index 8a624df82..e78bf72ff 100644 --- a/lib/RetryMiddlewareFactory.php +++ b/lib/RetryMiddlewareFactory.php @@ -2,7 +2,7 @@ namespace HubSpot; -use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; @@ -153,13 +153,13 @@ public static function getRetryFunctionByConnectionErrors( $retries, Request $request, ?Response $response = null, - ?RequestException $exception = null + $exception = null ) use ($maxRetries, $curlErrorCodes) { if ($retries >= $maxRetries) { return false; } - if (!$exception instanceof RequestException) { + if (!$exception instanceof ConnectException) { return false; }