Skip to content

Commit c741a48

Browse files
committed
Fix retry/circuit breaker plugins to detect HTTP 4xx/5xx failures via Promise outcome, not synchronous throw
1 parent 7836c9d commit c741a48

15 files changed

Lines changed: 397 additions & 23 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [4.1.1] - 2026-07-13
8+
### Fixed
9+
- Retry and circuit breaker plugins now detect failures correctly: they previously assumed the HTTP client throws synchronously on error, but a spec-compliant PSR-18 client returns 4xx/5xx responses without throwing, and `ErrorPlugin` (when used) only surfaces failures as a rejected Promise. Both `RetryHandler` and `CircuitBreaker` now inspect the actual resolved outcome (status code or exception) of each attempt, so `configureRetry()`/`configureFixedRetry()`/`enableReliability()` retry on 429/5xx responses as documented.
10+
11+
## [4.1.0] - 2026-07-03
12+
### Added
13+
- HTTP 429 rate-limit handling: retry strategies now honor the `x-rate-limit-reset` response header (capped by `max_delay_ms`) instead of relying solely on generic backoff.
14+
- `RateLimitExceededException`, thrown once retries are exhausted on a 429, exposing `getRetryAfterSeconds()` and `getResponse()`.
15+
716
## [4.0.0] - 2026-06-28
817
### Added
918
- Full HTTP observability tools: structured logging with token masking, per-status log levels, and custom logger support.

src/Exception/RateLimitExceededException.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ public function __construct(
2626

2727
/**
2828
* Returns the response.
29-
*
30-
* @return ResponseInterface
3129
*/
3230
public function getResponse(): ResponseInterface
3331
{

src/GeocachingSdk.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,7 @@ private function createStatusHttpClient(): HttpMethodsClientInterface
986986
$rawHttpClient = \Http\Discovery\Psr18ClientDiscovery::find();
987987
$reflection = new \ReflectionClass($this->clientBuilder);
988988
$pluginsProperty = $reflection->getProperty('plugins');
989-
$allPlugins = $pluginsProperty->getValue($this->clientBuilder);
989+
$allPlugins = $pluginsProperty->getValue($this->clientBuilder);
990990

991991
$statusPlugins = [];
992992
foreach ($allPlugins as $plugin) {
@@ -999,7 +999,7 @@ private function createStatusHttpClient(): HttpMethodsClientInterface
999999
$pluginClient = (new \Http\Client\Common\PluginClientFactory())->createClient($rawHttpClient, $statusPlugins);
10001000

10011001
$requestFactory = $reflection->getProperty('requestFactory');
1002-
$streamFactory = $reflection->getProperty('streamFactory');
1002+
$streamFactory = $reflection->getProperty('streamFactory');
10031003

10041004
return new \Http\Client\Common\HttpMethodsClient(
10051005
$pluginClient,

src/Options.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
use Geocaching\Enum\Environment;
99
use Geocaching\Plugin\CircuitBreakerPlugin;
1010
use Geocaching\Plugin\GeocachingHttpLoggerPlugin;
11-
use Geocaching\Plugin\StorageAwareAuthenticationPlugin;
1211
use Geocaching\Plugin\ReliabilityPlugin;
1312
use Geocaching\Plugin\RetryPlugin;
13+
use Geocaching\Plugin\StorageAwareAuthenticationPlugin;
1414
use Geocaching\Reliability\CircuitBreaker;
1515
use Geocaching\Reliability\ExponentialBackoffStrategy;
1616
use Geocaching\Reliability\FixedDelayStrategy;
@@ -19,8 +19,8 @@
1919
use Http\Client\Common\Plugin\BaseUriPlugin;
2020
use Http\Discovery\Psr17FactoryDiscovery;
2121
use Http\Message\Authentication\Bearer;
22-
use League\OAuth2\Client\Provider\Geocaching;
2322
use League\OAuth2\Client\Plugin\TokenRefreshPlugin;
23+
use League\OAuth2\Client\Provider\Geocaching;
2424
use League\OAuth2\Client\Token\TokenStorageInterface;
2525
use Monolog\Formatter\LineFormatter;
2626
use Monolog\Handler\StreamHandler;

src/Plugin/CircuitBreakerPlugin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,6 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
4242
}
4343

4444
// Execute request with circuit breaker protection
45-
return $this->circuitBreaker->call(fn() => $next($request));
45+
return $this->circuitBreaker->call(fn () => $next($request));
4646
}
4747
}

src/Plugin/ReliabilityPlugin.php

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44

55
namespace Geocaching\Plugin;
66

7+
use Geocaching\Exception\CircuitBreakerOpenException;
78
use Geocaching\Reliability\CircuitBreaker;
89
use Geocaching\Reliability\RetryHandler;
910
use Geocaching\Reliability\RetryStrategy;
1011
use Http\Client\Common\Plugin;
1112
use Http\Promise\Promise;
1213
use Psr\Http\Message\RequestInterface;
14+
use Psr\Http\Message\ResponseInterface;
1315
use Psr\Log\LoggerInterface;
1416
use Psr\Log\NullLogger;
1517

@@ -34,10 +36,25 @@ public function __construct(
3436

3537
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
3638
{
37-
// First layer: Circuit Breaker protection
38-
return $this->circuitBreaker->call(fn() =>
39-
// Second layer: Retry logic
40-
$this->retryHandler->execute(fn() => $next($request)));
39+
if (!$this->circuitBreaker->canExecute()) {
40+
throw new CircuitBreakerOpenException(
41+
"Circuit breaker is open. Next retry at: " .
42+
$this->circuitBreaker->getNextRetryTime()?->format('Y-m-d H:i:s')
43+
);
44+
}
45+
46+
return $this->retryHandler->executeRequest($request, $next)->then(
47+
function (ResponseInterface $response) {
48+
$this->circuitBreaker->recordSuccess();
49+
50+
return $response;
51+
},
52+
function (\Throwable $exception) {
53+
$this->circuitBreaker->recordFailure();
54+
55+
throw $exception;
56+
}
57+
);
4158
}
4259

4360
/**

src/Plugin/RetryPlugin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public function __construct(
2828

2929
public function handleRequest(RequestInterface $request, callable $next, callable $first): Promise
3030
{
31-
return $this->retryHandler->execute(fn() => $next($request));
31+
return $this->retryHandler->executeRequest($request, $next);
3232
}
3333

3434
/**

src/Reliability/CircuitBreaker.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ public function canExecute(): bool
6464
return !$this->isOpen();
6565
}
6666

67+
/**
68+
* Record a successful call, e.g. after resolving a Promise as fulfilled.
69+
*/
70+
public function recordSuccess(): void
71+
{
72+
$this->onSuccess();
73+
}
74+
75+
/**
76+
* Record a failed call, e.g. after resolving a Promise as rejected.
77+
*/
78+
public function recordFailure(): void
79+
{
80+
$this->onFailure();
81+
}
82+
6783
/**
6884
* Get current circuit breaker state
6985
*/

src/Reliability/ExponentialBackoffStrategy.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Http\Client\Exception\HttpException;
88
use Http\Client\Exception\NetworkException;
9-
use Psr\Http\Message\ResponseInterface;
109

1110
/**
1211
* Exponential backoff retry strategy
@@ -91,4 +90,9 @@ public function getMaxAttempts(): int
9190
{
9291
return $this->maxAttempts;
9392
}
93+
94+
public function isRetryableStatusCode(int $statusCode): bool
95+
{
96+
return in_array($statusCode, $this->retryableStatusCodes, true);
97+
}
9498
}

src/Reliability/FixedDelayStrategy.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Http\Client\Exception\HttpException;
88
use Http\Client\Exception\NetworkException;
9-
use Psr\Http\Message\ResponseInterface;
109

1110
/**
1211
* Fixed delay retry strategy
@@ -80,4 +79,9 @@ public function getMaxAttempts(): int
8079
{
8180
return $this->maxAttempts;
8281
}
82+
83+
public function isRetryableStatusCode(int $statusCode): bool
84+
{
85+
return in_array($statusCode, $this->retryableStatusCodes, true);
86+
}
8387
}

0 commit comments

Comments
 (0)