Skip to content

Commit 13a976e

Browse files
committed
feat(http-request): add hooks, maxResponseSize, rawResponse, enriched errors
- onRequest/onResponse hooks fire per-attempt (retries/redirects each trigger) - maxResponseSize rejects responses exceeding byte limit - rawResponse exposes IncomingMessage on HttpResponse for advanced use - Enriched error messages for ECONNREFUSED, ENOTFOUND, ETIMEDOUT, ECONNRESET, EPIPE, and SSL/TLS cert errors - New exported types: HttpHooks, HttpHookRequestInfo, HttpHookResponseInfo
1 parent 3f638c9 commit 13a976e

2 files changed

Lines changed: 167 additions & 35 deletions

File tree

src/http-request.ts

Lines changed: 164 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ function getHttps() {
7474
return _https as typeof import('node:https')
7575
}
7676

77+
/**
78+
* Information passed to the onRequest hook before each request attempt.
79+
*/
80+
export interface HttpHookRequestInfo {
81+
headers: Record<string, string>
82+
method: string
83+
timeout: number
84+
url: string
85+
}
86+
87+
/**
88+
* Information passed to the onResponse hook after each request attempt.
89+
*/
90+
export interface HttpHookResponseInfo {
91+
duration: number
92+
error?: Error | undefined
93+
headers?: Record<string, string | string[] | undefined> | undefined
94+
method: string
95+
status?: number | undefined
96+
statusText?: string | undefined
97+
url: string
98+
}
99+
100+
/**
101+
* Lifecycle hooks for observing HTTP request/response events.
102+
* Hooks fire per-attempt (retries produce multiple hook calls).
103+
*/
104+
export interface HttpHooks {
105+
onRequest?: ((info: HttpHookRequestInfo) => void) | undefined
106+
onResponse?: ((info: HttpHookResponseInfo) => void) | undefined
107+
}
108+
77109
/**
78110
* Configuration options for HTTP/HTTPS requests.
79111
*/
@@ -136,6 +168,11 @@ export interface HttpRequestOptions {
136168
* ```
137169
*/
138170
followRedirects?: boolean | undefined
171+
/**
172+
* Lifecycle hooks for observing request/response events.
173+
* Hooks fire per-attempt — retries and redirects each trigger separate hook calls.
174+
*/
175+
hooks?: HttpHooks | undefined
139176
/**
140177
* HTTP headers to send with the request.
141178
* A `User-Agent` header is automatically added if not provided.
@@ -167,6 +204,14 @@ export interface HttpRequestOptions {
167204
* ```
168205
*/
169206
maxRedirects?: number | undefined
207+
/**
208+
* Maximum response body size in bytes. Responses exceeding this limit
209+
* will be rejected with an error. Prevents memory exhaustion from
210+
* unexpectedly large responses.
211+
*
212+
* @default undefined (no limit)
213+
*/
214+
maxResponseSize?: number | undefined
170215
/**
171216
* HTTP method to use for the request.
172217
*
@@ -346,6 +391,12 @@ export interface HttpResponse {
346391
* ```
347392
*/
348393
text(): string
394+
/**
395+
* The underlying Node.js IncomingMessage for advanced use cases
396+
* (e.g., streaming, custom header inspection). Only available when
397+
* the response was not consumed by the convenience methods.
398+
*/
399+
rawResponse?: IncomingMessage | undefined
349400
}
350401

351402
/**
@@ -891,8 +942,48 @@ async function httpDownloadAttempt(
891942
})
892943
}
893944

945+
/**
946+
* Build an enriched error message based on the error code.
947+
* Generic guidance (no product-specific branding).
948+
* @private
949+
*/
950+
function enrichErrorMessage(
951+
url: string,
952+
method: string,
953+
error: NodeJS.ErrnoException,
954+
): string {
955+
const code = error.code
956+
let message = `${method} request failed: ${url}`
957+
if (code === 'ECONNREFUSED') {
958+
message +=
959+
'\n→ Connection refused. Server is unreachable.\n→ Check: Network connectivity and firewall settings.'
960+
} else if (code === 'ENOTFOUND') {
961+
message +=
962+
'\n→ DNS lookup failed. Cannot resolve hostname.\n→ Check: Internet connection and DNS settings.'
963+
} else if (code === 'ETIMEDOUT') {
964+
message +=
965+
'\n→ Connection timed out. Network or server issue.\n→ Try: Check network connectivity and retry.'
966+
} else if (code === 'ECONNRESET') {
967+
message +=
968+
'\n→ Connection reset by server. Possible network interruption.\n→ Try: Retry the request.'
969+
} else if (code === 'EPIPE') {
970+
message +=
971+
'\n→ Broken pipe. Server closed connection unexpectedly.\n→ Check: Authentication credentials and permissions.'
972+
} else if (
973+
code === 'CERT_HAS_EXPIRED' ||
974+
code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
975+
) {
976+
message +=
977+
'\n→ SSL/TLS certificate error.\n→ Check: System time and date are correct.\n→ Try: Update CA certificates on your system.'
978+
} else if (code) {
979+
message += `\n→ Error code: ${code}`
980+
}
981+
return message
982+
}
983+
894984
/**
895985
* Single HTTP request attempt (used internally by httpRequest with retry logic).
986+
* Supports hooks (fire per-attempt), maxResponseSize, and rawResponse.
896987
* @private
897988
*/
898989
async function httpRequestAttempt(
@@ -904,45 +995,68 @@ async function httpRequestAttempt(
904995
ca,
905996
followRedirects = true,
906997
headers = {},
998+
hooks,
907999
maxRedirects = 5,
1000+
maxResponseSize,
9081001
method = 'GET',
9091002
timeout = 30_000,
9101003
} = { __proto__: null, ...options } as HttpRequestOptions
9111004

1005+
const startTime = Date.now()
1006+
const mergedHeaders = {
1007+
'User-Agent': 'socket-registry/1.0',
1008+
...headers,
1009+
}
1010+
1011+
hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout })
1012+
9121013
return await new Promise((resolve, reject) => {
9131014
const parsedUrl = new URL(url)
9141015
const isHttps = parsedUrl.protocol === 'https:'
9151016
const httpModule = isHttps ? getHttps() : getHttp()
9161017

9171018
const requestOptions: Record<string, unknown> = {
918-
headers: {
919-
'User-Agent': 'socket-registry/1.0',
920-
...headers,
921-
},
1019+
headers: mergedHeaders,
9221020
hostname: parsedUrl.hostname,
9231021
method,
9241022
path: parsedUrl.pathname + parsedUrl.search,
9251023
port: parsedUrl.port,
9261024
timeout,
9271025
}
9281026

929-
// Pass custom CA certificates for TLS connections.
9301027
if (ca && isHttps) {
9311028
requestOptions['ca'] = ca
9321029
}
9331030

1031+
const emitResponse = (info: Partial<HttpHookResponseInfo>) => {
1032+
hooks?.onResponse?.({
1033+
duration: Date.now() - startTime,
1034+
method,
1035+
url,
1036+
...info,
1037+
})
1038+
}
1039+
9341040
/* c8 ignore start - External HTTP/HTTPS request */
9351041
const request = httpModule.request(
9361042
requestOptions,
9371043
(res: IncomingMessage) => {
938-
// Handle redirects
9391044
if (
9401045
followRedirects &&
9411046
res.statusCode &&
9421047
res.statusCode >= 300 &&
9431048
res.statusCode < 400 &&
9441049
res.headers.location
9451050
) {
1051+
emitResponse({
1052+
headers: res.headers as Record<
1053+
string,
1054+
string | string[] | undefined
1055+
>,
1056+
status: res.statusCode,
1057+
statusText: res.statusMessage,
1058+
})
1059+
9461060
if (maxRedirects <= 0) {
9471061
reject(
9481062
new Error(
@@ -952,12 +1066,10 @@ async function httpRequestAttempt(
9521066
return
9531067
}
9541068

955-
// Follow redirect
9561069
const redirectUrl = res.headers.location.startsWith('http')
9571070
? res.headers.location
9581071
: new URL(res.headers.location, url).toString()
9591072

960-
// Reject HTTPS-to-HTTP downgrade redirects.
9611073
const redirectParsed = new URL(redirectUrl)
9621074
if (isHttps && redirectParsed.protocol !== 'https:') {
9631075
reject(
@@ -974,17 +1086,32 @@ async function httpRequestAttempt(
9741086
ca,
9751087
followRedirects,
9761088
headers,
1089+
hooks,
9771090
maxRedirects: maxRedirects - 1,
1091+
maxResponseSize,
9781092
method,
9791093
timeout,
9801094
}),
9811095
)
9821096
return
9831097
}
9841098

985-
// Collect response data
9861099
const chunks: Buffer[] = []
1100+
let totalBytes = 0
1101+
9871102
res.on('data', (chunk: Buffer) => {
1103+
totalBytes += chunk.length
1104+
if (maxResponseSize && totalBytes > maxResponseSize) {
1105+
res.destroy()
1106+
const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2)
1107+
const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2)
1108+
const err = new Error(
1109+
`Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`,
1110+
)
1111+
emitResponse({ error: err })
1112+
reject(err)
1113+
return
1114+
}
9881115
chunks.push(chunk)
9891116
})
9901117

@@ -1011,52 +1138,53 @@ async function httpRequestAttempt(
10111138
return JSON.parse(responseBody.toString('utf8')) as T
10121139
},
10131140
ok,
1141+
rawResponse: res,
10141142
status: res.statusCode || 0,
10151143
statusText: res.statusMessage || '',
10161144
text(): string {
10171145
return responseBody.toString('utf8')
10181146
},
10191147
}
10201148

1149+
emitResponse({
1150+
headers: res.headers as Record<
1151+
string,
1152+
string | string[] | undefined
1153+
>,
1154+
status: res.statusCode,
1155+
statusText: res.statusMessage,
1156+
})
1157+
10211158
resolve(response)
10221159
})
10231160

10241161
res.on('error', (error: Error) => {
1162+
emitResponse({ error })
10251163
reject(error)
10261164
})
10271165
},
10281166
)
10291167

10301168
request.on('error', (error: Error) => {
1031-
const code = (error as NodeJS.ErrnoException).code
1032-
let message = `HTTP request failed for ${url}: ${error.message}\n`
1033-
1034-
if (code === 'ENOTFOUND') {
1035-
message +=
1036-
'DNS lookup failed. Check the hostname and your network connection.'
1037-
} else if (code === 'ECONNREFUSED') {
1038-
message +=
1039-
'Connection refused. Verify the server is running and accessible.'
1040-
} else if (code === 'ETIMEDOUT') {
1041-
message +=
1042-
'Request timed out. Check your network or increase the timeout value.'
1043-
} else if (code === 'ECONNRESET') {
1044-
message +=
1045-
'Connection reset. The server may have closed the connection unexpectedly.'
1046-
} else {
1047-
message +=
1048-
'Check your network connection and verify the URL is correct.'
1049-
}
1050-
1051-
reject(new Error(message, { cause: error }))
1169+
const message = enrichErrorMessage(
1170+
url,
1171+
method,
1172+
error as NodeJS.ErrnoException,
1173+
)
1174+
const enhanced = new Error(message, { cause: error })
1175+
emitResponse({ error: enhanced })
1176+
reject(enhanced)
10521177
})
10531178

10541179
request.on('timeout', () => {
10551180
request.destroy()
1056-
reject(new Error(`Request timed out after ${timeout}ms`))
1181+
const err = new Error(
1182+
`${method} request timed out after ${timeout}ms: ${url}\n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.`,
1183+
)
1184+
emitResponse({ error: err })
1185+
reject(err)
10571186
})
10581187

1059-
// Send body if present
10601188
if (body) {
10611189
request.write(body)
10621190
}
@@ -1384,7 +1512,9 @@ export async function httpRequest(
13841512
ca,
13851513
followRedirects = true,
13861514
headers = {},
1515+
hooks,
13871516
maxRedirects = 5,
1517+
maxResponseSize,
13881518
method = 'GET',
13891519
retries = 0,
13901520
retryDelay = 1000,
@@ -1401,7 +1531,9 @@ export async function httpRequest(
14011531
ca,
14021532
followRedirects,
14031533
headers,
1534+
hooks,
14041535
maxRedirects,
1536+
maxResponseSize,
14051537
method,
14061538
timeout,
14071539
})

test/unit/http-request.test.mts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ describe('http-request', () => {
428428
retries: 2,
429429
retryDelay: 10,
430430
}),
431-
).rejects.toThrow(/HTTP request failed/)
431+
).rejects.toThrow(/request failed/)
432432
expect(attemptCount).toBe(3) // Initial attempt + 2 retries
433433
} finally {
434434
await new Promise<void>(resolve => {
@@ -440,7 +440,7 @@ describe('http-request', () => {
440440
it('should handle network errors', async () => {
441441
await expect(
442442
httpRequest('http://localhost:1/nonexistent', { timeout: 100 }),
443-
).rejects.toThrow(/HTTP request failed/)
443+
).rejects.toThrow(/request failed/)
444444
})
445445

446446
it('should handle invalid URLs gracefully', async () => {
@@ -498,7 +498,7 @@ describe('http-request', () => {
498498
try {
499499
await expect(
500500
httpRequest(`http://localhost:${testPort}/`),
501-
).rejects.toThrow(/HTTP request failed/)
501+
).rejects.toThrow(/request failed/)
502502
} finally {
503503
await new Promise<void>(resolve => {
504504
testServer.close(() => resolve())

0 commit comments

Comments
 (0)