Skip to content

Commit b6e543c

Browse files
committed
fix(cli): retry mechanism when multiple systems query the MAPI
When multiple systems hit the MAPI with the same access token, the retry mechanisms quickly gave up. By decreasing the semaphore batch size and increasing the max retries, we make such situations less likely. However, it is still possible to run into limits when many systems query the MAPI with the same access token in parallel. See the following article for the rational behind the full jitter retry delay: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ Fixes WDX-184
1 parent d570a6c commit b6e543c

File tree

11 files changed

+60
-21
lines changed

11 files changed

+60
-21
lines changed

packages/cli/src/commands/migrations/run/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ migrationsCommand.command('run [componentName]')
114114
query,
115115
starts_with: startsWith,
116116
},
117-
batchSize: 100,
117+
batchSize: 12,
118118
onTotal: (total) => {
119119
storiesProgress.setTotal(total);
120120
migrationsProgress.setTotal(total);
@@ -141,7 +141,7 @@ migrationsCommand.command('run [componentName]')
141141
space,
142142
publish,
143143
dryRun,
144-
batchSize: 100,
144+
batchSize: 12,
145145
onProgress: () => {
146146
updateProgress.increment();
147147
},

packages/cli/src/commands/migrations/run/streams/stories-stream.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,7 @@ class StoriesStream extends Transform {
9191
objectMode: true,
9292
});
9393

94-
this.semaphore = new Sema(this.batchSize, {
95-
capacity: this.batchSize,
96-
});
94+
this.semaphore = new Sema(this.batchSize);
9795
}
9896

9997
async _transform(chunk: Omit<Story, 'content'>, _encoding: string, callback: (error?: Error | null, data?: any) => void) {

packages/cli/src/commands/migrations/run/streams/update-stream.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ export class UpdateStream extends Writable {
4040
totalProcessed: 0,
4141
};
4242

43-
this.semaphore = new Sema(this.batchSize, {
44-
capacity: this.batchSize,
45-
});
43+
this.semaphore = new Sema(this.batchSize);
4644
}
4745

4846
async _write(chunk: { storyId: number; name: string | undefined; content: StoryContent; published?: boolean; unpublished_changes?: boolean }, _encoding: string, callback: (error?: Error | null) => void) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Calculates the delay for the next retry attempt using exponential backoff with full jitter.
3+
*
4+
* @param attempt The current retry attempt number (starting from 0 for the first retry).
5+
* @param baseDelay The initial delay in milliseconds (e.g., 100).
6+
* @param maxDelay The maximum possible delay in milliseconds (e.g., 20000).
7+
* @returns The calculated delay in milliseconds to wait before the next attempt.
8+
*/
9+
export function calculateRetryDelay(
10+
attempt: number,
11+
baseDelay: number = 100,
12+
maxDelay: number = 20000,
13+
): number {
14+
const exponentialBackoff = baseDelay * 2 ** attempt;
15+
const cappedBackoff = Math.min(exponentialBackoff, maxDelay);
16+
// Apply full jitter: a random value between 0 and the capped backoff
17+
const jitter = Math.random() * cappedBackoff;
18+
19+
return jitter;
20+
}

packages/cli/src/utils/delay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

packages/cli/src/utils/fetch.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { delay } from './delay';
2+
import { calculateRetryDelay } from './calculate-retry-delay';
3+
14
export class FetchError extends Error {
25
response: {
36
status: number;
@@ -12,8 +15,6 @@ export class FetchError extends Error {
1215
}
1316
}
1417

15-
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
16-
1718
export interface FetchOptions {
1819
headers?: Record<string, string>;
1920
method?: string;
@@ -64,8 +65,7 @@ export async function customFetch<T>(url: string, options: FetchOptions = {}): P
6465
if (!response.ok) {
6566
// If we hit rate limit and have retries left
6667
if ((response.status === 429) && (attempt < maxRetries)) {
67-
const waitTime = baseDelay * 2 ** attempt;
68-
await delay(waitTime);
68+
await delay(calculateRetryDelay(attempt, baseDelay));
6969
attempt++;
7070
continue;
7171
}

packages/mapi-client/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,15 @@ The client includes built-in retry handling for rate limits and network errors:
140140

141141
```typescript
142142
// The client automatically handles retries with these defaults:
143-
// - maxRetries: 3
143+
// - maxRetries: 12
144144
// - retryDelay: 1000ms
145145
// - Respects retry-after headers from 429 responses
146146

147147
const stories = await client.stories.list({
148148
path: { space_id: 123456 },
149149
query: { per_page: 10 }
150150
});
151-
// If rate limited, will automatically retry up to 3 times
151+
// If rate limited, will automatically retry up to 12 times
152152
```
153153

154154
## Runtime Configuration

packages/mapi-client/src/__tests__/integration.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,12 @@ describe('ManagementApiClient Integration - Per-Instance Rate Limiting', () => {
103103
const promise = client.spaces.list({});
104104

105105
// Advance timers to allow all retries
106-
await vi.advanceTimersByTimeAsync(5000);
106+
await vi.advanceTimersByTimeAsync(14000);
107107

108108
const result = await promise;
109109

110-
// Should make initial + maxRetries calls (3 retries = 4 total)
111-
expect(mockFetch).toHaveBeenCalledTimes(4);
110+
// Should make initial + maxRetries calls (12 retries = 13 total)
111+
expect(mockFetch).toHaveBeenCalledTimes(13);
112112

113113
// Result should contain the error since all retries failed
114114
expect(result).toBeDefined();

packages/mapi-client/src/client/client.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mergeHeaders,
1010
setAuthParams,
1111
} from './utils';
12+
import { calculateRetryDelay } from "../utils/calculate-retry-delay";
13+
import { delay } from "../utils/delay";
1214

1315
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
1416
body?: any;
@@ -90,7 +92,7 @@ export const createClient = (config: Config): Client => {
9092

9193
// Execute with retry logic by recreating the request for each attempt
9294
let response = await executeWithRetry(_fetch, url, requestInit, {
93-
maxRetries: 3,
95+
maxRetries: 12,
9496
retryDelay: 1000
9597
});
9698

@@ -206,9 +208,8 @@ export const createClient = (config: Config): Client => {
206208

207209
if (response.status === 429 && attempt < retryConfig.maxRetries) {
208210
const retryAfter = response.headers.get('retry-after');
209-
const delay = retryAfter ? parseInt(retryAfter) * 1000 : retryConfig.retryDelay;
210-
211-
await new Promise(resolve => setTimeout(resolve, delay));
211+
const retryDelay = retryAfter ? parseInt(retryAfter) * 1000 : calculateRetryDelay(attempt, retryConfig.retryDelay);
212+
await delay(retryDelay);
212213

213214
// Use the original unconsumed request for retry
214215
return executeWithRetry(fetchFn, url, requestInit, retryConfig, attempt + 1);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Calculates the delay for the next retry attempt using exponential backoff with full jitter.
3+
*
4+
* @param attempt The current retry attempt number (starting from 0 for the first retry).
5+
* @param baseDelay The initial delay in milliseconds (e.g., 100).
6+
* @param maxDelay The maximum possible delay in milliseconds (e.g., 20000).
7+
* @returns The calculated delay in milliseconds to wait before the next attempt.
8+
*/
9+
export function calculateRetryDelay(
10+
attempt: number,
11+
baseDelay: number = 100,
12+
maxDelay: number = 20000,
13+
): number {
14+
const exponentialBackoff = baseDelay * 2 ** attempt;
15+
const cappedBackoff = Math.min(exponentialBackoff, maxDelay);
16+
// Apply full jitter: a random value between 0 and the capped backoff
17+
const jitter = Math.random() * cappedBackoff;
18+
19+
return jitter;
20+
}

0 commit comments

Comments
 (0)