Skip to content

Commit 81d00e1

Browse files
authored
fix: using addQuery option in retry-plugin query parameters will accumulating across retries (#4055)
1 parent 0f8e0ca commit 81d00e1

File tree

8 files changed

+170
-245
lines changed

8 files changed

+170
-245
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@module-federation/retry-plugin': patch
3+
---
4+
5+
fix(retry-plugin): prevent query parameter accumulation across retries

apps/router-demo/router-host-2000/src/runtime-plugin/retry.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@ const retryPlugin = () =>
1313
onError: (params) => {
1414
console.log('onError', params);
1515
},
16-
manifestDomains: ['https://m1.example.com', 'https://m2.example.com'],
16+
manifestDomains: [
17+
'https://m1.example.com',
18+
'https://m2.example.com',
19+
'https://m3.example.com',
20+
],
1721
domains: [
1822
'http://localhost:2011',
1923
'http://localhost:2021',
2024
'http://localhost:2031',
2125
],
22-
addQuery: true,
26+
addQuery: ({ times, originalQuery }) => {
27+
return `${originalQuery}&retry=${times}&retryTimeStamp=${new Date().valueOf()}`;
28+
},
2329
fetchOptions: {
2430
method: 'GET',
2531
},

packages/retry-plugin/__tests__/retry.spec.ts

Lines changed: 55 additions & 219 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import { fetchRetry } from '../src/fetch-retry';
33
import { scriptRetry } from '../src/script-retry';
4-
import { RetryPlugin } from '../src/index';
5-
import {
6-
getRetryUrl,
7-
rewriteWithNextDomain,
8-
appendRetryCountQuery,
9-
} from '../src/utils';
10-
11-
// Mock fetch
4+
import { ERROR_ABANDONED } from '../src/constant';
5+
126
const mockFetch = vi.fn();
137
global.fetch = mockFetch;
148

15-
// Mock logger
169
vi.mock('../src/logger', () => ({
1710
default: {
1811
log: vi.fn(),
@@ -60,7 +53,7 @@ describe('Retry Plugin', () => {
6053
retryTimes: 2,
6154
retryDelay: 10,
6255
}),
63-
).rejects.toThrow('The request failed and has now been abandoned');
56+
).rejects.toThrow(ERROR_ABANDONED);
6457

6558
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
6659
});
@@ -212,7 +205,7 @@ describe('Retry Plugin', () => {
212205

213206
await expect(
214207
retryFunction({ url: 'https://example.com/script.js' }),
215-
).rejects.toThrow('The request failed and has now been abandoned');
208+
).rejects.toThrow(ERROR_ABANDONED);
216209

217210
expect(mockRetryFn).toHaveBeenCalledTimes(2);
218211
});
@@ -281,12 +274,8 @@ describe('Retry Plugin', () => {
281274
'http://localhost:2021',
282275
];
283276
const mockRetryFn = vi.fn().mockImplementation(({ getEntryUrl }: any) => {
284-
// simulate consumer calling getEntryUrl with the current known url
285-
const prev =
286-
sequence.length === 0
287-
? 'http://localhost:2001/remoteEntry.js'
288-
: sequence[sequence.length - 1];
289-
const nextUrl = getEntryUrl(prev);
277+
// Consumer always calls getEntryUrl with the same original URL
278+
const nextUrl = getEntryUrl('http://localhost:2001/remoteEntry.js');
290279
sequence.push(nextUrl);
291280
// always throw to trigger next retry until retryTimes is reached
292281
throw new Error('Script load error');
@@ -303,27 +292,23 @@ describe('Retry Plugin', () => {
303292

304293
await expect(
305294
retryFunction({ url: 'http://localhost:2001/remoteEntry.js' }),
306-
).rejects.toThrow('The request failed and has now been abandoned');
295+
).rejects.toThrow(ERROR_ABANDONED);
307296

308-
// With current implementation, first attempt already rotates based on base URL
309-
// and then continues rotating on each retry
297+
// With the fix, should properly rotate domains across retries
310298
expect(sequence.length).toBe(3);
311-
// Start from 2001 -> next 2011
299+
// First retry: 2001 -> 2011
312300
expect(sequence[0]).toContain('http://localhost:2011');
313-
// 2011 -> 2021
301+
// Second retry: 2011 -> 2021
314302
expect(sequence[1]).toContain('http://localhost:2021');
315-
// 2021 -> wrap to 2001
303+
// Third retry: 2021 -> wrap to 2001
316304
expect(sequence[2]).toContain('http://localhost:2001');
317305
});
318306

319307
it('should append retryCount when addQuery is true for scripts', async () => {
320308
const sequence: string[] = [];
321309
const mockRetryFn = vi.fn().mockImplementation(({ getEntryUrl }: any) => {
322-
const prev =
323-
sequence.length === 0
324-
? 'https://cdn.example.com/entry.js'
325-
: sequence[sequence.length - 1];
326-
const nextUrl = getEntryUrl(prev);
310+
// Consumer always calls getEntryUrl with the same original URL
311+
const nextUrl = getEntryUrl('https://cdn-a.example.com/entry.js');
327312
sequence.push(nextUrl);
328313
throw new Error('Script load error');
329314
});
@@ -340,211 +325,62 @@ describe('Retry Plugin', () => {
340325

341326
await expect(
342327
retryFunction({ url: 'https://cdn-a.example.com/entry.js' }),
343-
).rejects.toThrow('The request failed and has now been abandoned');
328+
).rejects.toThrow(ERROR_ABANDONED);
344329

345330
expect(sequence.length).toBe(2);
346-
// first attempt (per current logic) applies retryIndex=1 and rotates domain
331+
// First retry: should rotate to cdn-b and have retryCount=1
332+
expect(sequence[0]).toContain('https://cdn-b.example.com');
347333
expect(sequence[0]).toMatch(/retryCount=1/);
348-
// second attempt uses retryIndex=2
334+
// Second retry: should rotate back to cdn-a and have retryCount=2
335+
expect(sequence[1]).toContain('https://cdn-a.example.com');
349336
expect(sequence[1]).toMatch(/retryCount=2/);
337+
// Should not accumulate previous retry parameters
338+
expect(sequence[1]).not.toMatch(/retryCount=1/);
350339
});
351-
});
352-
353-
describe('RetryPlugin', () => {
354-
it('should create plugin with default options', () => {
355-
const plugin = RetryPlugin({});
356-
expect(plugin.name).toBe('retry-plugin');
357-
expect(plugin.fetch).toBeDefined();
358-
expect(plugin.loadEntryError).toBeDefined();
359-
});
360-
361-
it('should handle fetch with retry', async () => {
362-
const mockResponse = {
363-
ok: true,
364-
json: () => Promise.resolve({ data: 'test' }),
365-
clone: () => ({
366-
ok: true,
367-
json: () => Promise.resolve({ data: 'test' }),
368-
}),
369-
};
370-
mockFetch.mockResolvedValue(mockResponse);
371-
372-
const plugin = RetryPlugin({
373-
retryTimes: 0, // 不重试,第一次就成功
374-
retryDelay: 10,
375-
});
376-
377-
const result = await plugin.fetch!('https://example.com/api', {});
378-
379-
expect(mockFetch).toHaveBeenCalledWith('https://example.com/api', {});
380-
expect(result).toBe(mockResponse);
381-
});
382-
383-
it('should prefer manifestDomains over domains for manifest fetch retries', async () => {
384-
// Arrange: fail first, then succeed
385-
const mockResponse = {
386-
ok: true,
387-
json: () => Promise.resolve({ data: 'ok' }),
388-
clone: () => ({
389-
ok: true,
390-
json: () => Promise.resolve({ data: 'ok' }),
391-
}),
392-
};
393-
mockFetch
394-
.mockRejectedValueOnce(new Error('Network error 1'))
395-
.mockResolvedValueOnce(mockResponse);
396-
397-
const plugin = RetryPlugin({
398-
retryTimes: 2,
399-
retryDelay: 1,
400-
// global domains (should be ignored when manifestDomains provided)
401-
domains: ['https://global-domain.com'],
402-
// manifestDomains should take precedence in plugin.fetch
403-
manifestDomains: ['https://m1.example.com', 'https://m2.example.com'],
404-
});
405-
406-
const result = await plugin.fetch!(
407-
'https://origin.example.com/mf-manifest.json',
408-
{} as any,
409-
);
410-
411-
// Assert: second call (first retry) should use manifestDomains[0]
412-
const calls = mockFetch.mock.calls;
413-
expect(calls[0][0]).toBe('https://origin.example.com/mf-manifest.json');
414-
expect(String(calls[1][0])).toContain('m1.example.com');
415-
expect(result).toBe(mockResponse as any);
416-
});
417-
});
418-
419-
describe('utils', () => {
420-
describe('rewriteWithNextDomain', () => {
421-
it('should return null for empty domains', () => {
422-
expect(rewriteWithNextDomain('https://example.com/api', [])).toBeNull();
423-
expect(
424-
rewriteWithNextDomain('https://example.com/api', undefined),
425-
).toBeNull();
426-
});
427-
428-
it('should rotate to next domain', () => {
429-
const domains = [
430-
'https://domain1.com',
431-
'https://domain2.com',
432-
'https://domain3.com',
433-
];
434-
const result = rewriteWithNextDomain(
435-
'https://domain1.com/api',
436-
domains,
437-
);
438-
expect(result).toBe('https://domain2.com/api');
439-
});
440-
441-
it('should wrap around to first domain', () => {
442-
const domains = ['https://domain1.com', 'https://domain2.com'];
443-
const result = rewriteWithNextDomain(
444-
'https://domain2.com/api',
445-
domains,
446-
);
447-
expect(result).toBe('https://domain1.com/api');
448-
});
449-
450-
it('should handle domains with different protocols', () => {
451-
const domains = ['https://domain1.com', 'http://domain2.com'];
452-
const result = rewriteWithNextDomain(
453-
'https://domain1.com/api',
454-
domains,
455-
);
456-
expect(result).toBe('http://domain2.com/api');
457-
});
458-
});
459-
460-
describe('appendRetryCountQuery', () => {
461-
it('should append retry count to URL', () => {
462-
const result = appendRetryCountQuery('https://example.com/api', 3);
463-
expect(result).toBe('https://example.com/api?retryCount=3');
464-
});
465340

466-
it('should append to existing query parameters', () => {
467-
const result = appendRetryCountQuery(
468-
'https://example.com/api?foo=bar',
469-
2,
470-
);
471-
expect(result).toBe('https://example.com/api?foo=bar&retryCount=2');
472-
});
473-
474-
it('should use custom query key', () => {
475-
const result = appendRetryCountQuery(
476-
'https://example.com/api',
477-
1,
478-
'retry',
479-
);
480-
expect(result).toBe('https://example.com/api?retry=1');
481-
});
482-
});
483-
484-
describe('getRetryUrl', () => {
485-
it('should return original URL when no options provided', () => {
486-
const result = getRetryUrl('https://example.com/api');
487-
expect(result).toBe('https://example.com/api');
488-
});
489-
490-
it('should apply domain rotation', () => {
491-
const domains = ['https://domain1.com', 'https://domain2.com'];
492-
const result = getRetryUrl('https://domain1.com/api', { domains });
493-
expect(result).toBe('https://domain2.com/api');
341+
it('should prevent query parameter accumulation for scripts with functional addQuery', async () => {
342+
const sequence: string[] = [];
343+
const mockRetryFn = vi.fn().mockImplementation(({ getEntryUrl }: any) => {
344+
// Consumer always calls getEntryUrl with the same original URL
345+
const nextUrl = getEntryUrl('https://m1.example.com/remoteEntry.js');
346+
sequence.push(nextUrl);
347+
throw new Error('Script load error');
494348
});
495349

496-
it('should add retry count query when addQuery is true', () => {
497-
const result = getRetryUrl('https://example.com/api', {
498-
addQuery: true,
499-
retryIndex: 2,
500-
});
501-
expect(result).toBe('https://example.com/api?retryCount=2');
350+
const retryFunction = scriptRetry({
351+
retryOptions: {
352+
retryTimes: 3,
353+
retryDelay: 0,
354+
domains: ['https://m1.example.com', 'https://m2.example.com'],
355+
addQuery: ({ times }) =>
356+
`retry=${times}&retryTimeStamp=${1757484964434 + times * 1000}`,
357+
},
358+
retryFn: mockRetryFn,
502359
});
503360

504-
it('should not add query when retryIndex is 0', () => {
505-
const result = getRetryUrl('https://example.com/api', {
506-
addQuery: true,
507-
retryIndex: 0,
508-
});
509-
expect(result).toBe('https://example.com/api');
510-
});
361+
await expect(
362+
retryFunction({ url: 'https://m1.example.com/remoteEntry.js' }),
363+
).rejects.toThrow(ERROR_ABANDONED);
511364

512-
it('should use custom query key', () => {
513-
const result = getRetryUrl('https://example.com/api', {
514-
addQuery: true,
515-
retryIndex: 1,
516-
queryKey: 'retry',
517-
});
518-
expect(result).toBe('https://example.com/api?retry=1');
519-
});
365+
expect(sequence.length).toBe(3);
520366

521-
it('should support functional addQuery to replace query string (no original query)', () => {
522-
const result = getRetryUrl('https://example.com/api', {
523-
addQuery: ({ times, originalQuery }) =>
524-
`${originalQuery}&retry=${times}&retryTimeStamp=123`,
525-
retryIndex: 2,
526-
});
527-
expect(result).toBe(
528-
'https://example.com/api?&retry=2&retryTimeStamp=123',
529-
);
530-
});
367+
// First retry: m1 -> m2 with retry=1
368+
expect(sequence[0]).toBe(
369+
'https://m2.example.com/remoteEntry.js?retry=1&retryTimeStamp=1757484965434',
370+
);
531371

532-
it('should support functional addQuery with existing original query', () => {
533-
const result = getRetryUrl('https://example.com/api?foo=bar', {
534-
addQuery: ({ times, originalQuery }) =>
535-
`${originalQuery}&retry=${times}`,
536-
retryIndex: 3,
537-
});
538-
expect(result).toBe('https://example.com/api?foo=bar&retry=3');
539-
});
372+
// Second retry: m2 -> m1 with retry=2 (no accumulation)
373+
expect(sequence[1]).toBe(
374+
'https://m1.example.com/remoteEntry.js?retry=2&retryTimeStamp=1757484966434',
375+
);
376+
expect(sequence[1]).not.toContain('retry=1');
540377

541-
it('should clear query when functional addQuery returns empty string', () => {
542-
const result = getRetryUrl('https://example.com/api?foo=bar', {
543-
addQuery: () => '',
544-
retryIndex: 1,
545-
});
546-
expect(result).toBe('https://example.com/api');
547-
});
378+
// Third retry: m1 -> m2 with retry=3 (no accumulation)
379+
expect(sequence[2]).toBe(
380+
'https://m2.example.com/remoteEntry.js?retry=3&retryTimeStamp=1757484967434',
381+
);
382+
expect(sequence[2]).not.toContain('retry=1');
383+
expect(sequence[2]).not.toContain('retry=2');
548384
});
549385
});
550386
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const defaultRetries = 3;
22
export const defaultRetryDelay = 1000;
33
export const PLUGIN_IDENTIFIER = '[ Module Federation RetryPlugin ]';
4+
export const ERROR_ABANDONED = 'The request failed and has now been abandoned';

0 commit comments

Comments
 (0)