UNPKG

7.25 kBJavaScriptView Raw
1const hash = require('object-hash');
2const fetchAPI = require('@adobe/helix-fetch');
3const { Lock } = require('./lock');
4
5const context = process.env.HELIX_FETCH_FORCE_HTTP1
6 ? fetchAPI.context({
7 httpProtocols: ['http1'],
8 httpsProtocols: ['http1'],
9 })
10 : fetchAPI;
11const { fetch } = context;
12
13class FastlyError extends Error {
14 constructor(response, text) {
15 try {
16 const body = JSON.parse(text);
17 super(body.detail || body.msg || text);
18 this.data = body;
19 this.code = body.msg;
20 } catch {
21 // eslint-disable-next-line constructor-super
22 super(text);
23 }
24
25 this.status = response.status;
26 this.name = 'FastlyError';
27 }
28}
29
30function memo(fn) {
31 const keyfn = (...args) => hash(args[0]);
32 const cache = new Map();
33 return (...fnargs) => {
34 const key = keyfn(...fnargs);
35 if (cache.has(key)) {
36 return cache.get(key);
37 }
38 const prom = fn(...fnargs);
39 return Promise.resolve(prom).then((res) => {
40 cache.set(key, res);
41 return res;
42 }).catch((e) => {
43 throw e;
44 });
45 };
46}
47
48function repeatError(error) {
49 if (error.name === 'FastlyError') {
50 return false;
51 }
52 return error.name === 'Error';
53}
54
55function repeatResponse({ status }) {
56 if (status === 429) {
57 return true;
58 }
59 if (status > 500 && status < 505) {
60 return true;
61 }
62 return false;
63}
64
65/**
66 * Determines if a response or error indicates that the response is repeatable.
67 *
68 * @param {object} responseOrError - – the error response or error object.
69 * @returns {boolean} - True, if another attempt can be made.
70 */
71function repeat(responseOrError) {
72 if (responseOrError instanceof Error) {
73 return repeatError(responseOrError);
74 }
75 return responseOrError.status ? repeatResponse(responseOrError) : false;
76}
77
78function create({ baseURL, timeout, headers }) {
79 const responselog = [];
80 /**
81 * Creates a function that mimicks the Axios request API
82 * for the selected HTTP method. Optionally enables
83 * memoization (function will always return the same results
84 * for the same arguments).
85 *
86 * @param {string} method - The HTTP method (lowercase).
87 * @param {boolean} memoize - Cache results (off by default).
88 * @param {number} retries - Number of retries in case of flaky servers (default 0).
89 * @returns {Function} - A function that makes HTTP requests.
90 */
91 function makereq(method, memoize = false, retries = 0) {
92 const myreq = function req(path, body, config) {
93 const myheaders = Object.assign(headers,
94 config && config.headers ? config.headers : {});
95
96 const options = {
97 method,
98 headers: myheaders,
99 cache: 'no-store',
100 };
101
102 const uri = `${baseURL}${path}`;
103
104 if (timeout) {
105 options.signal = context.timeoutSignal(timeout);
106 }
107
108 // set body or form based on content type. default is form, except for patch ;-)
109 const contentType = myheaders['content-type']
110 || (method === 'patch' ? 'application/json' : 'application/x-www-form-urlencoded');
111 if (contentType === 'application/x-www-form-urlencoded') {
112 // create form data
113 options.body = new URLSearchParams(Object.entries(body || {})).toString();
114 } else {
115 // send JSON
116 options.json = body;
117 }
118 options.headers['Content-Type'] = contentType;
119 const start = Date.now();
120
121 const reqfn = (attempt) => fetch(uri, options).then((response) => {
122 const end = Date.now();
123 responselog.push({ 'request-duration': end - start, headers: response.headers });
124
125 if (!response.ok) {
126 if (attempt < retries && repeat(response)) {
127 return response.text().then(() => reqfn(attempt + 1));
128 }
129 return response.text().then((text) => { throw new FastlyError(response, text); });
130 }
131
132 return response.text().then((text) => {
133 let data = text;
134 try {
135 data = JSON.parse(text);
136 return {
137 status: response.status,
138 statusText: response.statusText,
139 headers: response.headers,
140 config: options,
141 data,
142 };
143 } catch {
144 return {
145 status: response.status,
146 statusText: response.statusText,
147 headers: response.headers,
148 config: options,
149 data,
150 };
151 }
152 });
153 }).catch((reason) => {
154 if (attempt < retries && repeat(reason)) {
155 return reqfn(attempt + 1);
156 }
157 throw reason;
158 });
159 return reqfn(0);
160 };
161 return memoize ? memo(myreq) : myreq;
162 }
163
164 const lock = new Lock();
165
166 /**
167 * Guards a function against concurrent execution.
168 *
169 * @param {Function} fn - The function to guard.
170 * @returns {Function} A guarded function.
171 */
172 /* istanbul ignore next */
173 function protect(fn) { // eslint-disable-line no-unused-vars
174 return async (...args) => {
175 await lock.acquire();
176 try {
177 return fn(...args);
178 } finally {
179 lock.release();
180 }
181 };
182 }
183
184 const client = {
185 // remove serialization of API calls: too broad in scope
186
187 // post: protect(makereq('post')),
188 post: makereq('post'),
189 get: makereq('get', true, 2),
190 // put: protect(makereq('put')),
191 put: makereq('put'),
192 // patch: protect(makereq('patch')),
193 patch: makereq('patch'),
194 // delete: protect(makereq('delete')),
195 delete: makereq('delete'),
196 monitor: {
197 get count() {
198 return responselog.length;
199 },
200
201 get remaining() {
202 return responselog
203 .map((o) => o.headers)
204 .filter((hdrs) => typeof hdrs.get('fastly-ratelimit-remaining') !== 'undefined')
205 .map((hdrs) => hdrs.get('fastly-ratelimit-remaining'))
206 .map((remaining) => Number.parseInt(remaining, 10))
207 .pop();
208 },
209
210 get edgedurations() {
211 return responselog
212 .map((o) => o.headers)
213 .filter((hdrs) => typeof hdrs.get('x-timer') !== 'undefined')
214 .map((hdrs) => hdrs.get('x-timer'))
215 .map((timer) => timer.split(',').pop())
216 .map((ve) => ve.substring(2))
217 .map((ve) => Number.parseInt(ve, 10));
218 },
219
220 get durations() {
221 return responselog
222 .filter((hdrs) => typeof hdrs['request-duration'] !== 'undefined')
223 .map((hdrs) => hdrs['request-duration'])
224 .map((ve) => Number.parseInt(ve, 10));
225 },
226
227 get stats() {
228 const retval = {
229 count: this.count,
230 remaining: this.remaining,
231 minduration: Math.min(...this.durations),
232 maxduration: Math.max(...this.durations),
233 meanduration: Math.round(this.durations.reduce((a, b) => a + b, 0)
234 / this.durations.length),
235 minedgeduration: Math.min(...this.edgedurations),
236 maxedgeduration: Math.max(...this.edgedurations),
237 meanedgeduration: Math.round(this.edgedurations.reduce((a, b) => a + b, 0)
238 / this.edgedurations.length),
239 };
240 return retval;
241 },
242 },
243 };
244
245 client.get.fresh = makereq('get');
246 return client;
247}
248
249module.exports = { create, FastlyError };