Skip to content

Commit 376d404

Browse files
Merge pull request #120 from contentstack/staging
DX | 18-08-2025 | Release
2 parents d17d76e + 4b4f6f8 commit 376d404

File tree

6 files changed

+818
-5
lines changed

6 files changed

+818
-5
lines changed

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ fileignoreconfig:
66
checksum: 34d28e7736ffac2b27d3708b6bca28591f3a930292433001d2397bfdf2d2fd0f
77
- filename: .husky/pre-commit
88
checksum: 5baabd7d2c391648163f9371f0e5e9484f8fb90fa2284cfc378732ec3192c193
9+
- filename: test/request.spec.ts
10+
checksum: 87afd3bb570fd52437404cbe69a39311ad8a8c73bca9d075ecf88652fd3e13f6
911
version: ""

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
## Change log
2+
### Version: 1.2.4
3+
#### Date: Aug-18-2025
4+
- Fix: Retry request logic after rate limit replenishes
5+
26
### Version: 1.2.3
37
#### Date: Aug-04-2025
48
- Fix: Added Pre-commit hook to run talisman and snyk scan

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@contentstack/core",
3-
"version": "1.2.3",
3+
"version": "1.2.4",
44
"type": "commonjs",
55
"main": "./dist/cjs/src/index.js",
66
"types": "./dist/cjs/src/index.d.ts",

src/lib/retryPolicy/delivery-sdk-handlers.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,30 @@ export const retryResponseErrorHandler = (error: any, config: any, axiosInstance
4545
}
4646
} else {
4747
const rateLimitRemaining = response.headers['x-ratelimit-remaining'];
48+
49+
// Handle rate limit exhaustion with retry logic
4850
if (rateLimitRemaining !== undefined && parseInt(rateLimitRemaining) <= 0) {
49-
return Promise.reject(error.response.data);
51+
retryCount++;
52+
53+
if (retryCount >= config.retryLimit) {
54+
return Promise.reject(error.response.data);
55+
}
56+
57+
error.config.retryCount = retryCount;
58+
59+
// Calculate delay for rate limit reset
60+
const rateLimitResetDelay = calculateRateLimitDelay(response.headers);
61+
62+
return new Promise((resolve, reject) => {
63+
setTimeout(async () => {
64+
try {
65+
const retryResponse = await axiosInstance(error.config);
66+
resolve(retryResponse);
67+
} catch (retryError) {
68+
reject(retryError);
69+
}
70+
}, rateLimitResetDelay);
71+
});
5072
}
5173

5274
if (response.status == 429 || response.status == 401) {
@@ -99,3 +121,41 @@ const retry = (error: any, config: any, retryCount: number, retryDelay: number,
99121
}, delayTime);
100122
});
101123
};
124+
125+
/**
126+
* Calculate delay time for rate limit reset based on response headers
127+
* @param headers - Response headers from the API
128+
* @returns Delay time in milliseconds
129+
*/
130+
export const calculateRateLimitDelay = (headers: any): number => {
131+
// Check for retry-after header (in seconds)
132+
const retryAfter = headers['retry-after'];
133+
if (retryAfter) {
134+
return parseInt(retryAfter) * 1000; // Convert to milliseconds
135+
}
136+
137+
// Check for x-ratelimit-reset header (Unix timestamp)
138+
const rateLimitReset = headers['x-ratelimit-reset'];
139+
if (rateLimitReset) {
140+
const resetTime = parseInt(rateLimitReset) * 1000; // Convert to milliseconds
141+
const currentTime = Date.now();
142+
const delay = resetTime - currentTime;
143+
144+
// Ensure we have a positive delay, add a small buffer
145+
return Math.max(delay + 1000, 1000); // At least 1 second delay
146+
}
147+
148+
// Check for x-ratelimit-reset-time header (ISO string)
149+
const rateLimitResetTime = headers['x-ratelimit-reset-time'];
150+
if (rateLimitResetTime) {
151+
const resetTime = new Date(rateLimitResetTime).getTime();
152+
const currentTime = Date.now();
153+
const delay = resetTime - currentTime;
154+
155+
// Ensure we have a positive delay, add a small buffer
156+
return Math.max(delay + 1000, 1000); // At least 1 second delay
157+
}
158+
159+
// Default fallback delay (1 second) if no rate limit reset info is available
160+
return 1000;
161+
};

test/request.spec.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,213 @@ describe('Request tests', () => {
111111
expect(mock.history.get[0].url).toBe(livePreviewURL);
112112
expect(result).toEqual(mockResponse);
113113
});
114+
115+
it('should throw error when response has no data property', async () => {
116+
const client = httpClient({});
117+
const mock = new MockAdapter(client as any);
118+
const url = '/your-api-endpoint';
119+
const responseWithoutData = { status: 200, headers: {} }; // Response without data property
120+
121+
// Mock response that returns undefined/empty data
122+
mock.onGet(url).reply(() => [200, undefined, {}]);
123+
124+
await expect(getData(client, url)).rejects.toThrowError();
125+
});
126+
127+
it('should throw error when response is null', async () => {
128+
const client = httpClient({});
129+
const mock = new MockAdapter(client as any);
130+
const url = '/your-api-endpoint';
131+
132+
// Mock response that returns null
133+
mock.onGet(url).reply(() => [200, null]);
134+
135+
await expect(getData(client, url)).rejects.toThrowError();
136+
});
137+
138+
it('should handle live_preview when enable is false', async () => {
139+
const client = httpClient({});
140+
const mock = new MockAdapter(client as any);
141+
const url = '/your-api-endpoint';
142+
const mockResponse = { data: 'mocked' };
143+
144+
client.stackConfig = {
145+
live_preview: {
146+
enable: false, // Disabled
147+
preview_token: 'someToken',
148+
live_preview: 'someHash',
149+
host: 'rest-preview.com',
150+
},
151+
};
152+
153+
mock.onGet(url).reply(200, mockResponse);
154+
155+
const result = await getData(client, url, {});
156+
157+
// Should not modify URL when live preview is disabled
158+
expect(mock.history.get[0].url).toBe(url);
159+
expect(result).toEqual(mockResponse);
160+
});
161+
162+
it('should handle request when stackConfig is undefined', async () => {
163+
const client = httpClient({});
164+
const mock = new MockAdapter(client as any);
165+
const url = '/your-api-endpoint';
166+
const mockResponse = { data: 'mocked' };
167+
168+
// No stackConfig set
169+
client.stackConfig = undefined;
170+
171+
mock.onGet(url).reply(200, mockResponse);
172+
173+
const result = await getData(client, url, {});
174+
expect(result).toEqual(mockResponse);
175+
});
176+
177+
it('should handle request when stackConfig exists but live_preview is undefined', async () => {
178+
const client = httpClient({});
179+
const mock = new MockAdapter(client as any);
180+
const url = '/your-api-endpoint';
181+
const mockResponse = { data: 'mocked' };
182+
183+
client.stackConfig = {
184+
// live_preview not defined
185+
apiKey: 'test-key',
186+
};
187+
188+
mock.onGet(url).reply(200, mockResponse);
189+
190+
const result = await getData(client, url, {});
191+
expect(result).toEqual(mockResponse);
192+
});
193+
194+
it('should set live_preview to "init" when enable is true and no live_preview provided', async () => {
195+
const client = httpClient({});
196+
const mock = new MockAdapter(client as any);
197+
const url = '/your-api-endpoint';
198+
const mockResponse = { data: 'mocked' };
199+
200+
client.stackConfig = {
201+
live_preview: {
202+
enable: true,
203+
preview_token: 'someToken',
204+
// live_preview not provided
205+
},
206+
};
207+
208+
mock.onGet(url).reply(200, mockResponse);
209+
210+
const data: any = {};
211+
const result = await getData(client, url, data);
212+
213+
// Should set live_preview to 'init'
214+
expect(data.live_preview).toBe('init');
215+
expect(result).toEqual(mockResponse);
216+
});
217+
218+
it('should set headers when preview_token is provided', async () => {
219+
const client = httpClient({});
220+
const mock = new MockAdapter(client as any);
221+
const url = '/your-api-endpoint';
222+
const mockResponse = { data: 'mocked' };
223+
224+
client.stackConfig = {
225+
live_preview: {
226+
enable: true,
227+
preview_token: 'test-preview-token',
228+
live_preview: 'init',
229+
},
230+
};
231+
232+
mock.onGet(url).reply(200, mockResponse);
233+
234+
const result = await getData(client, url, {});
235+
236+
// Should set headers
237+
expect(client.defaults.headers.preview_token).toBe('test-preview-token');
238+
expect(client.defaults.headers.live_preview).toBe('init');
239+
expect(result).toEqual(mockResponse);
240+
});
241+
242+
it('should handle live_preview when enable is true but no preview_token', async () => {
243+
const client = httpClient({});
244+
const mock = new MockAdapter(client as any);
245+
const url = '/your-api-endpoint';
246+
const mockResponse = { data: 'mocked' };
247+
248+
client.stackConfig = {
249+
live_preview: {
250+
enable: true,
251+
live_preview: 'init',
252+
// preview_token not provided
253+
},
254+
};
255+
256+
mock.onGet(url).reply(200, mockResponse);
257+
258+
const data: any = {};
259+
const result = await getData(client, url, data);
260+
261+
// Should still set live_preview in data
262+
expect(data.live_preview).toBe('init');
263+
expect(result).toEqual(mockResponse);
264+
});
265+
266+
it('should handle custom error messages when request fails', async () => {
267+
const client = httpClient({});
268+
const mock = new MockAdapter(client as any);
269+
const url = '/your-api-endpoint';
270+
const customError = new Error('Custom network error');
271+
272+
mock.onGet(url).reply(() => {
273+
throw customError;
274+
});
275+
276+
await expect(getData(client, url)).rejects.toThrowError('Custom network error');
277+
});
278+
279+
it('should handle non-Error objects as errors when they have message property', async () => {
280+
const client = httpClient({});
281+
const mock = new MockAdapter(client as any);
282+
const url = '/your-api-endpoint';
283+
const errorObject = { status: 500, message: 'Internal Server Error' };
284+
285+
mock.onGet(url).reply(() => {
286+
throw errorObject;
287+
});
288+
289+
// When error has message property, it uses the message
290+
await expect(getData(client, url)).rejects.toThrowError('Internal Server Error');
291+
});
292+
293+
it('should handle non-Error objects as errors when they have no message property', async () => {
294+
const client = httpClient({});
295+
const mock = new MockAdapter(client as any);
296+
const url = '/your-api-endpoint';
297+
const errorObject = { status: 500, code: 'SERVER_ERROR' };
298+
299+
mock.onGet(url).reply(() => {
300+
throw errorObject;
301+
});
302+
303+
// When error has no message property, it stringifies the object
304+
await expect(getData(client, url)).rejects.toThrowError(JSON.stringify(errorObject));
305+
});
306+
307+
it('should pass data parameter to axios get request', async () => {
308+
const client = httpClient({});
309+
const mock = new MockAdapter(client as any);
310+
const url = '/your-api-endpoint';
311+
const mockResponse = { data: 'mocked' };
312+
const requestData = { params: { limit: 10, skip: 0 } };
313+
314+
mock.onGet(url).reply((config) => {
315+
// Verify that data was passed correctly
316+
expect(config.params).toEqual(requestData.params);
317+
return [200, mockResponse];
318+
});
319+
320+
const result = await getData(client, url, requestData);
321+
expect(result).toEqual(mockResponse);
322+
});
114323
});

0 commit comments

Comments
 (0)