Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/phpspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ jobs:
build:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
guzzle: ['^7.3', '^8.0']

name: "phpspec (guzzle ${{ matrix.guzzle }})"

env:
GUZZLE_CONSTRAINT: ${{ matrix.guzzle }}

steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: composer require "guzzlehttp/guzzle:$GUZZLE_CONSTRAINT" --no-update --no-interaction
- run: composer update --no-interaction --no-progress
- run: vendor/bin/phpspec run
13 changes: 12 additions & 1 deletion .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,22 @@ jobs:
build:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
guzzle: ['^7.3', '^8.0']

name: "phpunit (guzzle ${{ matrix.guzzle }})"

env:
GUZZLE_CONSTRAINT: ${{ matrix.guzzle }}

steps:
- uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install
- run: composer require "guzzlehttp/guzzle:$GUZZLE_CONSTRAINT" --no-update --no-interaction
- run: composer update --no-interaction --no-progress
- run: vendor/bin/phpunit ./tests
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased](https://github.com/HubSpot/hubspot-api-php/compare/14.1.0...HEAD)

### Guzzle 8 support

- `guzzlehttp/guzzle` `^8.0` and `guzzlehttp/psr7` `^3.0` are now allowed; Guzzle 7 remains supported.
- Generated clients no longer call the removed `\GuzzleHttp\Utils::jsonEncode()`; they use `json_encode(..., JSON_THROW_ON_ERROR)`.
- Generated clients no longer assume `RequestException::getResponse()` exists (removed in Guzzle 8, where only `ResponseException` subclasses carry a response). Failures without a response now produce an `ApiException` instead of a fatal error: with `null` headers/body on the sync path, and with empty headers/body (`[]` and `''`) on the async path, which is unchanged.
- Generated clients catch `Psr\Http\Client\NetworkExceptionInterface` instead of `GuzzleHttp\Exception\ConnectException`, so Guzzle 8's `NetworkException` (send/receive errors, HTTP/2 and HTTP/3 failures) is still converted to an `ApiException`.
- `RetryMiddlewareFactory::getRetryFunctionByConnectionErrors()` now matches `Psr\Http\Client\NetworkExceptionInterface` and reads the cURL errno from handler context when available, falling back to the exception message because Guzzle 8 removed `RequestException::getHandlerContext()` and reclassified cURL errors 52, 55 and 56 as `NetworkException` rather than `ConnectException`. Failures that happen after the response headers arrived are also retried, whether they are reported as Guzzle 8's `ResponseTransferException` or as Guzzle 7's `RequestException` with cURL handler context. Which cURL errors are retried always follows the `$curlErrorCodes` argument, on both majors.
- `apiRequest()` uppercases the `method` option. Guzzle 7 uppercased request methods, Guzzle 8 sends them verbatim.
- The retry decider callbacks accept any PSR-7 `RequestInterface`/`ResponseInterface` instead of only `GuzzleHttp\Psr7\Request`/`Response`.

## [14.1.0](https://github.com/HubSpot/hubspot-api-php/releases/tag/14.1.0) - 2026-05-12

### Retry Middleware
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ The current package requirements are:

PHP >= 8.1

Guzzle 7 and Guzzle 8 are both supported (`guzzlehttp/guzzle: ^7.3 || ^8.0`).

### Sample apps

Please, take a look at our [Sample apps](https://github.com/HubSpot/sample-apps-list)
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/guzzle": "^7.3",
"guzzlehttp/psr7": "^1.7 || ^2.0"
"guzzlehttp/guzzle": "^7.3 || ^8.0",
"guzzlehttp/psr7": "^1.7 || ^2.0 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.94",
Expand Down
2 changes: 1 addition & 1 deletion lib/Delay.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static function getLinearDelayFunction()
}

/**
* @deprecated pass null as the delay function instead — Guzzle will apply its built-in exponential delay via \GuzzleHttp\RetryMiddleware::exponentialDelay
* @deprecated pass null as the delay function instead - \GuzzleHttp\RetryMiddleware then applies its built-in exponential delay
*/
public static function getExponentialDelayFunction(int $base)
{
Expand Down
3 changes: 2 additions & 1 deletion lib/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public function __construct(Config $config, array $options = [])
if (array_key_exists('defaultJson', $this->options)) {
$this->defaultJson = $this->options['defaultJson'];
}
$this->method = $this->options['method'] ?? 'GET';
// Guzzle 7 uppercased request methods, Guzzle 8 sends them verbatim.
$this->method = strtoupper($this->options['method'] ?? 'GET');

$this->initHeaders();
$this->applyAuth();
Expand Down
71 changes: 49 additions & 22 deletions lib/RetryMiddlewareFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

namespace HubSpot;

use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ResponseTransferException;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class RetryMiddlewareFactory
{
Expand Down Expand Up @@ -92,14 +94,14 @@ public static function getRetryFunctionByRanges(
): callable {
return function (
$retries,
Request $request,
?Response $response = null
RequestInterface $request,
?ResponseInterface $response = null
) use ($ranges, $maxRetries) {
if ($retries >= $maxRetries) {
return false;
}

if (!$response instanceof Response) {
if (!$response instanceof ResponseInterface) {
return false;
}

Expand Down Expand Up @@ -130,14 +132,14 @@ public static function getRetryFunction(
): callable {
return function (
$retries,
Request $request,
?Response $response = null
RequestInterface $request,
?ResponseInterface $response = null
) use ($codes, $maxRetries) {
if ($retries >= $maxRetries) {
return false;
}

if (($response instanceof Response) && in_array($response->getStatusCode(), $codes)) {
if (($response instanceof ResponseInterface) && in_array($response->getStatusCode(), $codes)) {
return true;
}

Expand All @@ -151,34 +153,59 @@ public static function getRetryFunctionByConnectionErrors(
): callable {
return function (
$retries,
Request $request,
?Response $response = null,
RequestInterface $request,
?ResponseInterface $response = null,
$exception = null
) use ($maxRetries, $curlErrorCodes) {
if ($retries >= $maxRetries) {
return false;
}

if (!$exception instanceof ConnectException) {
// Guzzle 7 exposes cURL context; Guzzle 8 exposes only the message.
$context = ($exception instanceof RequestException || $exception instanceof NetworkExceptionInterface)
&& method_exists($exception, 'getHandlerContext')
? $exception->getHandlerContext()
: [];
$errno = $context['errno'] ?? null;
if (!is_numeric($errno) && $exception instanceof \Throwable
&& 1 === preg_match('/cURL error\s+(\d+):/i', $exception->getMessage(), $matches)) {
$errno = $matches[1];
}

if (!static::isTransferFailure($exception, $context, $errno)) {
return false;
}

if (empty($curlErrorCodes)) {
return true;
}

$handlerContext = $exception->getHandlerContext();
$errno = $handlerContext['errno'] ?? null;

if (is_numeric($errno) && in_array((int) $errno, $curlErrorCodes, true)) {
return true;
}
return is_numeric($errno) && in_array((int) $errno, $curlErrorCodes, true);
};
}

if (1 === preg_match('/cURL error\s+(\d+):/i', $exception->getMessage(), $matches)) {
return in_array((int) $matches[1], $curlErrorCodes, true);
}
/**
* Identifies transfer failures; the caller filters cURL error codes.
* Guzzle 7 uses handler context; Guzzle 8 uses specific exception types.
*
* @param mixed $exception rejection reason, not necessarily a Throwable
* @param array $context cURL handler context, empty on Guzzle 8
* @param mixed $errno cURL errno from the context or the message
*/
protected static function isTransferFailure($exception, array $context, $errno): bool
{
if ($exception instanceof NetworkExceptionInterface) {
return true;
}

if (!$exception instanceof RequestException) {
return false;
};
}

if (is_numeric($context['errno'] ?? null)) {
return true;
}

return $exception instanceof ResponseTransferException && is_numeric($errno);
}
}
Loading
Loading