UNPKG

5.12 kBPlain TextView Raw
1import { _jsonParseIfPossible, _since } from '@naturalcycles/js-lib'
2import got, { AfterResponseHook, BeforeErrorHook, BeforeRequestHook, Got, HTTPError } from 'got'
3import { URL } from 'url'
4import { inspectAny } from '..'
5import { dimGrey, grey, red, yellow } from '../colors'
6import { GetGotOptions, GotRequestContext } from './got.model'
7
8/**
9 * Returns instance of Got with "reasonable defaults":
10 *
11 * 1. Error handler hook that prints helpful errors.
12 * 2. Hooks that log start/end of request (optional, false by default).
13 * 3. Reasonable defaults(tm), e.g non-infinite Timeout
14 */
15export function getGot(opt: GetGotOptions = {}): Got {
16 return got.extend({
17 // Most-important is to set to anything non-empty (so, requests don't "hang" by default).
18 // Should be long enough to handle for slow responses from scaled cloud APIs in times of spikes
19 // Ideally should be LESS than default Request timeout in backend-lib (so, it has a chance to error
20 // before server times out with 503).
21 timeout: 90_000,
22 ...opt,
23 hooks: {
24 ...opt.hooks,
25 beforeError: [
26 ...(opt.hooks?.beforeError || []),
27 // User hooks go BEFORE
28 gotErrorHook(opt),
29 ],
30 beforeRequest: [
31 gotBeforeRequestHook(opt),
32 // User hooks go AFTER
33 ...(opt.hooks?.beforeRequest || []),
34 ],
35 afterResponse: [
36 ...(opt.hooks?.afterResponse || []),
37 // User hooks go BEFORE
38 gotAfterResponseHook(opt),
39 ],
40 },
41 })
42}
43
44/**
45 * Without this hook (default behaviour):
46 *
47 * HTTPError: Response code 422 (Unprocessable Entity)
48 * at EventEmitter.<anonymous> (.../node_modules/got/dist/source/as-promise.js:118:31)
49 * at processTicksAndRejections (internal/process/task_queues.js:97:5) {
50 * name: 'HTTPError'
51 *
52 *
53 * With this hook:
54 *
55 * HTTPError 422 GET http://a.com/err?q=1 in 8 ms
56 * {
57 * message: 'Reference already exists',
58 * documentation_url: 'https://developer.github.com/v3/git/refs/#create-a-reference'
59 * }
60 *
61 * Features:
62 * 1. Includes original method and URL (including e.g searchParams) in the error message.
63 * 2. Includes response.body in the error message (limited length).
64 * 3. Auto-detects and parses JSON response body (limited length).
65 * 4. Includes time spent (gotBeforeRequestHook must also be enabled).
66 * UPD: excluded now to allow automatic Sentry error grouping
67 */
68function gotErrorHook(opt: GetGotOptions = {}): BeforeErrorHook {
69 const { maxResponseLength = 10_000 } = opt
70
71 return err => {
72 if (err instanceof HTTPError) {
73 const { statusCode } = err.response
74 const { method, url, prefixUrl } = err.options
75 const shortUrl = getShortUrl(opt, url, prefixUrl)
76 // const { started } = context as GotRequestContext
77
78 const body = inspectAny(err.response.body, {
79 maxLen: maxResponseLength,
80 colors: false,
81 })
82
83 // timings are not part of err.message to allow automatic error grouping in Sentry
84 err.message = [[statusCode, method, shortUrl].filter(Boolean).join(' '), body]
85 .filter(Boolean)
86 .join('\n')
87 }
88
89 return err
90 }
91}
92
93function gotBeforeRequestHook(opt: GetGotOptions): BeforeRequestHook {
94 return options => {
95 options.context = {
96 ...options.context,
97 started: Date.now(),
98 } as GotRequestContext
99
100 if (opt.logStart) {
101 const shortUrl = getShortUrl(opt, options.url, options.prefixUrl)
102 console.log([dimGrey(' >>'), dimGrey(options.method), grey(shortUrl)].join(' '))
103 }
104 }
105}
106
107function gotAfterResponseHook(opt: GetGotOptions = {}): AfterResponseHook {
108 return resp => {
109 const success = resp.statusCode >= 200 && resp.statusCode < 400
110
111 if (opt.logFinished) {
112 const { started } = resp.request.options.context as GotRequestContext
113 const { url, prefixUrl, method } = resp.request.options
114 const shortUrl = getShortUrl(opt, url, prefixUrl)
115
116 console.log(
117 [
118 dimGrey(' <<'),
119 coloredHttpCode(resp.statusCode),
120 dimGrey(method),
121 grey(shortUrl),
122 started && dimGrey('in ' + _since(started)),
123 ]
124 .filter(Boolean)
125 .join(' '),
126 )
127 // console.log(`afterResp! ${resp.request.options.method} ${resp.url}`, { context: resp.request.options.context })
128 }
129
130 // Error responses are not logged, cause they're included in Error message already
131 if (opt.logResponse && success) {
132 console.log(inspectAny(_jsonParseIfPossible(resp.body), { maxLen: opt.maxResponseLength }))
133 }
134
135 return resp
136 }
137}
138
139function coloredHttpCode(statusCode: number): string {
140 if (statusCode < 400) return dimGrey(statusCode) // default
141 if (statusCode < 500) return yellow(statusCode)
142 return red(statusCode)
143}
144
145function getShortUrl(opt: GetGotOptions, url: URL, prefixUrl?: string): string {
146 let shortUrl = url.toString()
147
148 if (opt.logWithSearchParams === false) {
149 shortUrl = shortUrl.split('?')[0]!
150 }
151
152 if (opt.logWithPrefixUrl === false && prefixUrl && shortUrl.startsWith(prefixUrl)) {
153 shortUrl = shortUrl.slice(prefixUrl.length)
154 }
155
156 return shortUrl
157}