Skip to content

Commit 17aedea

Browse files
authored
Implement retries (#972)
* Implemented retries for fetch * changed nock test setup * Fixed nock setup order * Ensure the retry is not negative
1 parent d84a41a commit 17aedea

5 files changed

Lines changed: 134 additions & 18 deletions

File tree

.changeset/loud-pigs-tell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'tuf-js': patch
3+
---
4+
5+
Implement retry logic on fetch

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
},
3535
"dependencies": {
3636
"@tufjs/models": "4.1.0",
37-
"debug": "^4.4.3"
37+
"debug": "^4.4.3",
38+
"@gar/promise-retry": "^1.0.3"
3839
},
3940
"engines": {
4041
"node": "^20.17.0 || >=22.9.0"

packages/client/src/fetcher.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import util from 'util';
44

55
import { DownloadHTTPError, DownloadLengthMismatchError } from './error';
66
import { withTempFile } from './utils/tmpfile';
7+
import { promiseRetry } from '@gar/promise-retry';
78

8-
import type { TimeoutsOptions } from 'retry';
9+
import type { OperationOptions, TimeoutsOptions } from 'retry';
910

1011
const log = debug('tuf:fetch');
1112

@@ -95,31 +96,69 @@ interface FetcherOptions {
9596
export class DefaultFetcher extends BaseFetcher {
9697
private userAgent?: string;
9798
private timeout?: number;
98-
private retry?: Retry;
99+
private retry?: OperationOptions;
99100

100101
constructor(options: FetcherOptions = {}) {
101102
super();
102103
this.userAgent = options.userAgent;
103104
this.timeout = options.timeout;
104-
this.retry = options.retry;
105+
// Map retry to OperationOptions
106+
if (options.retry === true) {
107+
this.retry = { forever: true };
108+
} else if (options.retry === false || options.retry === undefined) {
109+
this.retry = undefined;
110+
} else if (typeof options.retry === 'number') {
111+
if (options.retry < 0) {
112+
throw new Error('Retry count must be non-negative number');
113+
}
114+
this.retry = { retries: options.retry };
115+
} else {
116+
this.retry = options.retry;
117+
}
105118
}
106119

107120
public override async fetch(
108121
url: string
109122
): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
110-
log('GET %s', url);
111-
const response = await fetch(url, {
112-
headers: {
113-
[USER_AGENT_HEADER]: this.userAgent || '',
114-
},
115-
signal: this.timeout ? AbortSignal.timeout(this.timeout) : undefined,
116-
});
123+
const shouldRetry = this.retry !== undefined;
124+
125+
return promiseRetry(
126+
async (retry: (err: Error) => never, number: number) => {
127+
log('GET %s (attempt %d)', url, number);
128+
129+
let response: Response;
130+
try {
131+
response = await fetch(url, {
132+
headers: {
133+
[USER_AGENT_HEADER]: this.userAgent || '',
134+
},
135+
signal: this.timeout
136+
? AbortSignal.timeout(this.timeout)
137+
: undefined,
138+
});
139+
} catch (error) {
140+
const err = error instanceof Error ? error : new Error(String(error));
141+
if (shouldRetry) {
142+
return retry(err);
143+
}
144+
throw err;
145+
}
117146

118-
if (!response.ok || !response?.body) {
119-
throw new DownloadHTTPError('Failed to download', response.status);
120-
}
147+
if (!response.ok || !response.body) {
148+
const err = new DownloadHTTPError(
149+
'Failed to download',
150+
response.status
151+
);
152+
if (shouldRetry && response.status >= 500 && response.status < 600) {
153+
return retry(err);
154+
}
155+
throw err;
156+
}
121157

122-
return response.body;
158+
return response.body;
159+
},
160+
this.retry
161+
);
123162
}
124163
}
125164

packages/client/tests/fetcher.test.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,68 @@ describe('Fetcher Test', () => {
4747
});
4848

4949
describe('Request Fetcher Fetch Test', () => {
50-
beforeAll(() => {
50+
it('fetch with bad status code', async () => {
51+
const fetcher = new DefaultFetcher();
5152
nock(baseURL).get('/').reply(404, 'error');
53+
await expect(fetcher.fetch(baseURL)).rejects.toThrow(DownloadHTTPError);
5254
});
5355

54-
it('fetch with bad status code', async () => {
56+
it('fetch with 500 status code', async () => {
5557
const fetcher = new DefaultFetcher();
56-
await expect(fetcher.fetch(baseURL)).rejects.toThrow(DownloadHTTPError);
58+
nock(baseURL).get('/internal-error').reply(500, 'error');
59+
60+
await expect(
61+
fetcher.fetch(`${baseURL}/internal-error`)
62+
).rejects.toMatchObject({
63+
statusCode: 500,
64+
});
65+
});
66+
67+
it('retries on 500 when retry is configured', async () => {
68+
let attempts = 0;
69+
nock(baseURL)
70+
.get('/retry-500')
71+
.times(3)
72+
.reply(() => {
73+
attempts += 1;
74+
return [500, 'error'];
75+
});
76+
77+
const fetcher = new DefaultFetcher({
78+
retry: {
79+
retries: 2,
80+
minTimeout: 1,
81+
maxTimeout: 1,
82+
randomize: false,
83+
},
84+
});
85+
86+
await expect(fetcher.fetch(`${baseURL}/retry-500`)).rejects.toMatchObject(
87+
{
88+
statusCode: 500,
89+
}
90+
);
91+
expect(attempts).toBe(3);
92+
});
93+
94+
it('succeeds after retrying on 500', async () => {
95+
const fetcher = new DefaultFetcher({
96+
retry: {
97+
retries: 1,
98+
minTimeout: 1,
99+
maxTimeout: 1,
100+
randomize: false,
101+
},
102+
});
103+
nock(baseURL)
104+
.get('/suceed-after-retry')
105+
.reply(500, 'error')
106+
.get('/suceed-after-retry')
107+
.reply(200, response);
108+
109+
const result = await fetcher.fetch(`${baseURL}/suceed-after-retry`);
110+
const text = await new Response(result).text();
111+
expect(text).toBe(response);
57112
});
58113
});
59114
});

0 commit comments

Comments
 (0)