diff --git a/.eslintrc.json b/.eslintrc.json index 7875aa7..c8e391a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -49,5 +49,30 @@ "newlines-between": "always" } ] - } + }, + "overrides": [ + { + "files": ["test/**/*", "*.spec.ts", "*.test.ts"], + "rules": { + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/indent": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/consistent-type-imports": "warn", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ] + } + } + ] } diff --git a/README.md b/README.md index 80a81ad..5c41d16 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ All _send_ methods accept a type parameter and the following arguments: - `throwOnError`;` - `reqContext`; - `safeParseJson`, used when the response content-type is `application/json`. If `true`, the response body will be parsed as JSON and a `ResponseError` will be thrown in case of syntax errors. If `false`, errors are not handled; + - `blobResponseBody`, used when the response body should be returned as Blob; + - `requestLabel`, if set, will be returned together with any thrown or returned Error to provide additional context about what request was being executed when the error has happened; - `disableKeepAlive`;` - `retryConfig`, defined by: - `maxAttempts`, the maximum number of times a request should be retried; diff --git a/package.json b/package.json index 675cae7..a8e7032 100644 --- a/package.json +++ b/package.json @@ -35,21 +35,21 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "pino": "^8.15.6", - "undici": "^5.25.4", - "undici-retry": "^2.1.0" + "pino": "^8.16.1", + "undici": "^5.27.0", + "undici-retry": "^3.1.2" }, "devDependencies": { - "@types/node": "^20.8.2", - "@typescript-eslint/eslint-plugin": "^6.7.4", - "@typescript-eslint/parser": "^6.7.4", + "@types/node": "^20.8.9", + "@typescript-eslint/eslint-plugin": "^6.9.1", + "@typescript-eslint/parser": "^6.9.1", "@vitest/coverage-v8": "^0.34.6", "auto-changelog": "^2.4.0", - "eslint": "^8.50.0", + "eslint": "^8.52.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-prettier": "^5.0.0", - "eslint-plugin-vitest": "^0.3.2", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-prettier": "^5.0.1", + "eslint-plugin-vitest": "^0.3.8", "prettier": "^3.0.3", "typescript": "^5.2.2", "vitest": "^0.34.6", diff --git a/src/errors/ResponseStatusError.ts b/src/errors/ResponseStatusError.ts index 3572d62..2fd4dca 100644 --- a/src/errors/ResponseStatusError.ts +++ b/src/errors/ResponseStatusError.ts @@ -7,10 +7,11 @@ export class ResponseStatusError extends InternalError { public readonly response: RequestResult // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(requestResult: RequestResult) { + constructor(requestResult: RequestResult, requestLabel = 'N/A') { super({ message: `Response status code ${requestResult.statusCode}`, details: { + requestLabel, response: { statusCode: requestResult.statusCode, body: requestResult.body, diff --git a/src/http/httpClient.spec.ts b/src/http/httpClient.spec.ts index 5aedb66..efe1887 100644 --- a/src/http/httpClient.spec.ts +++ b/src/http/httpClient.spec.ts @@ -1,5 +1,6 @@ import type { Interceptable } from 'undici' import { Client, MockAgent, setGlobalDispatcher } from 'undici' +import { isInternalRequestError } from 'undici-retry' import { z } from 'zod' import type { HttpRequestContext } from './httpClient' @@ -142,7 +143,7 @@ describe('httpClient', () => { .reply(200, 'this is not a real json', { headers: JSON_HEADERS }) try { - await sendGet(client, '/products/1', { safeParseJson: true }) + await sendGet(client, '/products/1', { safeParseJson: true, requestLabel: 'label' }) } catch (err) { // This is needed, because built-in error assertions do not assert nested fields // eslint-disable-next-line vitest/no-conditional-expect @@ -151,6 +152,7 @@ describe('httpClient', () => { errorCode: 'INVALID_HTTP_RESPONSE_JSON', details: { rawBody: 'this is not a real json', + requestLabel: 'label', }, }) } @@ -240,6 +242,26 @@ describe('httpClient', () => { }) }) + it('Throws an error with a label on internal error', async () => { + expect.assertions(2) + const query = { + limit: 3, + } + + try { + await sendGet(buildClient('http://127.0.0.1'), '/dummy', { + requestLabel: 'label', + query, + }) + } catch (err) { + if (!isInternalRequestError(err)) { + throw new Error('Invalid error type') + } + expect(err.message).toBe('connect ECONNREFUSED 127.0.0.1:80') + expect(err.details!.requestLabel).toBe('label') + } + }) + it('Returns error response', async () => { expect.assertions(1) const query = { @@ -257,6 +279,7 @@ describe('httpClient', () => { await expect( sendGet(client, '/products', { query, + requestLabel: 'label', }), ).rejects.toMatchObject({ message: 'Response status code 400', @@ -265,6 +288,7 @@ describe('httpClient', () => { statusCode: 400, }, details: { + requestLabel: 'label', response: { body: 'Invalid request', statusCode: 400, diff --git a/src/http/httpClient.ts b/src/http/httpClient.ts index 9b4337a..c1af36f 100644 --- a/src/http/httpClient.ts +++ b/src/http/httpClient.ts @@ -2,7 +2,7 @@ import type { Readable } from 'stream' import { Client } from 'undici' import type { FormData } from 'undici' -import type { RequestResult, RetryConfig } from 'undici-retry' +import type { RequestResult, RequestParams, RetryConfig } from 'undici-retry' import { DEFAULT_RETRY_CONFIG, sendWithRetry } from 'undici-retry' import { ResponseStatusError } from '../errors/ResponseStatusError' @@ -27,9 +27,13 @@ export type RequestOptions = { timeout: number | undefined throwOnError?: boolean reqContext?: HttpRequestContext + safeParseJson?: boolean + blobResponseBody?: boolean + requestLabel?: string + disableKeepAlive?: boolean - retryConfig?: Omit + retryConfig?: RetryConfig clientOptions?: Client.Options responseSchema?: ResponseSchema validateResponse: boolean @@ -73,6 +77,7 @@ export async function sendGet( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -80,6 +85,7 @@ export async function sendGet( options.throwOnError ?? DEFAULT_OPTIONS.throwOnError, options.validateResponse ?? DEFAULT_OPTIONS.validateResponse, options.responseSchema, + options.requestLabel, ) } @@ -104,6 +110,7 @@ export async function sendDelete( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -137,6 +144,7 @@ export async function sendPost( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -170,6 +178,7 @@ export async function sendPostBinary( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -203,6 +212,7 @@ export async function sendPut( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -236,6 +246,7 @@ export async function sendPutBinary( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -269,6 +280,7 @@ export async function sendPatch( throwOnError: false, }, resolveRetryConfig(options), + resolveRequestConfig(options), ) return resolveResult( @@ -279,17 +291,17 @@ export async function sendPatch( ) } +function resolveRequestConfig(options: Partial>): RequestParams { + return { + safeParseJson: options.safeParseJson ?? false, + blobBody: options.blobResponseBody ?? false, + throwOnInternalError: true, + requestLabel: options.requestLabel, + } +} + function resolveRetryConfig(options: Partial>): RetryConfig { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return options.retryConfig - ? { - ...options.retryConfig, - safeParseJson: options.safeParseJson ?? false, - } - : { - ...DEFAULT_RETRY_CONFIG, - safeParseJson: options.safeParseJson ?? false, - } + return options.retryConfig ?? DEFAULT_RETRY_CONFIG } export function buildClient(baseUrl: string, clientOptions?: Client.Options) { @@ -305,9 +317,11 @@ function resolveResult( throwOnError: boolean, validateResponse: boolean, validationSchema?: ResponseSchema, + requestLabel?: string, ): DefiniteEither, RequestResult> { + // Throw response error if (requestResult.error && throwOnError) { - throw new ResponseStatusError(requestResult.error) + throw new ResponseStatusError(requestResult.error, requestLabel) } if (requestResult.result && validateResponse && validationSchema) { requestResult.result.body = validationSchema.parse(requestResult.result.body)