Skip to content

Commit 1c5610f

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

File tree

1 file changed

+62
-8
lines changed

1 file changed

+62
-8
lines changed

javascript-client/src/client.ts

+62-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,42 @@ 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 fn();
84+
} catch (error) {
85+
if (condition(error)) {
86+
onRetry();
87+
// eslint-disable-next-line no-await-in-loop
88+
await sleep(interval);
89+
} else {
90+
throw error;
91+
}
92+
}
93+
}
5394
}
5495

5596
export default class S3FileFieldClient {
@@ -108,17 +149,23 @@ export default class S3FileFieldClient {
108149
let fileOffset = 0;
109150
for (const part of parts) {
110151
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
152+
// eslint-disable-next-line @typescript-eslint/no-loop-func, no-await-in-loop
153+
const response = await retry<AxiosResponse>(() => axios.put(part.upload_url, chunk, {
114154
onUploadProgress: (e) => {
115155
this.onProgress({
116156
uploaded: fileOffset + e.loaded,
117157
total: file.size,
118158
state: S3FileFieldProgressState.Sending,
119159
});
120160
},
161+
}), () => { // eslint-disable-line @typescript-eslint/no-loop-func
162+
this.onProgress({
163+
uploaded: fileOffset,
164+
total: file.size,
165+
state: S3FileFieldProgressState.Retrying,
166+
});
121167
});
168+
122169
uploadedParts.push({
123170
part_number: part.part_number,
124171
size: part.size,
@@ -140,15 +187,18 @@ export default class S3FileFieldClient {
140187
protected async completeUpload(
141188
multipartInfo: MultipartInfo, parts: UploadedPart[],
142189
): Promise<void> {
143-
const response = await this.api.post('upload-complete/', {
190+
const response = await retry<AxiosResponse>(() => this.api.post('upload-complete/', {
144191
upload_signature: multipartInfo.upload_signature,
145192
upload_id: multipartInfo.upload_id,
146193
parts,
194+
}), () => {
195+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
147196
});
148197
const { complete_url: completeUrl, body } = response.data;
149198

199+
// TODO support HTTP 200 error: https://github.com/girder/django-s3-file-field/issues/209
150200
// Send the CompleteMultipartUpload operation to S3
151-
await axios.post(completeUrl, body, {
201+
await retry<AxiosResponse>(() => axios.post(completeUrl, body, {
152202
headers: {
153203
// By default, Axios sets "Content-Type: application/x-www-form-urlencoded" on POST
154204
// requests. This causes AWS's API to interpret the request body as additional parameters
@@ -157,6 +207,8 @@ export default class S3FileFieldClient {
157207
// CompleteMultipartUpload docs.
158208
'Content-Type': null,
159209
},
210+
}), () => {
211+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
160212
});
161213
}
162214

@@ -168,8 +220,10 @@ export default class S3FileFieldClient {
168220
* @param multipartInfo Signed information returned from /upload-complete/.
169221
*/
170222
protected async finalize(multipartInfo: MultipartInfo): Promise<string> {
171-
const response = await this.api.post('finalize/', {
223+
const response = await retry<AxiosResponse>(() => this.api.post('finalize/', {
172224
upload_signature: multipartInfo.upload_signature,
225+
}), () => {
226+
this.onProgress({ state: S3FileFieldProgressState.Retrying });
173227
});
174228
return response.data.field_value;
175229
}

0 commit comments

Comments
 (0)