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
33 changes: 27 additions & 6 deletions packages/cli/src/commands/datasources/pull/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { mapiClient } from '../../../api';
import type { SpaceDatasource, SpaceDatasourceEntry } from '../constants';
import { vol } from 'memfs';

const MAX_RETRY_DURATION = 14_000;

// Mock datasources data that matches the SpaceDatasource interface
const mockedDatasources: SpaceDatasource[] = [
{
Expand Down Expand Up @@ -109,6 +111,7 @@ vi.mock('node:fs/promises');

describe('pull datasources actions', () => {
beforeEach(() => {
vi.useFakeTimers();
mapiClient({
token: {
accessToken: 'valid-token',
Expand All @@ -117,9 +120,15 @@ describe('pull datasources actions', () => {
});
});

afterEach(() => {
vi.useRealTimers();
});

describe('fetchDatasources', () => {
it('should fetch datasources successfully with a valid token', async () => {
const result = await fetchDatasources('12345');
const resultPromise = fetchDatasources('12345');
await vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION);
const result = await resultPromise;

// Each datasource should now have an 'entries' property
expect(result).toHaveLength(2);
Expand All @@ -143,7 +152,9 @@ describe('pull datasources actions', () => {
});

it('should return datasources with correct structure', async () => {
const result = await fetchDatasources('12345');
const resultPromise = fetchDatasources('12345');
await vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION);
const result = await resultPromise;

expect(result).toBeDefined();

Expand Down Expand Up @@ -180,7 +191,9 @@ describe('pull datasources actions', () => {
}),
);

const result = await fetchDatasources('12345');
const resultPromise = fetchDatasources('12345');
await vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION);
const result = await resultPromise;

expect(result).toEqual([]);
expect(result).toHaveLength(0);
Expand Down Expand Up @@ -218,7 +231,10 @@ describe('pull datasources actions', () => {
}),
);

await expect(fetchDatasources('12345')).rejects.toThrow();
await expect(Promise.all([
fetchDatasources('12345'),
vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION),
])).rejects.toThrow();
});

it('should handle server errors', async () => {
Expand All @@ -229,7 +245,10 @@ describe('pull datasources actions', () => {
}),
);

await expect(fetchDatasources('12345')).rejects.toThrow();
await expect(Promise.all([
fetchDatasources('12345'),
vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION),
])).rejects.toThrow();
});

it('should make request to correct endpoint with space parameter', async () => {
Expand All @@ -245,7 +264,9 @@ describe('pull datasources actions', () => {
}),
);

await fetchDatasources('54321');
const resultPromise = fetchDatasources('54321');
await vi.advanceTimersByTimeAsync(MAX_RETRY_DURATION);
await resultPromise;

expect(requestUrl).toBe('https://mapi.storyblok.com/v1/spaces/54321/datasources');
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/migrations/run/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ migrationsCommand.command('run [componentName]')
query,
starts_with: startsWith,
},
batchSize: 100,
batchSize: 12,
onTotal: (total) => {
storiesProgress.setTotal(total);
migrationsProgress.setTotal(total);
Expand All @@ -141,7 +141,7 @@ migrationsCommand.command('run [componentName]')
space,
publish,
dryRun,
batchSize: 100,
batchSize: 12,
onProgress: () => {
updateProgress.increment();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,7 @@ class StoriesStream extends Transform {
objectMode: true,
});

this.semaphore = new Sema(this.batchSize, {
capacity: this.batchSize,
});
this.semaphore = new Sema(this.batchSize);
}

async _transform(chunk: Omit<Story, 'content'>, _encoding: string, callback: (error?: Error | null, data?: any) => void) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ export class UpdateStream extends Writable {
totalProcessed: 0,
};

this.semaphore = new Sema(this.batchSize, {
capacity: this.batchSize,
});
this.semaphore = new Sema(this.batchSize);
}

async _write(chunk: { storyId: number; name: string | undefined; content: StoryContent; published?: boolean; unpublished_changes?: boolean }, _encoding: string, callback: (error?: Error | null) => void) {
Expand Down
4 changes: 2 additions & 2 deletions packages/mapi-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,15 @@ The client includes built-in retry handling for rate limits and network errors:

```typescript
// The client automatically handles retries with these defaults:
// - maxRetries: 3
// - maxRetries: 12
// - retryDelay: 1000ms
// - Respects retry-after headers from 429 responses

const stories = await client.stories.list({
path: { space_id: 123456 },
query: { per_page: 10 }
});
// If rate limited, will automatically retry up to 3 times
// If rate limited, will automatically retry up to 12 times
```

## Runtime Configuration
Expand Down
6 changes: 3 additions & 3 deletions packages/mapi-client/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@ describe('ManagementApiClient Integration - Per-Instance Rate Limiting', () => {
const promise = client.spaces.list({});

// Advance timers to allow all retries
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(14000);

const result = await promise;

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

// Result should contain the error since all retries failed
expect(result).toBeDefined();
Expand Down
9 changes: 5 additions & 4 deletions packages/mapi-client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
mergeHeaders,
setAuthParams,
} from './utils';
import { calculateRetryDelay } from "../utils/calculate-retry-delay";
import { delay } from "../utils/delay";

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

// Execute with retry logic by recreating the request for each attempt
let response = await executeWithRetry(_fetch, url, requestInit, {
maxRetries: 3,
maxRetries: 12,
retryDelay: 1000
});

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

if (response.status === 429 && attempt < retryConfig.maxRetries) {
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : retryConfig.retryDelay;

await new Promise(resolve => setTimeout(resolve, delay));
const retryDelay = retryAfter ? parseInt(retryAfter) * 1000 : calculateRetryDelay(attempt, retryConfig.retryDelay);
await delay(retryDelay);

// Use the original unconsumed request for retry
return executeWithRetry(fetchFn, url, requestInit, retryConfig, attempt + 1);
Expand Down
20 changes: 20 additions & 0 deletions packages/mapi-client/src/utils/calculate-retry-delay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Calculates the delay for the next retry attempt using exponential backoff with full jitter.
*
* @param attempt The current retry attempt number (starting from 0 for the first retry).
* @param baseDelay The initial delay in milliseconds (e.g., 100).
* @param maxDelay The maximum possible delay in milliseconds (e.g., 20000).
* @returns The calculated delay in milliseconds to wait before the next attempt.
*/
export function calculateRetryDelay(
attempt: number,
baseDelay: number = 100,
maxDelay: number = 20000,
): number {
const exponentialBackoff = baseDelay * 2 ** attempt;
const cappedBackoff = Math.min(exponentialBackoff, maxDelay);
// Apply full jitter: a random value between 0 and the capped backoff
const jitter = Math.random() * cappedBackoff;

return jitter;
}
1 change: 1 addition & 0 deletions packages/mapi-client/src/utils/delay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Loading