Skip to content

Commit d6f8c5e

Browse files
Introduce shouldRetry Flag for Automatic 429 Retry Handling (#30)
* introducing `shouldRetry` * Limiting retryal only for 429 responses * changelog and readme update for `shouldRetry`
1 parent 6aae189 commit d6f8c5e

File tree

5 files changed

+240
-40
lines changed

5 files changed

+240
-40
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 1.0.10 - 2025-12-12
4+
- Introduce `shouldRetry` flag for automatic 429 retry handling
5+
36
## 1.0.9 - 2025-12-10
47
- Story management improvements:
58
- Adding `publish` parameter in `create()` method for publishing the story immediately

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ require 'vendor/autoload.php';
4444

4545
use Storyblok\ManagementApi\ManagementApiClient;
4646

47-
/** @var ManagementApiClient $client */
4847
$client = new ManagementApiClient($storyblokPersonalAccessToken);
4948
```
5049

@@ -54,6 +53,33 @@ For using the `ManagementApiClient` class you have to import:
5453
use Storyblok\ManagementApi\ManagementApiClient;
5554
```
5655

56+
### Automatic 429 retry handling
57+
58+
The `ManagementApiClient` supports automatic retry handling when the API returns *HTTP 429 – Too Many Requests*.
59+
60+
To enable this behavior, pass the `shouldRetry` flag when creating the client.
61+
Under the hood, this activates Symfony’s `RetriableHttpClient`, which automatically retries failed requests using a backoff strategy.
62+
63+
```php
64+
<?php
65+
require 'vendor/autoload.php';
66+
67+
use Storyblok\ManagementApi\ManagementApiClient;
68+
69+
$client = new ManagementApiClient(
70+
token: $storyblokPersonalAccessToken,
71+
shouldRetry: true
72+
);
73+
```
74+
75+
When enabled:
76+
77+
- Requests returning **429** are retried automatically
78+
- Backoff logic is handled internally by the Symfony HTTP Client
79+
- No custom retry code is required in your application
80+
81+
This feature helps maintain stability when hitting Storyblok API rate limits and reduces the need for manual retry logic.
82+
5783
### Setting the space region
5884

5985
The second optional parameter is for setting the region.

src/ManagementApiClient.php

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
use Symfony\Contracts\HttpClient\HttpClientInterface;
2121
use Psr\Log\LoggerInterface;
2222
use Psr\Log\NullLogger;
23+
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
24+
use Symfony\Component\HttpClient\RetryableHttpClient;
2325

2426
/**
2527
* Class MapiClient
@@ -38,18 +40,27 @@ public function __construct(
3840
string $personalAccessToken,
3941
Region $region = Region::EU,
4042
?string $baseUri = null,
43+
bool $shouldRetry = false,
4144
) {
42-
$baseUriMapi = $baseUri ?? StoryblokUtils::baseUriFromRegionForMapi($region->value);
43-
$this->httpClient = HttpClient::create()
44-
->withOptions([
45-
'base_uri' => $baseUriMapi,
46-
'headers' =>
47-
[
48-
'Accept' => 'application/json',
49-
'Content-Type' => 'application/json',
50-
'Authorization' => $personalAccessToken,
51-
],
52-
]);
45+
$baseUriMapi =
46+
$baseUri ??
47+
StoryblokUtils::baseUriFromRegionForMapi($region->value);
48+
$httpClient = HttpClient::create();
49+
if ($shouldRetry) {
50+
$httpClient = new RetryableHttpClient(
51+
$httpClient,
52+
new GenericRetryStrategy([429]),
53+
);
54+
}
55+
56+
$this->httpClient = $httpClient->withOptions([
57+
"base_uri" => $baseUriMapi,
58+
"headers" => [
59+
"Accept" => "application/json",
60+
"Content-Type" => "application/json",
61+
"Authorization" => $personalAccessToken,
62+
],
63+
]);
5364
$this->httpAssetClient = HttpClient::create();
5465
}
5566

@@ -68,19 +79,20 @@ public static function initTest(
6879
HttpClientInterface $httpClient,
6980
?HttpClientInterface $httpAssetClient = null,
7081
): self {
71-
7282
$client = new self("");
7383
//$baseUriMapi = $baseUri ?? StoryblokUtils::baseUriFromRegionForMapi($region);
7484

7585
$client->httpClient = $httpClient;
76-
if ($httpAssetClient instanceof \Symfony\Contracts\HttpClient\HttpClientInterface) {
86+
if (
87+
$httpAssetClient instanceof
88+
\Symfony\Contracts\HttpClient\HttpClientInterface
89+
) {
7790
$client->httpAssetClient = $httpAssetClient;
7891
} else {
7992
$client->httpAssetClient = new MockHttpClient();
8093
}
8194

8295
return $client;
83-
8496
}
8597

8698
public function httpClient(): HttpClientInterface
@@ -93,22 +105,18 @@ public function httpAssetClient(): HttpClientInterface
93105
return $this->httpAssetClient;
94106
}
95107

96-
public function storyApi(string|int $spaceId, ?LoggerInterface $logger = null): StoryApi
97-
{
98-
return new StoryApi(
99-
$this,
100-
$spaceId,
101-
$logger ?? new NullLogger(),
102-
);
108+
public function storyApi(
109+
string|int $spaceId,
110+
?LoggerInterface $logger = null,
111+
): StoryApi {
112+
return new StoryApi($this, $spaceId, $logger ?? new NullLogger());
103113
}
104114

105-
public function storyBulkApi(string|int $spaceId, ?LoggerInterface $logger = null): StoryBulkApi
106-
{
107-
return new StoryBulkApi(
108-
$this,
109-
$spaceId,
110-
$logger ?? new NullLogger(),
111-
);
115+
public function storyBulkApi(
116+
string|int $spaceId,
117+
?LoggerInterface $logger = null,
118+
): StoryBulkApi {
119+
return new StoryBulkApi($this, $spaceId, $logger ?? new NullLogger());
112120
}
113121

114122
public function assetApi(string|int $spaceId): AssetApi
@@ -131,13 +139,11 @@ public function workflowStageApi(string|int $spaceId): WorkflowStageApi
131139
return new WorkflowStageApi($this, $spaceId);
132140
}
133141

134-
public function componentApi(string|int $spaceId, ?LoggerInterface $logger = null): ComponentApi
135-
{
136-
return new ComponentApi(
137-
$this,
138-
$spaceId,
139-
$logger ?? new NullLogger(),
140-
);
142+
public function componentApi(
143+
string|int $spaceId,
144+
?LoggerInterface $logger = null,
145+
): ComponentApi {
146+
return new ComponentApi($this, $spaceId, $logger ?? new NullLogger());
141147
}
142148

143149
public function managementApi(): ManagementApi
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
use Storyblok\ManagementApi\Endpoints\StoryApi;
4+
use Storyblok\ManagementApi\ManagementApiClient;
5+
use Storyblok\ManagementApi\QueryParameters\Filters\Filter;
6+
use Storyblok\ManagementApi\QueryParameters\Filters\QueryFilters;
7+
use Storyblok\ManagementApi\RateLimitRetryService;
8+
use Storyblok\ManagementApi\Response\StoriesResponse;
9+
use Symfony\Component\Console\Logger\ConsoleLogger;
10+
use Symfony\Component\Console\Output\ConsoleOutput;
11+
use Symfony\Component\HttpClient\MockHttpClient;
12+
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
13+
use Symfony\Component\HttpClient\RetryableHttpClient;
14+
15+
test("Testing retry mechanism with list stories", function (): void {
16+
$responses = [
17+
\mockResponse("list-stories-page-1", 429),
18+
\mockResponse("list-stories-page-1", 429),
19+
\mockResponse("list-stories-page-1", 200, [
20+
"total" => 6,
21+
"per-page" => 2,
22+
"page" => 1,
23+
]),
24+
\mockResponse("list-stories-page-2", 429),
25+
\mockResponse("list-stories-page-2", 429),
26+
\mockResponse("list-stories-page-2", 200, [
27+
"total" => 6,
28+
"per-page" => 2,
29+
"page" => 2,
30+
]),
31+
\mockResponse("list-stories-page-3", 200, [
32+
"total" => 6,
33+
"per-page" => 2,
34+
"page" => 3,
35+
]),
36+
37+
//\mockResponse("empty-asset", 404),
38+
];
39+
$client = new RetryableHttpClient(
40+
new MockHttpClient($responses),
41+
new GenericRetryStrategy([429], delayMs: 0),
42+
);
43+
$mapiClient = ManagementApiClient::initTest($client);
44+
$storyApi = new StoryApi($mapiClient, "222");
45+
46+
$storyResponse = $storyApi->page(
47+
queryFilters: new QueryFilters()->add(
48+
new Filter("headline", "like", "Development"),
49+
),
50+
);
51+
expect($storyResponse->data()->get("0.slug"))->toBe("my-first-post");
52+
$storyResponse = $storyApi->page(
53+
queryFilters: new QueryFilters()->add(
54+
new Filter("headline", "like", "Development"),
55+
),
56+
);
57+
expect($storyResponse->data()->get("0.slug"))->toBe("my-third-post");
58+
});
59+
60+
test("Testing retry mechanism with list stories 2", function (): void {
61+
$responses = [
62+
\mockResponse("list-stories-page-1", 429),
63+
\mockResponse("list-stories-page-1", 429),
64+
\mockResponse("list-stories-page-1", 200, [
65+
"Total" => 6,
66+
"per-page" => 2,
67+
"page" => 1,
68+
]),
69+
\mockResponse("list-stories-page-2", 429),
70+
\mockResponse("list-stories-page-2", 429),
71+
\mockResponse("list-stories-page-2", 200, [
72+
"total" => 6,
73+
"per-page" => 2,
74+
"page" => 2,
75+
]),
76+
\mockResponse("list-stories-page-3", 200, [
77+
"total" => 6,
78+
"per-page" => 2,
79+
"page" => 3,
80+
]),
81+
82+
//\mockResponse("empty-asset", 404),
83+
];
84+
85+
$client = new RetryableHttpClient(
86+
new MockHttpClient($responses),
87+
new GenericRetryStrategy([429], delayMs: 0),
88+
);
89+
$mapiClient = ManagementApiClient::initTest($client);
90+
$storyApi = new StoryApi($mapiClient, "222");
91+
92+
$storyResponse = $storyApi->page(
93+
queryFilters: new QueryFilters()->add(
94+
new Filter("headline", "like", "Development"),
95+
),
96+
);
97+
expect($storyResponse->data()->get("0.slug"))->toBe("my-first-post");
98+
expect($storyResponse->total())->toBe(6);
99+
expect($storyResponse->data()->count())->toBe(2);
100+
101+
$storyResponse = $storyApi->page(
102+
queryFilters: new QueryFilters()->add(
103+
new Filter("headline", "like", "Development"),
104+
),
105+
);
106+
expect($storyResponse->data()->get("0.slug"))->toBe("my-third-post");
107+
});
108+
109+
test(
110+
"Testing retry mechanism with list stories with 500 exception",
111+
function (): void {
112+
$responses = [
113+
\mockResponse("list-stories-page-1", 429),
114+
\mockResponse("list-stories-page-1", 500),
115+
\mockResponse("list-stories-page-1", 200, [
116+
"Total" => 6,
117+
"per-page" => 2,
118+
"page" => 1,
119+
]),
120+
\mockResponse("list-stories-page-2", 429),
121+
\mockResponse("list-stories-page-2", 429),
122+
\mockResponse("list-stories-page-2", 200, [
123+
"total" => 6,
124+
"per-page" => 2,
125+
"page" => 2,
126+
]),
127+
\mockResponse("list-stories-page-3", 200, [
128+
"total" => 6,
129+
"per-page" => 2,
130+
"page" => 3,
131+
]),
132+
133+
//\mockResponse("empty-asset", 404),
134+
];
135+
136+
$client = new RetryableHttpClient(
137+
new MockHttpClient($responses),
138+
new GenericRetryStrategy([429], delayMs: 0),
139+
);
140+
$mapiClient = ManagementApiClient::initTest($client);
141+
$storyApi = new StoryApi($mapiClient, "222");
142+
143+
$storyResponse = $storyApi->page(
144+
queryFilters: new QueryFilters()->add(
145+
new Filter("headline", "like", "Development"),
146+
),
147+
);
148+
149+
expect(
150+
fn(): StoriesResponse => $storyApi->page(
151+
queryFilters: new QueryFilters()->add(
152+
new Filter("headline", "like", "Development"),
153+
),
154+
),
155+
)->toThrow(
156+
\Symfony\Component\HttpClient\Exception\ServerException::class,
157+
"HTTP 500 returned",
158+
);
159+
160+
$storyResponse = $storyApi->page(
161+
queryFilters: new QueryFilters()->add(
162+
new Filter("headline", "like", "Development"),
163+
),
164+
);
165+
expect($storyResponse->data()->get("0.slug"))->toBe("my-first-post");
166+
expect($storyResponse->total())->toBe(6);
167+
expect($storyResponse->perPage())->toBe(2);
168+
},
169+
);

tests/Feature/StoryBulkTest.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@
22

33
declare(strict_types=1);
44

5-
use Pest\Logging\TeamCity\TeamCityLogger;
6-
use PHPUnit\Logging\JUnit\JunitXmlLogger;
7-
use Storyblok\ManagementApi\Endpoints\StoryBulkApi;
85
use Storyblok\ManagementApi\ManagementApiClient;
96
use Storyblok\ManagementApi\Data\Story;
107
use Storyblok\ManagementApi\QueryParameters\StoriesParams;
118
use Symfony\Component\HttpClient\Response\MockResponse;
129
use Symfony\Component\HttpClient\Response\JsonMockResponse;
1310
use Symfony\Component\HttpClient\MockHttpClient;
1411
use Psr\Log\NullLogger;
15-
use Symfony\Component\Console\Logger\ConsoleLogger;
1612

1713
// This is a mock class to eliminate the sleep from the rate limit handling
1814
class TestStoryBulkApi extends \Storyblok\ManagementApi\Endpoints\StoryBulkApi

0 commit comments

Comments
 (0)