1
- import axios , { AxiosInstance , AxiosRequestConfig } from 'axios' ;
1
+ import axios , {
2
+ AxiosError ,
3
+ AxiosInstance ,
4
+ AxiosRequestConfig ,
5
+ AxiosResponse ,
6
+ } from 'axios' ;
2
7
3
8
// Description of a part from initializeUpload()
4
9
interface PartInfo {
@@ -36,6 +41,7 @@ export enum S3FileFieldProgressState {
36
41
Sending ,
37
42
Finalizing ,
38
43
Done ,
44
+ Retrying ,
39
45
}
40
46
41
47
export interface S3FileFieldProgress {
@@ -49,7 +55,42 @@ export type S3FileFieldProgressCallback = (progress: S3FileFieldProgress) => voi
49
55
export interface S3FileFieldClientOptions {
50
56
readonly baseUrl : string ;
51
57
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
+ }
53
94
}
54
95
55
96
export default class S3FileFieldClient {
@@ -108,17 +149,23 @@ export default class S3FileFieldClient {
108
149
let fileOffset = 0 ;
109
150
for ( const part of parts ) {
110
151
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 , {
114
154
onUploadProgress : ( e ) => {
115
155
this . onProgress ( {
116
156
uploaded : fileOffset + e . loaded ,
117
157
total : file . size ,
118
158
state : S3FileFieldProgressState . Sending ,
119
159
} ) ;
120
160
} ,
161
+ } ) , ( ) => { // eslint-disable-line @typescript-eslint/no-loop-func
162
+ this . onProgress ( {
163
+ uploaded : fileOffset ,
164
+ total : file . size ,
165
+ state : S3FileFieldProgressState . Retrying ,
166
+ } ) ;
121
167
} ) ;
168
+
122
169
uploadedParts . push ( {
123
170
part_number : part . part_number ,
124
171
size : part . size ,
@@ -140,15 +187,18 @@ export default class S3FileFieldClient {
140
187
protected async completeUpload (
141
188
multipartInfo : MultipartInfo , parts : UploadedPart [ ] ,
142
189
) : Promise < void > {
143
- const response = await this . api . post ( 'upload-complete/' , {
190
+ const response = await retry < AxiosResponse > ( ( ) => this . api . post ( 'upload-complete/' , {
144
191
upload_signature : multipartInfo . upload_signature ,
145
192
upload_id : multipartInfo . upload_id ,
146
193
parts,
194
+ } ) , ( ) => {
195
+ this . onProgress ( { state : S3FileFieldProgressState . Retrying } ) ;
147
196
} ) ;
148
197
const { complete_url : completeUrl , body } = response . data ;
149
198
199
+ // TODO support HTTP 200 error: https://github.com/girder/django-s3-file-field/issues/209
150
200
// Send the CompleteMultipartUpload operation to S3
151
- await axios . post ( completeUrl , body , {
201
+ await retry < AxiosResponse > ( ( ) => axios . post ( completeUrl , body , {
152
202
headers : {
153
203
// By default, Axios sets "Content-Type: application/x-www-form-urlencoded" on POST
154
204
// requests. This causes AWS's API to interpret the request body as additional parameters
@@ -157,6 +207,8 @@ export default class S3FileFieldClient {
157
207
// CompleteMultipartUpload docs.
158
208
'Content-Type' : null ,
159
209
} ,
210
+ } ) , ( ) => {
211
+ this . onProgress ( { state : S3FileFieldProgressState . Retrying } ) ;
160
212
} ) ;
161
213
}
162
214
@@ -168,8 +220,10 @@ export default class S3FileFieldClient {
168
220
* @param multipartInfo Signed information returned from /upload-complete/.
169
221
*/
170
222
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/' , {
172
224
upload_signature : multipartInfo . upload_signature ,
225
+ } ) , ( ) => {
226
+ this . onProgress ( { state : S3FileFieldProgressState . Retrying } ) ;
173
227
} ) ;
174
228
return response . data . field_value ;
175
229
}
0 commit comments