Skip to content

Commit bcec314

Browse files
committed
Retry upload requests under certain conditions
1 parent ac26cb9 commit bcec314

File tree

1 file changed

+61
-8
lines changed

1 file changed

+61
-8
lines changed

javascript-client/src/client.ts

+61-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
1+
import axios, {
2+
AxiosError,
3+
AxiosInstance,
4+
AxiosRequestConfig,
5+
AxiosResponse,
6+
} from 'axios';
27

38
// Description of a part from initializeUpload()
49
interface PartInfo {
@@ -36,6 +41,7 @@ export enum S3FileFieldProgressState {
3641
Sending,
3742
Finalizing,
3843
Done,
44+
Retrying,
3945
}
4046

4147
export interface S3FileFieldProgress {
@@ -49,7 +55,41 @@ export type S3FileFieldProgressCallback = (progress: S3FileFieldProgress) => voi
4955
export interface S3FileFieldClientOptions {
5056
readonly baseUrl: string;
5157
readonly onProgress?: S3FileFieldProgressCallback;
52-
readonly apiConfig?: AxiosRequestConfig
58+
readonly apiConfig?: AxiosRequestConfig;
59+
}
60+
61+
function sleep(ms: number): Promise<void> {
62+
return new Promise((resolve) => {
63+
window.setTimeout(() => { resolve(); }, ms);
64+
});
65+
}
66+
67+
function shouldRetry(error: Error): boolean {
68+
// We only retry requests under certain failure modes. Namely, either
69+
// network errors, or a subset of HTTP error codes.
70+
const axiosErr = (error as AxiosError);
71+
return axiosErr.isAxiosError && (
72+
!axiosErr.response
73+
|| [429, 500, 502, 503, 504].includes(axiosErr.response.status)
74+
);
75+
}
76+
77+
async function retry<T>(
78+
fn: () => Promise<T>, onRetry: () => void, condition: (error: Error) => boolean = shouldRetry,
79+
interval = 5000,
80+
): Promise<T> {
81+
while (true) { // eslint-disable-line no-constant-condition
82+
try {
83+
return await fn(); // eslint-disable-line no-await-in-loop
84+
} catch (error) {
85+
if (condition(error)) {
86+
onRetry();
87+
await sleep(interval); // eslint-disable-line no-await-in-loop
88+
} else {
89+
throw error;
90+
}
91+
}
92+
}
5393
}
5494

5595
export default class S3FileFieldClient {
@@ -108,17 +148,23 @@ export default class S3FileFieldClient {
108148
let fileOffset = 0;
109149
for (const part of parts) {
110150
const chunk = file.slice(fileOffset, fileOffset + part.size);
111-
// eslint-disable-next-line no-await-in-loop
112-
const response = await axios.put(part.upload_url, chunk, {
113-
// eslint-disable-next-line @typescript-eslint/no-loop-func
151+
// eslint-disable-next-line @typescript-eslint/no-loop-func, no-await-in-loop
152+
const response = await retry<AxiosResponse>(() => axios.put(part.upload_url, chunk, {
114153
onUploadProgress: (e) => {
115154
this.onProgress({
116155
uploaded: fileOffset + e.loaded,
117156
total: file.size,
118157
state: S3FileFieldProgressState.Sending,
119158
});
120159
},
160+
}), () => { // eslint-disable-line @typescript-eslint/no-loop-func
161+
this.onProgress({
162+
uploaded: fileOffset,
163+
total: file.size,
164+
state: S3FileFieldProgressState.Retrying,
165+
});
121166
});
167+
122168
uploadedParts.push({
123169
part_number: part.part_number,
124170
size: part.size,
@@ -140,15 +186,18 @@ export default class S3FileFieldClient {
140186
protected async completeUpload(
141187
multipartInfo: MultipartInfo, parts: UploadedPart[],
142188
): Promise<void> {
143-
const response = await this.api.post('upload-complete/', {
189+
const response = await retry<AxiosResponse>(() => this.api.post('upload-complete/', {
144190
upload_signature: multipartInfo.upload_signature,
145191
upload_id: multipartInfo.upload_id,
146192
parts,
193+
}), () => {
194+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
147195
});
148196
const { complete_url: completeUrl, body } = response.data;
149197

198+
// TODO support HTTP 200 error: https://github.com/girder/django-s3-file-field/issues/209
150199
// Send the CompleteMultipartUpload operation to S3
151-
await axios.post(completeUrl, body, {
200+
await retry<AxiosResponse>(() => axios.post(completeUrl, body, {
152201
headers: {
153202
// By default, Axios sets "Content-Type: application/x-www-form-urlencoded" on POST
154203
// requests. This causes AWS's API to interpret the request body as additional parameters
@@ -157,6 +206,8 @@ export default class S3FileFieldClient {
157206
// CompleteMultipartUpload docs.
158207
'Content-Type': null,
159208
},
209+
}), () => {
210+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
160211
});
161212
}
162213

@@ -168,8 +219,10 @@ export default class S3FileFieldClient {
168219
* @param multipartInfo Signed information returned from /upload-complete/.
169220
*/
170221
protected async finalize(multipartInfo: MultipartInfo): Promise<string> {
171-
const response = await this.api.post('finalize/', {
222+
const response = await retry<AxiosResponse>(() => this.api.post('finalize/', {
172223
upload_signature: multipartInfo.upload_signature,
224+
}), () => {
225+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
173226
});
174227
return response.data.field_value;
175228
}

0 commit comments

Comments
 (0)