UNPKG

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