UNPKG

10.3 kBPlain TextView Raw
1import { URL } from 'url'
2import { _since } from '@naturalcycles/js-lib'
3import got, {
4 AfterResponseHook,
5 BeforeErrorHook,
6 BeforeRequestHook,
7 BeforeRetryHook,
8 Got,
9} from 'got'
10import { inspectAny } from '..'
11import { GetGotOptions, GotRequestContext } from './got.model'
12
13/**
14 * Returns instance of Got with "reasonable defaults":
15 *
16 * 1. Error handler hook that prints helpful errors.
17 * 2. Hooks that log start/end of request (optional, false by default).
18 * 3. Reasonable defaults(tm), e.g non-infinite Timeout
19 * 4. Preserves error stack traces (!) (experimental!)
20 */
21export function getGot(opt: GetGotOptions = {}): Got {
22 opt.logger ||= console
23
24 if (opt.debug) {
25 opt.logStart = opt.logFinished = opt.logResponse = opt.logRequest = true
26 }
27
28 return got.extend({
29 // Most-important is to set to anything non-empty (so, requests don't "hang" by default).
30 // Should be long enough to handle for slow responses from scaled cloud APIs in times of spikes
31 // Ideally should be LESS than default Request timeout in backend-lib (so, it has a chance to error
32 // before server times out with 503).
33 //
34 // UPD 2021-11-27
35 // There are 2 types/strategies for requests:
36 // 1. Optimized to get result no matter what. E.g in Cron jobs, where otherwise there'll be a job failure
37 // 2. Part of the Backend request, where we better retry quickly and fail on timeout before Backend aborts it with "503 Request timeout"
38 //
39 // Here it's hard to set the default timeout right for both use-cases.
40 // So, if it's important, you should override it according to your use-cases:
41 // - set it longer for Type 1 (e.g 120 seconds)
42 // - set it shorter for Type 2 (e.g 10/20 seconds)
43 // Please beware of default Retry strategy of Got:
44 // by default it will retry 2 times (after first try)
45 // First delay between tries will be ~1 second, then ~2 seconds
46 // Each retry it'll wait up to `timeout` (so, up to 60 seconds by default).
47 // So, for 3 tries it multiplies your timeout by 3 (+3 seconds between the tries).
48 // So, e.g 60 seconds timeout with 2 retries becomes up to 183 seconds.
49 // Which definitely doesn't fit into default "RequestTimeout"
50 timeout: 60_000,
51 ...opt,
52 handlers: [
53 (options, next) => {
54 options.context = {
55 ...options.context,
56 started: Date.now(),
57 // This is to preserve original stack trace
58 // https://github.com/sindresorhus/got/blob/main/documentation/async-stack-traces.md
59 err: new Error('RequestError'),
60 } as GotRequestContext
61
62 return next(options)
63 },
64 ],
65 hooks: {
66 ...opt.hooks,
67 beforeError: [
68 ...(opt.hooks?.beforeError || []),
69 // User hooks go BEFORE
70 gotErrorHook(opt),
71 ],
72 beforeRequest: [
73 gotBeforeRequestHook(opt),
74 // User hooks go AFTER
75 ...(opt.hooks?.beforeRequest || []),
76 ],
77 beforeRetry: [
78 gotBeforeRetryHook(opt),
79 // User hooks go AFTER
80 ...(opt.hooks?.beforeRetry || []),
81 ],
82 afterResponse: [
83 ...(opt.hooks?.afterResponse || []),
84 // User hooks go BEFORE
85 gotAfterResponseHook(opt),
86 ],
87 },
88 })
89}
90
91/**
92 * Without this hook (default behaviour):
93 *
94 * HTTPError: Response code 422 (Unprocessable Entity)
95 * at EventEmitter.<anonymous> (.../node_modules/got/dist/source/as-promise.js:118:31)
96 * at processTicksAndRejections (internal/process/task_queues.js:97:5) {
97 * name: 'HTTPError'
98 *
99 *
100 * With this hook:
101 *
102 * HTTPError 422 GET http://a.com/err?q=1 in 8 ms
103 * {
104 * message: 'Reference already exists',
105 * documentation_url: 'https://developer.github.com/v3/git/refs/#create-a-reference'
106 * }
107 *
108 * Features:
109 * 1. Includes original method and URL (including e.g searchParams) in the error message.
110 * 2. Includes response.body in the error message (limited length).
111 * 3. Auto-detects and parses JSON response body (limited length).
112 * 4. Includes time spent (gotBeforeRequestHook must also be enabled).
113 * UPD: excluded now to allow automatic Sentry error grouping
114 */
115function gotErrorHook(opt: GetGotOptions = {}): BeforeErrorHook {
116 const { maxResponseLength = 10_000 } = opt
117
118 return err => {
119 const statusCode = err.response?.statusCode || 0
120 const { method, url, prefixUrl } = err.options
121 const shortUrl = getShortUrl(opt, url, prefixUrl)
122 const { started, retryCount } = (err.request?.options.context || {}) as GotRequestContext
123
124 const body = err.response?.body
125 ? inspectAny(err.response.body, {
126 maxLen: maxResponseLength,
127 colors: false,
128 })
129 : err.message
130
131 // We don't include Response/Body/Message in the log, because it's included in the Error thrown from here
132 opt.logger!.log(
133 [
134 ' <<',
135 statusCode,
136 method,
137 shortUrl,
138 retryCount && `(retry ${retryCount})`,
139 'error',
140 started && 'in ' + _since(started),
141 ]
142 .filter(Boolean)
143 .join(' '),
144 )
145
146 // timings are not part of err.message to allow automatic error grouping in Sentry
147 // Colors are not used, because there's high chance that this Error will be propagated all the way to the Frontend
148 err.message = [[statusCode, method, shortUrl].filter(Boolean).join(' '), body]
149 .filter(Boolean)
150 .join('\n')
151
152 const stack = (err.options.context as GotRequestContext)?.err?.stack
153 if (stack) {
154 const originalStack = err.stack.split('\n')
155 let originalStackIndex = originalStack.findIndex(line => line.includes(' at '))
156 if (originalStackIndex === -1) originalStackIndex = originalStack.length - 1
157
158 // Skipping first line as it has RequestError: ...
159 // Skipping second line as it's known to be from e.g at got_1.default.extend.handlers
160 const syntheticStack = stack.split('\n').slice(2)
161 let firstNonNodeModulesIndex = syntheticStack.findIndex(
162 line => !line.includes('node_modules'),
163 )
164 if (firstNonNodeModulesIndex === -1) firstNonNodeModulesIndex = 0
165
166 err.stack = [
167 // First lines of original error
168 ...originalStack.slice(0, originalStackIndex),
169 // Other lines from "Synthetic error"
170 ...syntheticStack.slice(firstNonNodeModulesIndex),
171 ].join('\n')
172 // err.stack += '\n --' + stack.replace('Error: RequestError', '')
173 }
174
175 return err
176 }
177}
178
179function gotBeforeRequestHook(opt: GetGotOptions): BeforeRequestHook {
180 return options => {
181 if (opt.logStart) {
182 const { retryCount } = options.context as GotRequestContext
183 const shortUrl = getShortUrl(opt, options.url, options.prefixUrl)
184 opt.logger!.log(
185 [' >>', options.method, shortUrl, retryCount && `(retry ${retryCount})`].join(' '),
186 )
187 }
188
189 if (opt.logRequest) {
190 const body = options.json || options.body
191
192 if (body) {
193 opt.logger!.log(body)
194 }
195 }
196 }
197}
198
199// Here we log always, because it's similar to ErrorHook - we always log errors
200// Because Retries are always result of some Error
201function gotBeforeRetryHook(opt: GetGotOptions): BeforeRetryHook {
202 const { maxResponseLength = 10_000 } = opt
203
204 return (options, err, retryCount) => {
205 // opt.logger!.log('beforeRetry', retryCount)
206 const statusCode = err?.response?.statusCode || 0
207
208 if (statusCode && statusCode < 300) {
209 // todo: possibly remove the log message completely in the future
210 // opt.logger!.log(
211 // `skipping got.beforeRetry hook as statusCode is ${statusCode}, err.msg is ${err?.message}`,
212 // )
213 return
214 }
215
216 const { method, url, prefixUrl } = options
217 const shortUrl = getShortUrl(opt, url, prefixUrl)
218 const { started } = options.context as GotRequestContext
219 Object.assign(options.context, { retryCount })
220
221 const body = err?.response?.body
222 ? inspectAny(err.response.body, {
223 maxLen: maxResponseLength,
224 colors: false,
225 })
226 : err?.message
227
228 // We don't include Response/Body/Message in the log, because it's included in the Error thrown from here
229 opt.logger!.warn(
230 [
231 [
232 ' <<',
233 statusCode,
234 method,
235 shortUrl,
236 retryCount && retryCount > 1 ? `(retry ${retryCount - 1})` : '(first try)',
237 'error',
238 started && 'in ' + _since(started),
239 ]
240 .filter(Boolean)
241 .join(' '),
242 body,
243 ]
244 .filter(Boolean)
245 .join('\n'),
246 )
247 }
248}
249
250// AfterResponseHook is never called on Error
251// So, coloredHttpCode(resp.statusCode) is probably useless
252function gotAfterResponseHook(opt: GetGotOptions = {}): AfterResponseHook {
253 return resp => {
254 const success = resp.statusCode >= 200 && resp.statusCode < 400
255
256 // Errors are not logged here, as they're logged by gotErrorHook
257 if (opt.logFinished && success) {
258 const { started, retryCount } = resp.request.options.context as GotRequestContext
259 const { url, prefixUrl, method } = resp.request.options
260 const shortUrl = getShortUrl(opt, url, prefixUrl)
261
262 opt.logger!.log(
263 [
264 ' <<',
265 resp.statusCode,
266 method,
267 shortUrl,
268 retryCount && `(retry ${retryCount - 1})`,
269 started && 'in ' + _since(started),
270 ]
271 .filter(Boolean)
272 .join(' '),
273 )
274 // console.log(`afterResp! ${resp.request.options.method} ${resp.url}`, { context: resp.request.options.context })
275 }
276
277 // Error responses are not logged, cause they're included in Error message already
278 if (opt.logResponse && success) {
279 opt.logger!.log(inspectAny(resp.body, { maxLen: opt.maxResponseLength }))
280 }
281
282 return resp
283 }
284}
285
286function getShortUrl(opt: GetGotOptions, url: URL, prefixUrl?: string): string {
287 if (url.password) {
288 url = new URL(url.toString()) // prevent original url mutation
289 url.password = '[redacted]'
290 }
291
292 let shortUrl = url.toString()
293
294 if (opt.logWithSearchParams === false) {
295 shortUrl = shortUrl.split('?')[0]!
296 }
297
298 if (opt.logWithPrefixUrl === false && prefixUrl && shortUrl.startsWith(prefixUrl)) {
299 shortUrl = shortUrl.slice(prefixUrl.length)
300 }
301
302 return shortUrl
303}