Skip to content

Commit 8d771cb

Browse files
authored
feat(http-request): add hooks, maxResponseSize, rawResponse, enriched errors (#133)
* 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 * test(http-request): add 25 tests for hooks, maxResponseSize, rawResponse, enrichErrorMessage - hooks: onRequest/onResponse fire per-attempt, per-redirect, with POST method, custom headers - maxResponseSize: rejects oversized responses, works with httpJson/httpText, fires error hooks - rawResponse: exposes IncomingMessage on success and non-2xx responses - enrichErrorMessage: tests all 7 error codes + unknown + no-code cases - Export enrichErrorMessage for testability * test(http-request): add 16 more tests for complete coverage of new features - hooks edge cases: onRequest-only, onResponse-only, empty hooks, httpJson/httpText passthrough, response headers in hook, duration - maxResponseSize edge cases: exact size match, zero (no limit), enforcement after redirect - rawResponse edge cases: after redirect (final response), on server error - enriched errors integration: method+url in timeout, method+url in connection error, cause chain preserved, url in enrichErrorMessage * refactor(test): dedup http-request tests while preserving coverage (141 → 123) Merge duplicate test sections into single describe blocks. Combine tests that hit the same endpoint with identical setup. Use parameterized test for enrichErrorMessage error codes. All coverage preserved. * style: format http-request tests with oxfmt * fix: use Array<string> instead of string[] in union types for oxlint array-simple rule * fix: use IncomingHttpHeaders to avoid oxfmt/oxlint array-type conflict * fix: use Array<T> for non-simple types in tests (oxlint array-simple) * fix: revert Array<T> back to T[] — oxlint considers interfaces as simple types
1 parent 3f638c9 commit 8d771cb

2 files changed

Lines changed: 476 additions & 41 deletions

File tree

src/http-request.ts

Lines changed: 160 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function getFs() {
3030
return _fs as typeof import('node:fs')
3131
}
3232

33-
import type { IncomingMessage } from 'http'
33+
import type { IncomingHttpHeaders, IncomingMessage } from 'http'
3434

3535
import type { Logger } from './logger.js'
3636

@@ -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?: IncomingHttpHeaders | 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
*
@@ -281,7 +326,7 @@ export interface HttpResponse {
281326
* console.log(response.headers['set-cookie']) // May be string[]
282327
* ```
283328
*/
284-
headers: Record<string, string | string[] | undefined>
329+
headers: IncomingHttpHeaders
285330
/**
286331
* Parse response body as JSON.
287332
* Type parameter `T` allows specifying the expected JSON structure.
@@ -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,47 @@ 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+
*/
949+
export function enrichErrorMessage(
950+
url: string,
951+
method: string,
952+
error: NodeJS.ErrnoException,
953+
): string {
954+
const code = error.code
955+
let message = `${method} request failed: ${url}`
956+
if (code === 'ECONNREFUSED') {
957+
message +=
958+
'\n→ Connection refused. Server is unreachable.\n→ Check: Network connectivity and firewall settings.'
959+
} else if (code === 'ENOTFOUND') {
960+
message +=
961+
'\n→ DNS lookup failed. Cannot resolve hostname.\n→ Check: Internet connection and DNS settings.'
962+
} else if (code === 'ETIMEDOUT') {
963+
message +=
964+
'\n→ Connection timed out. Network or server issue.\n→ Try: Check network connectivity and retry.'
965+
} else if (code === 'ECONNRESET') {
966+
message +=
967+
'\n→ Connection reset by server. Possible network interruption.\n→ Try: Retry the request.'
968+
} else if (code === 'EPIPE') {
969+
message +=
970+
'\n→ Broken pipe. Server closed connection unexpectedly.\n→ Check: Authentication credentials and permissions.'
971+
} else if (
972+
code === 'CERT_HAS_EXPIRED' ||
973+
code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
974+
) {
975+
message +=
976+
'\n→ SSL/TLS certificate error.\n→ Check: System time and date are correct.\n→ Try: Update CA certificates on your system.'
977+
} else if (code) {
978+
message += `\n→ Error code: ${code}`
979+
}
980+
return message
981+
}
982+
894983
/**
895984
* Single HTTP request attempt (used internally by httpRequest with retry logic).
985+
* Supports hooks (fire per-attempt), maxResponseSize, and rawResponse.
896986
* @private
897987
*/
898988
async function httpRequestAttempt(
@@ -904,45 +994,65 @@ async function httpRequestAttempt(
904994
ca,
905995
followRedirects = true,
906996
headers = {},
997+
hooks,
907998
maxRedirects = 5,
999+
maxResponseSize,
9081000
method = 'GET',
9091001
timeout = 30_000,
9101002
} = { __proto__: null, ...options } as HttpRequestOptions
9111003

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

9171017
const requestOptions: Record<string, unknown> = {
918-
headers: {
919-
'User-Agent': 'socket-registry/1.0',
920-
...headers,
921-
},
1018+
headers: mergedHeaders,
9221019
hostname: parsedUrl.hostname,
9231020
method,
9241021
path: parsedUrl.pathname + parsedUrl.search,
9251022
port: parsedUrl.port,
9261023
timeout,
9271024
}
9281025

929-
// Pass custom CA certificates for TLS connections.
9301026
if (ca && isHttps) {
9311027
requestOptions['ca'] = ca
9321028
}
9331029

1030+
const emitResponse = (info: Partial<HttpHookResponseInfo>) => {
1031+
hooks?.onResponse?.({
1032+
duration: Date.now() - startTime,
1033+
method,
1034+
url,
1035+
...info,
1036+
})
1037+
}
1038+
9341039
/* c8 ignore start - External HTTP/HTTPS request */
9351040
const request = httpModule.request(
9361041
requestOptions,
9371042
(res: IncomingMessage) => {
938-
// Handle redirects
9391043
if (
9401044
followRedirects &&
9411045
res.statusCode &&
9421046
res.statusCode >= 300 &&
9431047
res.statusCode < 400 &&
9441048
res.headers.location
9451049
) {
1050+
emitResponse({
1051+
headers: res.headers,
1052+
status: res.statusCode,
1053+
statusText: res.statusMessage,
1054+
})
1055+
9461056
if (maxRedirects <= 0) {
9471057
reject(
9481058
new Error(
@@ -952,12 +1062,10 @@ async function httpRequestAttempt(
9521062
return
9531063
}
9541064

955-
// Follow redirect
9561065
const redirectUrl = res.headers.location.startsWith('http')
9571066
? res.headers.location
9581067
: new URL(res.headers.location, url).toString()
9591068

960-
// Reject HTTPS-to-HTTP downgrade redirects.
9611069
const redirectParsed = new URL(redirectUrl)
9621070
if (isHttps && redirectParsed.protocol !== 'https:') {
9631071
reject(
@@ -974,17 +1082,32 @@ async function httpRequestAttempt(
9741082
ca,
9751083
followRedirects,
9761084
headers,
1085+
hooks,
9771086
maxRedirects: maxRedirects - 1,
1087+
maxResponseSize,
9781088
method,
9791089
timeout,
9801090
}),
9811091
)
9821092
return
9831093
}
9841094

985-
// Collect response data
9861095
const chunks: Buffer[] = []
1096+
let totalBytes = 0
1097+
9871098
res.on('data', (chunk: Buffer) => {
1099+
totalBytes += chunk.length
1100+
if (maxResponseSize && totalBytes > maxResponseSize) {
1101+
res.destroy()
1102+
const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2)
1103+
const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2)
1104+
const err = new Error(
1105+
`Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`,
1106+
)
1107+
emitResponse({ error: err })
1108+
reject(err)
1109+
return
1110+
}
9881111
chunks.push(chunk)
9891112
})
9901113

@@ -1003,60 +1126,55 @@ async function httpRequestAttempt(
10031126
)
10041127
},
10051128
body: responseBody,
1006-
headers: res.headers as Record<
1007-
string,
1008-
string | string[] | undefined
1009-
>,
1129+
headers: res.headers,
10101130
json<T = unknown>(): T {
10111131
return JSON.parse(responseBody.toString('utf8')) as T
10121132
},
10131133
ok,
1134+
rawResponse: res,
10141135
status: res.statusCode || 0,
10151136
statusText: res.statusMessage || '',
10161137
text(): string {
10171138
return responseBody.toString('utf8')
10181139
},
10191140
}
10201141

1142+
emitResponse({
1143+
headers: res.headers,
1144+
status: res.statusCode,
1145+
statusText: res.statusMessage,
1146+
})
1147+
10211148
resolve(response)
10221149
})
10231150

10241151
res.on('error', (error: Error) => {
1152+
emitResponse({ error })
10251153
reject(error)
10261154
})
10271155
},
10281156
)
10291157

10301158
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 }))
1159+
const message = enrichErrorMessage(
1160+
url,
1161+
method,
1162+
error as NodeJS.ErrnoException,
1163+
)
1164+
const enhanced = new Error(message, { cause: error })
1165+
emitResponse({ error: enhanced })
1166+
reject(enhanced)
10521167
})
10531168

10541169
request.on('timeout', () => {
10551170
request.destroy()
1056-
reject(new Error(`Request timed out after ${timeout}ms`))
1171+
const err = new Error(
1172+
`${method} request timed out after ${timeout}ms: ${url}\n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.`,
1173+
)
1174+
emitResponse({ error: err })
1175+
reject(err)
10571176
})
10581177

1059-
// Send body if present
10601178
if (body) {
10611179
request.write(body)
10621180
}
@@ -1384,7 +1502,9 @@ export async function httpRequest(
13841502
ca,
13851503
followRedirects = true,
13861504
headers = {},
1505+
hooks,
13871506
maxRedirects = 5,
1507+
maxResponseSize,
13881508
method = 'GET',
13891509
retries = 0,
13901510
retryDelay = 1000,
@@ -1401,7 +1521,9 @@ export async function httpRequest(
14011521
ca,
14021522
followRedirects,
14031523
headers,
1524+
hooks,
14041525
maxRedirects,
1526+
maxResponseSize,
14051527
method,
14061528
timeout,
14071529
})

0 commit comments

Comments
 (0)