@merchi/sdk 1.42.18 → 1.42.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/request.js +103 -26
- package/dist/request.test.js +243 -3
- package/package.json +1 -1
- package/src/request.test.ts +89 -3
- package/src/request.ts +31 -10
package/dist/request.js
CHANGED
|
@@ -13,6 +13,53 @@ var __extends = (this && this.__extends) || (function () {
|
|
|
13
13
|
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
|
14
14
|
};
|
|
15
15
|
})();
|
|
16
|
+
var __assign = (this && this.__assign) || function () {
|
|
17
|
+
__assign = Object.assign || function(t) {
|
|
18
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
19
|
+
s = arguments[i];
|
|
20
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
21
|
+
t[p] = s[p];
|
|
22
|
+
}
|
|
23
|
+
return t;
|
|
24
|
+
};
|
|
25
|
+
return __assign.apply(this, arguments);
|
|
26
|
+
};
|
|
27
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
28
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
29
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
30
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
31
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
32
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
33
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
37
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
38
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
39
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
40
|
+
function step(op) {
|
|
41
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
42
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
43
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
44
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
45
|
+
switch (op[0]) {
|
|
46
|
+
case 0: case 1: t = op; break;
|
|
47
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
48
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
49
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
50
|
+
default:
|
|
51
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
52
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
53
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
54
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
55
|
+
if (t[2]) _.ops.pop();
|
|
56
|
+
_.trys.pop(); continue;
|
|
57
|
+
}
|
|
58
|
+
op = body.call(thisArg, _);
|
|
59
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
60
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
61
|
+
}
|
|
62
|
+
};
|
|
16
63
|
var __values = (this && this.__values) || function(o) {
|
|
17
64
|
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
|
|
18
65
|
if (m) return m.call(o);
|
|
@@ -42,10 +89,11 @@ var ApiError = /** @class */ (function (_super) {
|
|
|
42
89
|
var message = resolveApiErrorMessage(err);
|
|
43
90
|
/* istanbul ignore next */
|
|
44
91
|
_this = _super.call(this, message) || this;
|
|
45
|
-
|
|
46
|
-
_this.
|
|
92
|
+
var fields = err && typeof err === 'object' ? err : {};
|
|
93
|
+
_this.statusCode = fields.statusCode;
|
|
94
|
+
_this.errorCode = getErrorFromCode(fields.errorCode);
|
|
47
95
|
_this.errorMessage = message;
|
|
48
|
-
_this.details =
|
|
96
|
+
_this.details = fields.details;
|
|
49
97
|
_this.name = 'ApiError';
|
|
50
98
|
_this.original = err;
|
|
51
99
|
return _this;
|
|
@@ -78,34 +126,63 @@ function ensureVersionInUri(baseUrl) {
|
|
|
78
126
|
};
|
|
79
127
|
}
|
|
80
128
|
export function backendFetch(baseUrl, resource, options) {
|
|
81
|
-
var
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
129
|
+
var _a, _b;
|
|
130
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
131
|
+
var _c, cleanBaseUrl, apiVersion, cleanResource, url, query, query_1, query_1_1, entry, method, error_1, networkError;
|
|
132
|
+
var e_1, _d;
|
|
133
|
+
return __generator(this, function (_e) {
|
|
134
|
+
switch (_e.label) {
|
|
135
|
+
case 0:
|
|
136
|
+
_c = ensureVersionInUri(baseUrl), cleanBaseUrl = _c.baseUrl, apiVersion = _c.version;
|
|
137
|
+
cleanResource = resource.startsWith('/') ? resource.slice(1) : resource;
|
|
138
|
+
url = new URL(cleanBaseUrl + apiVersion + '/' + cleanResource);
|
|
139
|
+
query = options === null || options === void 0 ? void 0 : options.query;
|
|
140
|
+
if (query) {
|
|
141
|
+
try {
|
|
142
|
+
for (query_1 = __values(query), query_1_1 = query_1.next(); !query_1_1.done; query_1_1 = query_1.next()) {
|
|
143
|
+
entry = query_1_1.value;
|
|
144
|
+
url.searchParams.append(entry[0], entry[1]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
148
|
+
finally {
|
|
149
|
+
try {
|
|
150
|
+
if (query_1_1 && !query_1_1.done && (_d = query_1.return)) _d.call(query_1);
|
|
151
|
+
}
|
|
152
|
+
finally { if (e_1) throw e_1.error; }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
method = ((options === null || options === void 0 ? void 0 : options.method) || 'GET').toUpperCase();
|
|
156
|
+
_e.label = 1;
|
|
157
|
+
case 1:
|
|
158
|
+
_e.trys.push([1, 3, , 5]);
|
|
159
|
+
return [4 /*yield*/, fetch(url.toString(), options)];
|
|
160
|
+
case 2: return [2 /*return*/, _e.sent()];
|
|
161
|
+
case 3:
|
|
162
|
+
error_1 = _e.sent();
|
|
163
|
+
networkError = error_1 instanceof TypeError ||
|
|
164
|
+
(error_1 && typeof error_1 === 'object' && error_1.name === 'NetworkError');
|
|
165
|
+
if (!networkError || !['GET', 'HEAD'].includes(method) || ((_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.aborted)) {
|
|
166
|
+
throw error_1;
|
|
167
|
+
}
|
|
168
|
+
return [4 /*yield*/, new Promise(function (resolve) { return setTimeout(resolve, 250); })];
|
|
169
|
+
case 4:
|
|
170
|
+
_e.sent();
|
|
171
|
+
if ((_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.aborted)
|
|
172
|
+
throw error_1;
|
|
173
|
+
return [2 /*return*/, fetch(url.toString(), options)];
|
|
174
|
+
case 5: return [2 /*return*/];
|
|
97
175
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
return fetch(url.toString(), options);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
102
178
|
}
|
|
103
179
|
export function apiFetch(baseUrl, resource, options, expectEmptyResponse) {
|
|
104
180
|
return backendFetch(baseUrl, resource, options).then(function (response) {
|
|
105
181
|
if (response.status < 200 || response.status > 299) {
|
|
106
|
-
return response.json().then(function (json) {
|
|
107
|
-
var
|
|
108
|
-
|
|
182
|
+
return response.json().catch(function () { return null; }).then(function (json) {
|
|
183
|
+
var fields = json && typeof json === 'object' ? json : {};
|
|
184
|
+
throw new ApiError(__assign(__assign({}, fields), { statusCode: response.status, message: typeof json === 'string' && json ? json : fields.message ||
|
|
185
|
+
"Request failed (HTTP ".concat(response.status, ")") }));
|
|
109
186
|
});
|
|
110
187
|
}
|
|
111
188
|
else {
|
package/dist/request.test.js
CHANGED
|
@@ -1,4 +1,51 @@
|
|
|
1
|
-
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
11
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
12
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
13
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
14
|
+
function step(op) {
|
|
15
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
16
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
17
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
18
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
19
|
+
switch (op[0]) {
|
|
20
|
+
case 0: case 1: t = op; break;
|
|
21
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
22
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
23
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
24
|
+
default:
|
|
25
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
26
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
27
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
28
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
29
|
+
if (t[2]) _.ops.pop();
|
|
30
|
+
_.trys.pop(); continue;
|
|
31
|
+
}
|
|
32
|
+
op = body.call(thisArg, _);
|
|
33
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
34
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var __values = (this && this.__values) || function(o) {
|
|
38
|
+
var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0;
|
|
39
|
+
if (m) return m.call(o);
|
|
40
|
+
if (o && typeof o.length === "number") return {
|
|
41
|
+
next: function () {
|
|
42
|
+
if (o && i >= o.length) o = void 0;
|
|
43
|
+
return { value: o && o[i++], done: !o };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined.");
|
|
47
|
+
};
|
|
48
|
+
import { apiFetch, backendFetch, ApiError } from './request.js';
|
|
2
49
|
import { ErrorType } from './constants/errors.js';
|
|
3
50
|
import { setup, mockFetch } from './test_util.js';
|
|
4
51
|
setup();
|
|
@@ -17,11 +64,11 @@ test('can pass through data from server with override url', function () {
|
|
|
17
64
|
});
|
|
18
65
|
test('404 creates ApiError', function () {
|
|
19
66
|
mockFetch(false, { 'statusCode': 404, 'errorCode': ErrorType.RESOURCE_NOT_FOUND }, 404);
|
|
20
|
-
apiFetch('https://api.merchi.co/', '/test').catch(function (e) {
|
|
67
|
+
return apiFetch('https://api.merchi.co/', '/test').catch(function (e) {
|
|
21
68
|
expect(e.statusCode).toBe(404);
|
|
22
69
|
expect(e.name).toBe('ApiError');
|
|
23
70
|
expect(e.errorCode).toBe(ErrorType.RESOURCE_NOT_FOUND);
|
|
24
|
-
expect(e.errorMessage).toBe('
|
|
71
|
+
expect(e.errorMessage).toBe('Request failed (HTTP 404)');
|
|
25
72
|
});
|
|
26
73
|
});
|
|
27
74
|
test('will get default errorCode', function () {
|
|
@@ -87,3 +134,196 @@ test('ApiError leaves details undefined when the server omits them', function ()
|
|
|
87
134
|
expect(e.details).toBeUndefined();
|
|
88
135
|
});
|
|
89
136
|
});
|
|
137
|
+
test.each([null, undefined])('ApiError accepts an empty response body (%s)', function (value) {
|
|
138
|
+
var error = new ApiError(value);
|
|
139
|
+
expect(error.message).toBe('No error message');
|
|
140
|
+
expect(error.statusCode).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
test.each(['GET', 'HEAD', undefined])('retries one lost %s response', function (method) { return __awaiter(void 0, void 0, void 0, function () {
|
|
143
|
+
var response, fetch;
|
|
144
|
+
return __generator(this, function (_a) {
|
|
145
|
+
switch (_a.label) {
|
|
146
|
+
case 0:
|
|
147
|
+
response = { status: 200, json: function () { return Promise.resolve({ cart: { id: 42 } }); } };
|
|
148
|
+
fetch = jest.fn().mockRejectedValueOnce(new TypeError('Load failed')).mockResolvedValue(response);
|
|
149
|
+
global.fetch = fetch;
|
|
150
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/', { method: method })).resolves.toEqual({ cart: { id: 42 } })];
|
|
151
|
+
case 1:
|
|
152
|
+
_a.sent();
|
|
153
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
154
|
+
expect(fetch.mock.calls[0]).toEqual(fetch.mock.calls[1]);
|
|
155
|
+
return [2 /*return*/];
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}); });
|
|
159
|
+
test('retries Safari NetworkError reads', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
160
|
+
var error, fetch;
|
|
161
|
+
return __generator(this, function (_a) {
|
|
162
|
+
switch (_a.label) {
|
|
163
|
+
case 0:
|
|
164
|
+
error = Object.assign(new Error('A network error occurred.'), { name: 'NetworkError' });
|
|
165
|
+
fetch = jest.fn().mockRejectedValueOnce(error).mockResolvedValue({ status: 200, json: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
166
|
+
return [2 /*return*/, ({})];
|
|
167
|
+
}); }); } });
|
|
168
|
+
global.fetch = fetch;
|
|
169
|
+
return [4 /*yield*/, apiFetch('https://api.merchi.co/', '/carts/42/')];
|
|
170
|
+
case 1:
|
|
171
|
+
_a.sent();
|
|
172
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
173
|
+
return [2 /*return*/];
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}); });
|
|
177
|
+
test('read retry is bounded and preserves the final failure', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
178
|
+
var error, fetch;
|
|
179
|
+
return __generator(this, function (_a) {
|
|
180
|
+
switch (_a.label) {
|
|
181
|
+
case 0:
|
|
182
|
+
error = new TypeError('Failed to fetch');
|
|
183
|
+
fetch = jest.fn().mockRejectedValue(error);
|
|
184
|
+
global.fetch = fetch;
|
|
185
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toBe(error)];
|
|
186
|
+
case 1:
|
|
187
|
+
_a.sent();
|
|
188
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
189
|
+
return [2 /*return*/];
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}); });
|
|
193
|
+
test.each(['POST', 'PATCH', 'PUT', 'DELETE'])('never retries %s after a lost response', function (method) { return __awaiter(void 0, void 0, void 0, function () {
|
|
194
|
+
var error, fetch;
|
|
195
|
+
return __generator(this, function (_a) {
|
|
196
|
+
switch (_a.label) {
|
|
197
|
+
case 0:
|
|
198
|
+
error = new TypeError('Load failed');
|
|
199
|
+
fetch = jest.fn().mockRejectedValue(error);
|
|
200
|
+
global.fetch = fetch;
|
|
201
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/', { method: method })).rejects.toBe(error)];
|
|
202
|
+
case 1:
|
|
203
|
+
_a.sent();
|
|
204
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
205
|
+
return [2 /*return*/];
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}); });
|
|
209
|
+
test('does not retry aborts or unrelated exceptions', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
210
|
+
var _a, _b, error, fetch_1, e_1_1;
|
|
211
|
+
var e_1, _c;
|
|
212
|
+
return __generator(this, function (_d) {
|
|
213
|
+
switch (_d.label) {
|
|
214
|
+
case 0:
|
|
215
|
+
_d.trys.push([0, 5, 6, 7]);
|
|
216
|
+
_a = __values([Object.assign(new Error('Cancelled'), { name: 'AbortError' }), new Error('unexpected failure')]), _b = _a.next();
|
|
217
|
+
_d.label = 1;
|
|
218
|
+
case 1:
|
|
219
|
+
if (!!_b.done) return [3 /*break*/, 4];
|
|
220
|
+
error = _b.value;
|
|
221
|
+
fetch_1 = jest.fn().mockRejectedValue(error);
|
|
222
|
+
global.fetch = fetch_1;
|
|
223
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toBe(error)];
|
|
224
|
+
case 2:
|
|
225
|
+
_d.sent();
|
|
226
|
+
expect(fetch_1).toHaveBeenCalledTimes(1);
|
|
227
|
+
_d.label = 3;
|
|
228
|
+
case 3:
|
|
229
|
+
_b = _a.next();
|
|
230
|
+
return [3 /*break*/, 1];
|
|
231
|
+
case 4: return [3 /*break*/, 7];
|
|
232
|
+
case 5:
|
|
233
|
+
e_1_1 = _d.sent();
|
|
234
|
+
e_1 = { error: e_1_1 };
|
|
235
|
+
return [3 /*break*/, 7];
|
|
236
|
+
case 6:
|
|
237
|
+
try {
|
|
238
|
+
if (_b && !_b.done && (_c = _a.return)) _c.call(_a);
|
|
239
|
+
}
|
|
240
|
+
finally { if (e_1) throw e_1.error; }
|
|
241
|
+
return [7 /*endfinally*/];
|
|
242
|
+
case 7: return [2 /*return*/];
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}); });
|
|
246
|
+
test('does not retry a network error after its signal is aborted', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
247
|
+
var controller, error;
|
|
248
|
+
return __generator(this, function (_a) {
|
|
249
|
+
switch (_a.label) {
|
|
250
|
+
case 0:
|
|
251
|
+
controller = new AbortController();
|
|
252
|
+
error = new TypeError('Load failed');
|
|
253
|
+
global.fetch = jest.fn().mockImplementation(function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
254
|
+
controller.abort();
|
|
255
|
+
throw error;
|
|
256
|
+
}); }); });
|
|
257
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/', { signal: controller.signal })).rejects.toBe(error)];
|
|
258
|
+
case 1:
|
|
259
|
+
_a.sent();
|
|
260
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
261
|
+
return [2 /*return*/];
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}); });
|
|
265
|
+
test('cancelling during the retry delay prevents a second request', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
266
|
+
var controller, error, pending;
|
|
267
|
+
return __generator(this, function (_a) {
|
|
268
|
+
switch (_a.label) {
|
|
269
|
+
case 0:
|
|
270
|
+
controller = new AbortController();
|
|
271
|
+
error = new TypeError('Load failed');
|
|
272
|
+
global.fetch = jest.fn().mockRejectedValue(error);
|
|
273
|
+
pending = apiFetch('https://api.merchi.co/', '/carts/42/', { signal: controller.signal });
|
|
274
|
+
return [4 /*yield*/, Promise.resolve()];
|
|
275
|
+
case 1:
|
|
276
|
+
_a.sent();
|
|
277
|
+
controller.abort();
|
|
278
|
+
return [4 /*yield*/, expect(pending).rejects.toBe(error)];
|
|
279
|
+
case 2:
|
|
280
|
+
_a.sent();
|
|
281
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
282
|
+
return [2 /*return*/];
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}); });
|
|
286
|
+
test('query parameters survive retries without duplication', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
287
|
+
var fetch;
|
|
288
|
+
return __generator(this, function (_a) {
|
|
289
|
+
switch (_a.label) {
|
|
290
|
+
case 0:
|
|
291
|
+
fetch = jest.fn().mockRejectedValueOnce(new TypeError('Load failed')).mockResolvedValue({ status: 200 });
|
|
292
|
+
global.fetch = fetch;
|
|
293
|
+
return [4 /*yield*/, backendFetch('https://api.merchi.co/v6/', '/carts/42/', { query: [['cart_token', 'test-token']], method: 'GET' })];
|
|
294
|
+
case 1:
|
|
295
|
+
_a.sent();
|
|
296
|
+
expect(fetch).toHaveBeenCalledWith('https://api.merchi.co/v6/carts/42/?cart_token=test-token', { method: 'GET', query: [['cart_token', 'test-token']] });
|
|
297
|
+
return [2 /*return*/];
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}); });
|
|
301
|
+
test('non-JSON gateway failures preserve their HTTP status without exposing HTML', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
302
|
+
var fetch;
|
|
303
|
+
return __generator(this, function (_a) {
|
|
304
|
+
switch (_a.label) {
|
|
305
|
+
case 0:
|
|
306
|
+
fetch = jest.fn().mockResolvedValue({ status: 502, json: function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
|
|
307
|
+
throw new SyntaxError('private gateway HTML');
|
|
308
|
+
}); }); } });
|
|
309
|
+
global.fetch = fetch;
|
|
310
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toMatchObject({ name: 'ApiError', statusCode: 502, message: 'Request failed (HTTP 502)' })];
|
|
311
|
+
case 1:
|
|
312
|
+
_a.sent();
|
|
313
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
314
|
+
return [2 /*return*/];
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}); });
|
|
318
|
+
test.each([null, 'Service unavailable', { statusCode: 200, message: 'Service unavailable' }])('HTTP status is authoritative for error body %s', function (body) { return __awaiter(void 0, void 0, void 0, function () {
|
|
319
|
+
return __generator(this, function (_a) {
|
|
320
|
+
switch (_a.label) {
|
|
321
|
+
case 0:
|
|
322
|
+
mockFetch(false, body, 503);
|
|
323
|
+
return [4 /*yield*/, expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toMatchObject({ name: 'ApiError', statusCode: 503 })];
|
|
324
|
+
case 1:
|
|
325
|
+
_a.sent();
|
|
326
|
+
return [2 /*return*/];
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}); });
|
package/package.json
CHANGED
package/src/request.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { apiFetch, ApiError } from './request.js';
|
|
1
|
+
import { apiFetch, backendFetch, ApiError } from './request.js';
|
|
2
2
|
import { ErrorType } from './constants/errors.js';
|
|
3
3
|
import { setup, mockFetch } from './test_util.js';
|
|
4
4
|
|
|
@@ -21,11 +21,11 @@ test('can pass through data from server with override url', () => {
|
|
|
21
21
|
|
|
22
22
|
test('404 creates ApiError', () => {
|
|
23
23
|
mockFetch(false, {'statusCode': 404, 'errorCode': ErrorType.RESOURCE_NOT_FOUND}, 404);
|
|
24
|
-
apiFetch('https://api.merchi.co/', '/test').catch(e => {
|
|
24
|
+
return apiFetch('https://api.merchi.co/', '/test').catch(e => {
|
|
25
25
|
expect(e.statusCode).toBe(404);
|
|
26
26
|
expect(e.name).toBe('ApiError');
|
|
27
27
|
expect(e.errorCode).toBe(ErrorType.RESOURCE_NOT_FOUND);
|
|
28
|
-
expect(e.errorMessage).toBe('
|
|
28
|
+
expect(e.errorMessage).toBe('Request failed (HTTP 404)');
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -98,3 +98,89 @@ test('ApiError leaves details undefined when the server omits them', () => {
|
|
|
98
98
|
expect(e.details).toBeUndefined();
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test.each([null, undefined])('ApiError accepts an empty response body (%s)', value => {
|
|
103
|
+
const error = new ApiError(value);
|
|
104
|
+
expect(error.message).toBe('No error message');
|
|
105
|
+
expect(error.statusCode).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test.each(['GET', 'HEAD', undefined])('retries one lost %s response', async method => {
|
|
109
|
+
const response = { status: 200, json: () => Promise.resolve({ cart: { id: 42 } }) };
|
|
110
|
+
const fetch = jest.fn().mockRejectedValueOnce(new TypeError('Load failed')).mockResolvedValue(response);
|
|
111
|
+
(global as any).fetch = fetch;
|
|
112
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/', { method })).resolves.toEqual({ cart: { id: 42 } });
|
|
113
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
114
|
+
expect(fetch.mock.calls[0]).toEqual(fetch.mock.calls[1]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('retries Safari NetworkError reads', async () => {
|
|
118
|
+
const error = Object.assign(new Error('A network error occurred.'), { name: 'NetworkError' });
|
|
119
|
+
const fetch = jest.fn().mockRejectedValueOnce(error).mockResolvedValue({ status: 200, json: async () => ({}) });
|
|
120
|
+
(global as any).fetch = fetch;
|
|
121
|
+
await apiFetch('https://api.merchi.co/', '/carts/42/');
|
|
122
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('read retry is bounded and preserves the final failure', async () => {
|
|
126
|
+
const error = new TypeError('Failed to fetch');
|
|
127
|
+
const fetch = jest.fn().mockRejectedValue(error);
|
|
128
|
+
(global as any).fetch = fetch;
|
|
129
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toBe(error);
|
|
130
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test.each(['POST', 'PATCH', 'PUT', 'DELETE'])('never retries %s after a lost response', async method => {
|
|
134
|
+
const error = new TypeError('Load failed');
|
|
135
|
+
const fetch = jest.fn().mockRejectedValue(error);
|
|
136
|
+
(global as any).fetch = fetch;
|
|
137
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/', { method })).rejects.toBe(error);
|
|
138
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('does not retry aborts or unrelated exceptions', async () => {
|
|
142
|
+
for (const error of [Object.assign(new Error('Cancelled'), { name: 'AbortError' }), new Error('unexpected failure')]) {
|
|
143
|
+
const fetch = jest.fn().mockRejectedValue(error);
|
|
144
|
+
(global as any).fetch = fetch;
|
|
145
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toBe(error);
|
|
146
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('does not retry a network error after its signal is aborted', async () => {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const error = new TypeError('Load failed');
|
|
153
|
+
(global as any).fetch = jest.fn().mockImplementation(async () => { controller.abort(); throw error; });
|
|
154
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/', { signal: controller.signal })).rejects.toBe(error);
|
|
155
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('cancelling during the retry delay prevents a second request', async () => {
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const error = new TypeError('Load failed');
|
|
161
|
+
(global as any).fetch = jest.fn().mockRejectedValue(error);
|
|
162
|
+
const pending = apiFetch('https://api.merchi.co/', '/carts/42/', { signal: controller.signal });
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
controller.abort();
|
|
165
|
+
await expect(pending).rejects.toBe(error);
|
|
166
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('query parameters survive retries without duplication', async () => {
|
|
170
|
+
const fetch = jest.fn().mockRejectedValueOnce(new TypeError('Load failed')).mockResolvedValue({ status: 200 });
|
|
171
|
+
(global as any).fetch = fetch;
|
|
172
|
+
await backendFetch('https://api.merchi.co/v6/', '/carts/42/', { query: [['cart_token', 'test-token']], method: 'GET' });
|
|
173
|
+
expect(fetch).toHaveBeenCalledWith('https://api.merchi.co/v6/carts/42/?cart_token=test-token', { method: 'GET', query: [['cart_token', 'test-token']] });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('non-JSON gateway failures preserve their HTTP status without exposing HTML', async () => {
|
|
177
|
+
const fetch = jest.fn().mockResolvedValue({ status: 502, json: async () => { throw new SyntaxError('private gateway HTML'); } });
|
|
178
|
+
(global as any).fetch = fetch;
|
|
179
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toMatchObject({ name: 'ApiError', statusCode: 502, message: 'Request failed (HTTP 502)' });
|
|
180
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test.each([null, 'Service unavailable', { statusCode: 200, message: 'Service unavailable' }])('HTTP status is authoritative for error body %s', async body => {
|
|
184
|
+
mockFetch(false, body, 503);
|
|
185
|
+
await expect(apiFetch('https://api.merchi.co/', '/carts/42/')).rejects.toMatchObject({ name: 'ApiError', statusCode: 503 });
|
|
186
|
+
});
|
package/src/request.ts
CHANGED
|
@@ -65,10 +65,11 @@ export class ApiError extends Error {
|
|
|
65
65
|
const message = resolveApiErrorMessage(err);
|
|
66
66
|
/* istanbul ignore next */
|
|
67
67
|
super(message);
|
|
68
|
-
|
|
69
|
-
this.
|
|
68
|
+
const fields = err && typeof err === 'object' ? err : {};
|
|
69
|
+
this.statusCode = fields.statusCode;
|
|
70
|
+
this.errorCode = getErrorFromCode(fields.errorCode);
|
|
70
71
|
this.errorMessage = message;
|
|
71
|
-
this.details =
|
|
72
|
+
this.details = fields.details;
|
|
72
73
|
this.name = 'ApiError';
|
|
73
74
|
this.original = err;
|
|
74
75
|
}
|
|
@@ -108,17 +109,32 @@ function ensureVersionInUri(baseUrl: string): UriComponents {
|
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
export function backendFetch(baseUrl: string, resource: string, options?: RequestOptions) {
|
|
112
|
+
export async function backendFetch(baseUrl: string, resource: string, options?: RequestOptions) {
|
|
112
113
|
const { baseUrl: cleanBaseUrl, version: apiVersion } = ensureVersionInUri(baseUrl);
|
|
113
114
|
// Remove leading slash from resource if it exists to prevent double slashes
|
|
114
115
|
const cleanResource = resource.startsWith('/') ? resource.slice(1) : resource;
|
|
115
116
|
const url = new URL(cleanBaseUrl + apiVersion + '/' + cleanResource);
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
const query = options?.query;
|
|
118
|
+
if (query) {
|
|
119
|
+
for (const entry of query) {
|
|
118
120
|
url.searchParams.append(entry[0], entry[1]);
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
|
-
|
|
123
|
+
const method = (options?.method || 'GET').toUpperCase();
|
|
124
|
+
try {
|
|
125
|
+
return await fetch(url.toString(), options);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Only reads are safe to repeat when the browser loses a response. A cart
|
|
128
|
+
// creation or payment may already have succeeded on the server.
|
|
129
|
+
const networkError = error instanceof TypeError ||
|
|
130
|
+
(error && typeof error === 'object' && (error as Error).name === 'NetworkError');
|
|
131
|
+
if (!networkError || !['GET', 'HEAD'].includes(method) || options?.signal?.aborted) {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
135
|
+
if (options?.signal?.aborted) throw error;
|
|
136
|
+
return fetch(url.toString(), options);
|
|
137
|
+
}
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
export function apiFetch(
|
|
@@ -130,9 +146,14 @@ export function apiFetch(
|
|
|
130
146
|
return backendFetch(baseUrl, resource, options as RequestInit | undefined).then(
|
|
131
147
|
function (response) {
|
|
132
148
|
if (response.status < 200 || response.status > 299) {
|
|
133
|
-
return response.json().then(function (json) {
|
|
134
|
-
const
|
|
135
|
-
|
|
149
|
+
return response.json().catch(() => null).then(function (json) {
|
|
150
|
+
const fields = json && typeof json === 'object' ? json : {};
|
|
151
|
+
throw new ApiError({
|
|
152
|
+
...fields,
|
|
153
|
+
statusCode: response.status,
|
|
154
|
+
message: typeof json === 'string' && json ? json : fields.message ||
|
|
155
|
+
`Request failed (HTTP ${response.status})`,
|
|
156
|
+
});
|
|
136
157
|
});
|
|
137
158
|
} else {
|
|
138
159
|
return expectEmptyResponse ? '' : response.json();
|