From fd08b4b3e334630ccfd5ea9e250b73e9b9f2b204 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 09:43:00 -0600 Subject: [PATCH 01/16] Replaced axios with native fetch + undici for HTTP transport Removes axios and axios-retry dependencies in response to the axios npm supply chain compromise. HTTP requests now use the native fetch API (Node 18+ / browsers) with undici's ProxyAgent for proxy support. - Rewrote HttpSender to use fetch with AbortSignal.timeout - Added ProxyConfig interface to types.ts (replaces AxiosProxyConfig) - Updated ClientBuilder.withProxy() to build a proxy URL string - Renamed AxiosLikeResponse to HttpResponse in buildSmartyResponse.ts - Added injectable fetchFn parameter for unit test mocking - Added integration tests using a local HTTP server --- package-lock.json | 298 ++------------------------- package.json | 5 +- rollup.config.mjs | 2 +- src/ClientBuilder.ts | 18 +- src/HttpSender.ts | 138 ++++++++----- src/types.ts | 4 + src/util/buildSmartyResponse.ts | 4 +- tests/test_ClientBuilder.ts | 2 +- tests/test_HttpSender.ts | 232 ++++++++++++++++----- tests/test_HttpSender_integration.ts | 182 ++++++++++++++++ 10 files changed, 478 insertions(+), 407 deletions(-) create mode 100644 tests/test_HttpSender_integration.ts diff --git a/package-lock.json b/package-lock.json index fd36778..d9cf363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,8 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "axios": "^1.13.2", - "axios-retry": "^4.5.0", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "undici": "^7.0.0" }, "devDependencies": { "@babel/preset-env": "^7.28.5", @@ -2736,35 +2735,6 @@ "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", - "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", - "license": "Apache-2.0", - "dependencies": { - "is-retry-allowed": "^2.2.0" - }, - "peerDependencies": { - "axios": "0.x || 1.x" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -2901,19 +2871,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3102,18 +3059,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3241,15 +3186,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -3260,20 +3196,6 @@ "node": ">=0.3.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3295,51 +3217,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", @@ -3507,26 +3384,6 @@ "flat": "cli.js" } }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3544,22 +3401,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3579,6 +3420,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3605,43 +3447,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -3710,18 +3515,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3732,37 +3525,11 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3900,18 +3667,6 @@ "@types/estree": "*" } }, - "node_modules/is-retry-allowed": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", - "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4055,15 +3810,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4101,27 +3847,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", @@ -4364,12 +4089,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4915,6 +4634,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 12ee9ea..7856d3b 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,7 @@ "serialize-javascript": "^7.0.3" }, "dependencies": { - "axios": "^1.13.2", - "axios-retry": "^4.5.0", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "undici": "^7.0.0" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index afd4b2d..cac9b8e 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -7,7 +7,7 @@ import typescript from "@rollup/plugin-typescript"; export default { input: "index.ts", - external: ["axios", "axios-retry"], + external: ["undici"], output: [ { dir: "dist", diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index aecfecd..0c30126 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -22,8 +22,7 @@ import UsReverseGeoClient from "./us_reverse_geo/Client.js"; import InternationalAddressAutocompleteClient from "./international_address_autocomplete/Client.js"; import UsEnrichmentClient from "./us_enrichment/Client.js"; import InternationalPostalCodeClient from "./international_postal_code/Client.js"; -import { Sender } from "./types.js"; -import { AxiosProxyConfig } from "axios"; +import { ProxyConfig, Sender } from "./types.js"; const INTERNATIONAL_STREET_API_URI = "https://international-street.api.smarty.com/verify"; const US_AUTOCOMPLETE_PRO_API_URL = "https://us-autocomplete-pro.api.smarty.com/lookup"; @@ -44,7 +43,7 @@ export default class ClientBuilder { private maxRetries: number; private maxTimeout: number; private baseUrl: string | undefined; - private proxy: AxiosProxyConfig | undefined; + private proxy: ProxyConfig | undefined; private customHeaders: Record; private appendHeaders: Record; private debug: boolean | undefined; @@ -101,18 +100,11 @@ export default class ClientBuilder { username?: string, password?: string, ): ClientBuilder { - this.proxy = { - host: host, - port: port, - protocol: protocol, - }; - + let auth = ""; if (username && password) { - this.proxy.auth = { - username: username, - password: password, - }; + auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; } + this.proxy = { url: `${protocol}://${auth}${host}:${port}` }; return this; } diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 901e8dd..101057c 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -1,69 +1,115 @@ -import Axios, { AxiosInstance, AxiosProxyConfig, AxiosRequestConfig } from "axios"; import { buildSmartyResponse } from "./util/buildSmartyResponse.js"; -import { Request as SmartyRequest, Response as SmartyResponse } from "./types.js"; +import { Request as SmartyRequest, Response as SmartyResponse, ProxyConfig } from "./types.js"; + +type FetchFunction = typeof fetch; export default class HttpSender { - private axiosInstance: AxiosInstance; private timeout: number; - private proxyConfig: AxiosProxyConfig | undefined; + private debug: boolean; + private fetchFn: FetchFunction; + private dispatcher: unknown; - constructor(timeout: number = 10000, proxyConfig?: AxiosProxyConfig, debug: boolean = false) { - this.axiosInstance = Axios.create(); + constructor( + timeout: number = 10000, + proxyConfig?: ProxyConfig, + debug: boolean = false, + fetchFn?: FetchFunction, + ) { this.timeout = timeout; - this.proxyConfig = proxyConfig; - if (debug) this.enableDebug(); + this.debug = debug; + this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis); + + if (proxyConfig) { + this.initProxy(proxyConfig); + } + } + + private initProxy(config: ProxyConfig): void { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ProxyAgent } = require("undici"); + this.dispatcher = new ProxyAgent(config.url); + } catch { + throw new Error( + "The 'undici' package is required for proxy support. Install it with: npm install undici", + ); + } } - buildRequestConfig(request: SmartyRequest): AxiosRequestConfig { - const config: AxiosRequestConfig = { - method: "GET", - timeout: this.timeout, - params: request.parameters, + buildFetchArgs(request: SmartyRequest): { url: string; init: RequestInit } { + const url = this.buildUrl(request); + + const init: RequestInit = { + method: request.payload ? "POST" : "GET", headers: request.headers, - baseURL: request.baseUrl, - validateStatus: function (status: number) { - return status < 500; - }, + signal: AbortSignal.timeout(this.timeout), }; if (request.payload) { - config.method = "POST"; - config.data = request.payload; + init.body = + typeof request.payload === "string" + ? request.payload + : JSON.stringify(request.payload); } - if (this.proxyConfig) config.proxy = this.proxyConfig; - return config; + if (this.dispatcher) { + (init as Record)["dispatcher"] = this.dispatcher; + } + + return { url, init }; } - send(request: SmartyRequest): Promise { - return new Promise((resolve, reject) => { - const requestConfig = this.buildRequestConfig(request); + async send(request: SmartyRequest): Promise { + const { url, init } = this.buildFetchArgs(request); + + if (this.debug) { + console.log("Request:\r\n", { url, ...init }); + console.log("\r\n*******************************************\r\n"); + } + + try { + const response = await this.fetchFn(url, init); + const data = await this.parseResponseBody(response); + const headers = Object.fromEntries(response.headers.entries()); + + if (this.debug) { + console.log("Response:\r\n"); + console.log("Status:", response.status, response.statusText); + console.log("Headers:", headers); + console.log("Data:", data); + } + + const smartyResponse = buildSmartyResponse({ status: response.status, data, headers }); - this.axiosInstance(requestConfig) - .then((response) => { - const smartyResponse = buildSmartyResponse(response); + if (smartyResponse.statusCode >= 400) return Promise.reject(smartyResponse); - if (smartyResponse.statusCode >= 400) return reject(smartyResponse); + return smartyResponse; + } catch (error) { + if (error && typeof error === "object" && "statusCode" in error) throw error; + return Promise.reject(buildSmartyResponse(undefined, error as Error)); + } + } - resolve(smartyResponse); - }) - .catch((error) => reject(buildSmartyResponse(undefined, error))); - }); + private buildUrl(request: SmartyRequest): string { + const url = new URL(request.baseUrl); + for (const [key, value] of Object.entries(request.parameters)) { + url.searchParams.append(key, String(value)); + } + return url.toString(); } - private enableDebug(): void { - this.axiosInstance.interceptors.request.use((request) => { - console.log("Request:\r\n", request); - console.log("\r\n*******************************************\r\n"); - return request; - }); - - this.axiosInstance.interceptors.response.use((response) => { - console.log("Response:\r\n"); - console.log("Status:", response.status, response.statusText); - console.log("Headers:", response.headers); - console.log("Data:", response.data); - return response; - }); + private async parseResponseBody( + response: globalThis.Response, + ): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + return null; + } + } + const text = await response.text(); + return text || null; } } diff --git a/src/types.ts b/src/types.ts index a0accf0..b1f8cd7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,10 @@ export interface Signer { sign(request: Request): void; } +export interface ProxyConfig { + url: string; +} + export interface BaseLookup { inputId?: string | number | undefined; customParameters: Record; diff --git a/src/util/buildSmartyResponse.ts b/src/util/buildSmartyResponse.ts index b2f1e12..0a58a52 100644 --- a/src/util/buildSmartyResponse.ts +++ b/src/util/buildSmartyResponse.ts @@ -1,13 +1,13 @@ import Response from "../Response.js"; -interface AxiosLikeResponse { +interface HttpResponse { status: number; data?: object[] | object | string | null | undefined; error?: any; headers?: Record | undefined; } -export function buildSmartyResponse(response?: AxiosLikeResponse, error?: Error): Response { +export function buildSmartyResponse(response?: HttpResponse, error?: Error): Response { if (response) return new Response(response.status, response.data, response.error, response.headers); return new Response(0, null, error); diff --git a/tests/test_ClientBuilder.ts b/tests/test_ClientBuilder.ts index a6b7dbb..5d41263 100644 --- a/tests/test_ClientBuilder.ts +++ b/tests/test_ClientBuilder.ts @@ -32,7 +32,7 @@ describe("ClientBuilder", function () { it("throws when withSender() is combined with withProxy().", function () { expect(() => - new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withProxy({ host: "localhost", port: 8080 }).buildUsStreetApiClient() + new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withProxy("localhost", 8080, "http").buildUsStreetApiClient() ).to.throw("withSender() cannot be combined with: withProxy()"); }); diff --git a/tests/test_HttpSender.ts b/tests/test_HttpSender.ts index e69cf3a..95acfcd 100644 --- a/tests/test_HttpSender.ts +++ b/tests/test_HttpSender.ts @@ -3,87 +3,207 @@ import Request from "../src/Request.js"; import HttpSender from "../src/HttpSender.js"; import { buildSmartyResponse } from "../src/util/buildSmartyResponse.js"; -describe("An Axios implementation of a HTTP sender", function () { - it("adds a data payload to the HTTP request config.", function () { - let expectedPayload = "test payload"; - let request = new Request(expectedPayload); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); - - expect(requestConfig.hasOwnProperty("data")).to.equal(true); - expect(requestConfig.data).to.equal(expectedPayload); +function mockFetchResponse( + status: number, + body: unknown = null, + headers: Record = { "content-type": "application/json" }, +): typeof fetch { + return async () => + new Response(body === null ? null : JSON.stringify(body), { + status, + headers, + }); +} + +function mockFetchError(error: Error): typeof fetch { + return async () => { + throw error; + }; +} + +describe("A fetch-based HTTP sender", function () { + it("builds a GET request when there is no payload.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { url, init } = sender.buildFetchArgs(request); + + expect(init.method).to.equal("GET"); + expect(init.body).to.be.undefined; + expect(url).to.equal("https://example.com/api"); }); - it("adds a POST method to the HTTP request config when appropriate.", function () { - let request = new Request("test payload"); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("builds a POST request when a payload is provided.", function () { + const payload = { key: "value" }; + const request = new Request(payload); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); - expect(requestConfig.hasOwnProperty("method")).to.equal(true); - expect(requestConfig.method).to.equal("POST"); + expect(init.method).to.equal("POST"); + expect(init.body).to.equal(JSON.stringify(payload)); }); - it("adds a GET method to the HTTP request config when appropriate.", function () { - let request = new Request(); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("sends a string payload as-is without JSON.stringify.", function () { + const payload = "raw string body"; + const request = new Request(payload); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); - expect(requestConfig.hasOwnProperty("method")).to.equal(true); - expect(requestConfig.method).to.equal("GET"); + expect(init.body).to.equal("raw string body"); }); - it("add a timeout to the HTTP request config.", function () { - let request = new Request("test payload"); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("appends query parameters to the URL.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + request.parameters["auth-id"] = "123"; + request.parameters["zipcode"] = "20500"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { url } = sender.buildFetchArgs(request); + + const parsed = new URL(url); + expect(parsed.searchParams.get("auth-id")).to.equal("123"); + expect(parsed.searchParams.get("zipcode")).to.equal("20500"); + }); + + it("includes headers in the fetch init.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.headers).to.deep.equal(request.headers); + expect((init.headers as Record)["Content-Type"]).to.equal( + "application/json; charset=utf-8", + ); + }); + + it("includes an abort signal for timeout.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(5000, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.signal).to.be.an.instanceOf(AbortSignal); + }); + + it("uses the default timeout of 10000ms.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender(undefined, undefined, false, mockFetchResponse(200)); + const { init } = sender.buildFetchArgs(request); + + expect(init.signal).to.be.an.instanceOf(AbortSignal); + }); + + it("includes a dispatcher when proxy config is provided.", function () { + const request = new Request(); + request.baseUrl = "https://example.com/api"; + const sender = new HttpSender( + 10000, + { url: "http://proxy:8080" }, + false, + mockFetchResponse(200), + ); + const { init } = sender.buildFetchArgs(request); + + expect((init as Record)["dispatcher"]).to.not.be.undefined; + }); - let customTimeoutSender = new HttpSender(5); - let customTimeoutRequestConfig = customTimeoutSender.buildRequestConfig(request); + it("resolves with a SmartyResponse on a 2xx response.", async function () { + const mockData = [{ street: "123 Main St" }]; + const sender = new HttpSender(10000, undefined, false, mockFetchResponse(200, mockData)); + const request = new Request(); + request.baseUrl = "https://example.com/api"; - expect(requestConfig.hasOwnProperty("timeout")).to.equal(true); - expect(requestConfig.timeout).to.equal(10000); + const response = await sender.send(request); - expect(customTimeoutRequestConfig.timeout).to.equal(5); + expect(response.statusCode).to.equal(200); + expect(response.payload).to.deep.equal(mockData); }); - it("adds parameters to the HTTP request config.", function () { - let request = new Request(""); - let sender = new HttpSender(); - request.parameters["test"] = "1"; - let requestConfig = sender.buildRequestConfig(request); + it("rejects with a SmartyResponse on a 4xx response.", async function () { + const sender = new HttpSender( + 10000, + undefined, + false, + mockFetchResponse(401, { error: "unauthorized" }), + ); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + } + }); - expect(requestConfig.hasOwnProperty("params")).to.equal(true); - expect(requestConfig.params).to.deep.equal(request.parameters); + it("rejects with a SmartyResponse on a network error.", async function () { + const networkError = new Error("fetch failed"); + const sender = new HttpSender(10000, undefined, false, mockFetchError(networkError)); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.equal(networkError); + } }); - it("adds headers to the HTTP request config.", function () { - let request = new Request(""); - let sender = new HttpSender(); - let requestConfig = sender.buildRequestConfig(request); + it("parses text response when content-type is not JSON.", async function () { + const textFetch: typeof fetch = async () => + new Response("plain text body", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + const sender = new HttpSender(10000, undefined, false, textFetch); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + const response = await sender.send(request); - expect(requestConfig.hasOwnProperty("headers")).to.equal(true); - expect(requestConfig.headers!["Content-Type"]).to.equal("application/json; charset=utf-8"); + expect(response.statusCode).to.equal(200); + expect(response.payload).to.equal("plain text body"); }); + it("captures response headers.", async function () { + const headerFetch: typeof fetch = async () => + new Response(JSON.stringify({}), { + status: 200, + headers: { + "content-type": "application/json", + "x-custom": "value", + }, + }); + const sender = new HttpSender(10000, undefined, false, headerFetch); + const request = new Request(); + request.baseUrl = "https://example.com/api"; + + const response = await sender.send(request); + + expect(response.headers["x-custom"]).to.equal("value"); + }); +}); + +describe("buildSmartyResponse", function () { it("has a response with the right status code.", function () { - let mockResponse = { - status: 200, - }; - let smartyResponse = buildSmartyResponse(mockResponse); + const mockResponse = { status: 200 }; + const smartyResponse = buildSmartyResponse(mockResponse); - expect(smartyResponse.hasOwnProperty("statusCode")).to.equal(true); expect(smartyResponse.statusCode).to.equal(200); }); it("has a response with a payload.", function () { - let mockData = [1, 2, 3]; - let mockResponse = { - status: 200, - data: mockData, - }; - let smartyResponse = buildSmartyResponse(mockResponse); - - expect(smartyResponse.hasOwnProperty("payload")).to.equal(true); + const mockData = [1, 2, 3]; + const mockResponse = { status: 200, data: mockData }; + const smartyResponse = buildSmartyResponse(mockResponse); + expect(smartyResponse.payload).to.equal(mockData); }); }); diff --git a/tests/test_HttpSender_integration.ts b/tests/test_HttpSender_integration.ts new file mode 100644 index 0000000..b15cac2 --- /dev/null +++ b/tests/test_HttpSender_integration.ts @@ -0,0 +1,182 @@ +import { expect } from "chai"; +import * as http from "node:http"; +import Request from "../src/Request.js"; +import HttpSender from "../src/HttpSender.js"; + +let server: http.Server; +let baseUrl: string; + +function startServer(handler: http.RequestListener): Promise { + return new Promise((resolve) => { + server = http.createServer(handler); + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as { port: number }; + baseUrl = `http://127.0.0.1:${addr.port}`; + resolve(); + }); + }); +} + +function stopServer(): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe("HttpSender integration (real fetch against local server)", function () { + afterEach(async function () { + if (server) await stopServer(); + }); + + it("sends a GET request and receives a JSON response.", async function () { + await startServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ method: req.method, url: req.url })); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + request.parameters["auth-id"] = "123"; + request.parameters["zipcode"] = "20500"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + const payload = response.payload as { method: string; url: string }; + expect(payload.method).to.equal("GET"); + expect(payload.url).to.include("auth-id=123"); + expect(payload.url).to.include("zipcode=20500"); + }); + + it("sends a POST request with a JSON body.", async function () { + let receivedBody = ""; + let receivedContentType = ""; + + await startServer((req, res) => { + receivedContentType = req.headers["content-type"] ?? ""; + req.on("data", (chunk: Buffer) => (receivedBody += chunk.toString())); + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ method: req.method, body: JSON.parse(receivedBody) })); + }); + }); + + const payload = [{ street: "123 Main St" }, { street: "456 Oak Ave" }]; + const request = new Request(payload); + request.baseUrl = baseUrl + "/street-address"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + expect(receivedContentType).to.include("application/json"); + const result = response.payload as { method: string; body: object[] }; + expect(result.method).to.equal("POST"); + expect(result.body).to.deep.equal(payload); + }); + + it("rejects with the correct status on a 401 response.", async function () { + await startServer((_req, res) => { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(401); + } + }); + + it("rejects with status 0 on a connection error.", async function () { + const request = new Request(); + request.baseUrl = "http://127.0.0.1:1"; // nothing listening + + const sender = new HttpSender(1000); + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.be.an("error"); + } + }); + + it("preserves custom headers through the request.", async function () { + let receivedHeaders: http.IncomingHttpHeaders = {}; + + await startServer((req, res) => { + receivedHeaders = req.headers; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({})); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + request.headers["X-Custom"] = "test-value"; + + const sender = new HttpSender(); + await sender.send(request); + + expect(receivedHeaders["x-custom"]).to.equal("test-value"); + }); + + it("returns response headers to the caller.", async function () { + await startServer((_req, res) => { + res.writeHead(200, { + "Content-Type": "application/json", + "X-RateLimit-Remaining": "99", + }); + res.end(JSON.stringify({})); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.headers["x-ratelimit-remaining"]).to.equal("99"); + }); + + it("handles a plain text response.", async function () { + await startServer((_req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("plain text body"); + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(); + const response = await sender.send(request); + + expect(response.statusCode).to.equal(200); + expect(response.payload).to.equal("plain text body"); + }); + + it("times out when the server is too slow.", async function () { + await startServer((_req, _res) => { + // never respond + }); + + const request = new Request(); + request.baseUrl = baseUrl + "/lookup"; + + const sender = new HttpSender(100); // 100ms timeout + + try { + await sender.send(request); + expect.fail("should have rejected"); + } catch (error: any) { + expect(error.statusCode).to.equal(0); + expect(error.error).to.not.be.null; + } + }); +}); From f5ee2219f9e04c6cff54e616d99fa4e173452533 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 10:03:11 -0600 Subject: [PATCH 02/16] Fix type errors in us_street test payload assertions --- tests/us_street/test_Client.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/us_street/test_Client.ts b/tests/us_street/test_Client.ts index f8f3bcb..60bc5d1 100644 --- a/tests/us_street/test_Client.ts +++ b/tests/us_street/test_Client.ts @@ -220,8 +220,9 @@ describe("A US Street client", function () { client.send(batch); - expect(mockSender.request.payload[0].match).to.equal("strict"); - expect(mockSender.request.payload[1].match).to.equal("strict"); + const payload1 = mockSender.request.payload as Record[]; + expect(payload1[0]["match"]).to.equal("strict"); + expect(payload1[1]["match"]).to.equal("strict"); }); it("sends defaults in batch mode.", function () { @@ -233,8 +234,9 @@ describe("A US Street client", function () { client.send(batch); - expect(mockSender.request.payload[0].match).to.equal("enhanced"); - expect(mockSender.request.payload[0].candidates).to.equal(5); + const payload2 = mockSender.request.payload as Record[]; + expect(payload2[0]["match"]).to.equal("enhanced"); + expect(payload2[0]["candidates"]).to.equal(5); }); it("doesn't send an empty batch.", function () { From cde88ab4ae4bfa32a019db59140fa5d60d3ecdfd Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 10:19:04 -0600 Subject: [PATCH 03/16] Harden HttpSender: optional undici, deferred fetch resolution, consistent throw --- package.json | 4 +++- src/HttpSender.ts | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 7856d3b..d73c1f1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,9 @@ "serialize-javascript": "^7.0.3" }, "dependencies": { - "tslib": "^2.8.1", + "tslib": "^2.8.1" + }, + "optionalDependencies": { "undici": "^7.0.0" } } diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 101057c..cbdc114 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -6,7 +6,7 @@ type FetchFunction = typeof fetch; export default class HttpSender { private timeout: number; private debug: boolean; - private fetchFn: FetchFunction; + private fetchFn: FetchFunction | undefined; private dispatcher: unknown; constructor( @@ -17,13 +17,22 @@ export default class HttpSender { ) { this.timeout = timeout; this.debug = debug; - this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis); + this.fetchFn = fetchFn; if (proxyConfig) { this.initProxy(proxyConfig); } } + private resolveFetch(): FetchFunction { + if (this.fetchFn) return this.fetchFn; + if (typeof globalThis.fetch === "function") { + this.fetchFn = globalThis.fetch.bind(globalThis); + return this.fetchFn; + } + throw new Error("No fetch implementation available. Provide one via the fetchFn constructor parameter."); + } + private initProxy(config: ProxyConfig): void { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -60,6 +69,7 @@ export default class HttpSender { } async send(request: SmartyRequest): Promise { + const fetchFn = this.resolveFetch(); const { url, init } = this.buildFetchArgs(request); if (this.debug) { @@ -68,7 +78,7 @@ export default class HttpSender { } try { - const response = await this.fetchFn(url, init); + const response = await fetchFn(url, init); const data = await this.parseResponseBody(response); const headers = Object.fromEntries(response.headers.entries()); @@ -81,12 +91,12 @@ export default class HttpSender { const smartyResponse = buildSmartyResponse({ status: response.status, data, headers }); - if (smartyResponse.statusCode >= 400) return Promise.reject(smartyResponse); + if (smartyResponse.statusCode >= 400) throw smartyResponse; return smartyResponse; } catch (error) { if (error && typeof error === "object" && "statusCode" in error) throw error; - return Promise.reject(buildSmartyResponse(undefined, error as Error)); + throw buildSmartyResponse(undefined, error as Error); } } @@ -106,6 +116,7 @@ export default class HttpSender { try { return await response.json(); } catch { + // Malformed JSON from the server — return null rather than surfacing a parse error. return null; } } From f8b0ec776de50c010f0c5a2ec686288bf58d8104 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 10:40:37 -0600 Subject: [PATCH 04/16] Refine fetch migration: stricter types, proxy URL overload, debug logging - Add withProxy(url) single-arg overload for simpler proxy configuration - Narrow header types from Record to Record - Add debug logging for JSON parse failures in HttpSender - Add comment explaining undici dispatcher type bypass - Declare Node >=18.0.0 engine requirement (fetch is built-in from v18) --- package.json | 3 +++ src/ClientBuilder.ts | 20 ++++++++++++++++---- src/HttpSender.ts | 8 ++++++-- src/types.ts | 2 +- src/util/buildSmartyResponse.ts | 2 +- tests/fixtures/mock_senders.ts | 4 ++-- tests/test_RetrySender.ts | 6 +++--- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index d73c1f1..347ee6a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,9 @@ "test": "mocha --require tsx/cjs 'tests/**/*.ts'" }, "author": "Smarty SDK Team (https://www.smarty.com)", + "engines": { + "node": ">=18.0.0" + }, "license": "Apache-2.0", "repository": { "type": "git", diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index 0c30126..4704d2b 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -93,18 +93,30 @@ export default class ClientBuilder { return this; } + withProxy(url: string): ClientBuilder; withProxy( host: string, port: number, protocol: string, username?: string, password?: string, + ): ClientBuilder; + withProxy( + hostOrUrl: string, + port?: number, + protocol?: string, + username?: string, + password?: string, ): ClientBuilder { - let auth = ""; - if (username && password) { - auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + if (port === undefined) { + this.proxy = { url: hostOrUrl }; + } else { + let auth = ""; + if (username && password) { + auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; + } + this.proxy = { url: `${protocol}://${auth}${hostOrUrl}:${port}` }; } - this.proxy = { url: `${protocol}://${auth}${host}:${port}` }; return this; } diff --git a/src/HttpSender.ts b/src/HttpSender.ts index cbdc114..a166844 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -61,6 +61,8 @@ export default class HttpSender { : JSON.stringify(request.payload); } + // "dispatcher" is an undici-specific extension not present in the standard + // RequestInit type, so we must bypass the type system to attach it. if (this.dispatcher) { (init as Record)["dispatcher"] = this.dispatcher; } @@ -115,8 +117,10 @@ export default class HttpSender { if (contentType.includes("application/json")) { try { return await response.json(); - } catch { - // Malformed JSON from the server — return null rather than surfacing a parse error. + } catch (error) { + if (this.debug) { + console.log("Failed to parse JSON response:", error); + } return null; } } diff --git a/src/types.ts b/src/types.ts index b1f8cd7..bb844f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,7 +37,7 @@ export interface BaseLookup { export interface MockSenderInstance extends Sender { statusCodes: number[]; - headers?: Record | undefined; + headers?: Record | undefined; error?: string | undefined; currentStatusCodeIndex: number; } diff --git a/src/util/buildSmartyResponse.ts b/src/util/buildSmartyResponse.ts index 0a58a52..2bdb57b 100644 --- a/src/util/buildSmartyResponse.ts +++ b/src/util/buildSmartyResponse.ts @@ -4,7 +4,7 @@ interface HttpResponse { status: number; data?: object[] | object | string | null | undefined; error?: any; - headers?: Record | undefined; + headers?: Record | undefined; } export function buildSmartyResponse(response?: HttpResponse, error?: Error): Response { diff --git a/tests/fixtures/mock_senders.ts b/tests/fixtures/mock_senders.ts index 4ab6ed2..3356544 100644 --- a/tests/fixtures/mock_senders.ts +++ b/tests/fixtures/mock_senders.ts @@ -43,11 +43,11 @@ export class MockSenderWithResponse { export class MockSenderWithStatusCodesAndHeaders { statusCodes: number[]; - headers: Record | undefined; + headers: Record | undefined; error: string | undefined; currentStatusCodeIndex: number; - constructor(statusCodes: number[], headers?: Record, error?: string) { + constructor(statusCodes: number[], headers?: Record, error?: string) { this.statusCodes = statusCodes; this.headers = headers; this.error = error; diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index af93322..300de99 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -7,7 +7,7 @@ import type { Sender, Sleeper, MockSenderInstance, MockSleeperInstance } from ". class CompatibleMockSender implements MockSenderInstance { statusCodes: number[]; - headers?: Record | undefined; + headers?: Record | undefined; error?: string | undefined; currentStatusCodeIndex: number; private mockSender: { @@ -15,7 +15,7 @@ class CompatibleMockSender implements MockSenderInstance { currentStatusCodeIndex: number; }; - constructor(statusCodes: number[], headers?: Record, error?: string) { + constructor(statusCodes: number[], headers?: Record, error?: string) { this.statusCodes = statusCodes; this.headers = headers; this.error = error; @@ -111,7 +111,7 @@ describe("Retry Sender tests", function () { }); it("test rate limit error return", async function () { - let inner = new CompatibleMockSender([429], { "Retry-After": 7 }); + let inner = new CompatibleMockSender([429], { "Retry-After": "7" }); const sleeper = new CompatibleMockSleeper(); await sendWithRetry(10, inner, sleeper); From 871ec9a81946f92b78dd75872e1a54efa84f769c Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 10:54:55 -0600 Subject: [PATCH 05/16] Tighten types and simplify mocks from review feedback - Remove silent swallow of JSON parse errors in HttpSender - Narrow buildSmartyResponse error field from `any` to `string | Error` - Widen Response.error to `Error | string | null` to match actual usage - Make MockSenderWithStatusCodesAndHeaders.send() return Promise - Remove CompatibleMockSender wrapper and mock interfaces from types.ts --- src/HttpSender.ts | 9 +--- src/Response.ts | 4 +- src/types.ts | 13 +----- src/util/buildSmartyResponse.ts | 2 +- tests/fixtures/mock_senders.ts | 2 +- tests/test_RetrySender.ts | 76 ++++++++++----------------------- 6 files changed, 28 insertions(+), 78 deletions(-) diff --git a/src/HttpSender.ts b/src/HttpSender.ts index a166844..5f20616 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -115,14 +115,7 @@ export default class HttpSender { ): Promise { const contentType = response.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - try { - return await response.json(); - } catch (error) { - if (this.debug) { - console.log("Failed to parse JSON response:", error); - } - return null; - } + return await response.json(); } const text = await response.text(); return text || null; diff --git a/src/Response.ts b/src/Response.ts index b893a92..3af4808 100644 --- a/src/Response.ts +++ b/src/Response.ts @@ -3,13 +3,13 @@ import { Response as IResponse } from "./types.js"; export default class Response implements IResponse { statusCode: number; payload: object[] | object | string | null; - error: Error | null; + error: Error | string | null; headers: Record; constructor( statusCode: number, payload: object[] | object | string | null = null, - error: Error | null = null, + error: Error | string | null = null, headers: Record = {}, ) { this.statusCode = statusCode; diff --git a/src/types.ts b/src/types.ts index bb844f3..9cafd72 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ export interface Request { export interface Response { statusCode: number; payload: object[] | object | string | null; - error: Error | null; + error: Error | string | null; headers: Record; } @@ -34,14 +34,3 @@ export interface BaseLookup { customParameters: Record; result: { inputIndex: number }[]; } - -export interface MockSenderInstance extends Sender { - statusCodes: number[]; - headers?: Record | undefined; - error?: string | undefined; - currentStatusCodeIndex: number; -} - -export interface MockSleeperInstance extends Sleeper { - sleepDurations: number[]; -} diff --git a/src/util/buildSmartyResponse.ts b/src/util/buildSmartyResponse.ts index 2bdb57b..a47daca 100644 --- a/src/util/buildSmartyResponse.ts +++ b/src/util/buildSmartyResponse.ts @@ -3,7 +3,7 @@ import Response from "../Response.js"; interface HttpResponse { status: number; data?: object[] | object | string | null | undefined; - error?: any; + error?: string | Error | undefined; headers?: Record | undefined; } diff --git a/tests/fixtures/mock_senders.ts b/tests/fixtures/mock_senders.ts index 3356544..fcbd3ea 100644 --- a/tests/fixtures/mock_senders.ts +++ b/tests/fixtures/mock_senders.ts @@ -54,7 +54,7 @@ export class MockSenderWithStatusCodesAndHeaders { this.currentStatusCodeIndex = 0; } - send(_request: IRequest) { + async send(_request: IRequest): Promise { const mockResponse = { status: this.statusCodes[this.currentStatusCodeIndex], headers: this.headers, diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 300de99..687a1fe 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -2,45 +2,13 @@ import { expect } from "chai"; import RetrySender from "../src/RetrySender.js"; import { MockSenderWithStatusCodesAndHeaders } from "./fixtures/mock_senders.js"; import Request from "../src/Request.js"; -import Response from "../src/Response.js"; -import type { Sender, Sleeper, MockSenderInstance, MockSleeperInstance } from "../src/types"; - -class CompatibleMockSender implements MockSenderInstance { - statusCodes: number[]; - headers?: Record | undefined; - error?: string | undefined; - currentStatusCodeIndex: number; - private mockSender: { - send(request: Request): Response; - currentStatusCodeIndex: number; - }; - - constructor(statusCodes: number[], headers?: Record, error?: string) { - this.statusCodes = statusCodes; - this.headers = headers; - this.error = error; - this.currentStatusCodeIndex = 0; - this.mockSender = new (MockSenderWithStatusCodesAndHeaders as new ( - ...args: [number[], Record?, string?] - ) => { - send(request: Request): Response; - currentStatusCodeIndex: number; - })(statusCodes, headers, error); - } - - async send(request: Request): Promise { - const result = this.mockSender.send(request); - this.currentStatusCodeIndex = this.mockSender.currentStatusCodeIndex; - return Promise.resolve(result); - } -} +import type { Sender, Sleeper } from "../src/types"; -class CompatibleMockSleeper implements MockSleeperInstance { +class MockSleeper implements Sleeper { sleepDurations: number[] = []; async sleep(ms: number): Promise { this.sleepDurations.push(ms); - return Promise.resolve(); } } @@ -52,29 +20,29 @@ async function sendWithRetry(retries: number, inner: Sender, sleeper: Sleeper) { describe("Retry Sender tests", function () { it("test success does not retry", async function () { - let inner = new CompatibleMockSender([200]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); + let inner = new MockSenderWithStatusCodesAndHeaders([200]); + await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test client error does not retry", async function () { - let inner = new CompatibleMockSender([422]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); + let inner = new MockSenderWithStatusCodesAndHeaders([422]); + await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test will retry until success", async function () { - let inner = new CompatibleMockSender([500, 500, 500, 200, 500]); - await sendWithRetry(10, inner, new CompatibleMockSleeper()); + let inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 200, 500]); + await sendWithRetry(10, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(4); }); it("test return response if retry limit exceeded", async function () { - let inner = new CompatibleMockSender([500, 500, 500, 500, 500]); - const sleeper = new CompatibleMockSleeper(); + let inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 500, 500]); + const sleeper = new MockSleeper(); const response = await sendWithRetry(4, inner, sleeper); expect(response); @@ -84,10 +52,10 @@ describe("Retry Sender tests", function () { }); it("test backoff does not exceed max", async function () { - let inner = new CompatibleMockSender([ + let inner = new MockSenderWithStatusCodesAndHeaders([ 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 200, ]); - const sleeper = new CompatibleMockSleeper(); + const sleeper = new MockSleeper(); await sendWithRetry(20, inner, sleeper); @@ -95,15 +63,15 @@ describe("Retry Sender tests", function () { }); it("test empty status does not retry", async function () { - let inner = new CompatibleMockSender([]); - await sendWithRetry(5, inner, new CompatibleMockSleeper()); + let inner = new MockSenderWithStatusCodesAndHeaders([]); + await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test sleep on rate limit", async function () { - let inner = new CompatibleMockSender([429, 200]); - const sleeper = new CompatibleMockSleeper(); + let inner = new MockSenderWithStatusCodesAndHeaders([429, 200]); + const sleeper = new MockSleeper(); await sendWithRetry(5, inner, sleeper); @@ -111,8 +79,8 @@ describe("Retry Sender tests", function () { }); it("test rate limit error return", async function () { - let inner = new CompatibleMockSender([429], { "Retry-After": "7" }); - const sleeper = new CompatibleMockSleeper(); + let inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "7" }); + const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); @@ -120,8 +88,8 @@ describe("Retry Sender tests", function () { }); it("test retry after invalid value", async function () { - let inner = new CompatibleMockSender([429], { "Retry-After": "a" }); - const sleeper = new CompatibleMockSleeper(); + let inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "a" }); + const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); @@ -129,8 +97,8 @@ describe("Retry Sender tests", function () { }); it("test retry error", async function () { - let inner = new CompatibleMockSender([429], undefined, "Big Bad"); - const sleeper = new CompatibleMockSleeper(); + let inner = new MockSenderWithStatusCodesAndHeaders([429], undefined, "Big Bad"); + const sleeper = new MockSleeper(); const response = await sendWithRetry(10, inner, sleeper); From 8c377f3ce098126c653852982e0be3a12964285b Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 11:12:50 -0600 Subject: [PATCH 06/16] Fix RetrySender to handle thrown error responses, inline ProxyConfig, tighten types RetrySender now catches rejected responses from the sender chain and converts them back to return values for retry evaluation. Mock sender updated to throw on >= 400 to match real HttpSender behavior. Removed ProxyConfig interface in favor of inline type, typed dispatcher as undici.Dispatcher, and cleaned up let -> const in retry tests. --- src/ClientBuilder.ts | 4 ++-- src/HttpSender.ts | 10 ++++------ src/RetrySender.ts | 15 +++++++++++++-- src/types.ts | 4 ---- tests/fixtures/mock_senders.ts | 1 + tests/test_RetrySender.ts | 20 ++++++++++---------- 6 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index 4704d2b..6cc7078 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -22,7 +22,7 @@ import UsReverseGeoClient from "./us_reverse_geo/Client.js"; import InternationalAddressAutocompleteClient from "./international_address_autocomplete/Client.js"; import UsEnrichmentClient from "./us_enrichment/Client.js"; import InternationalPostalCodeClient from "./international_postal_code/Client.js"; -import { ProxyConfig, Sender } from "./types.js"; +import { Sender } from "./types.js"; const INTERNATIONAL_STREET_API_URI = "https://international-street.api.smarty.com/verify"; const US_AUTOCOMPLETE_PRO_API_URL = "https://us-autocomplete-pro.api.smarty.com/lookup"; @@ -43,7 +43,7 @@ export default class ClientBuilder { private maxRetries: number; private maxTimeout: number; private baseUrl: string | undefined; - private proxy: ProxyConfig | undefined; + private proxy: { url: string } | undefined; private customHeaders: Record; private appendHeaders: Record; private debug: boolean | undefined; diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 5f20616..718b750 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -1,5 +1,5 @@ import { buildSmartyResponse } from "./util/buildSmartyResponse.js"; -import { Request as SmartyRequest, Response as SmartyResponse, ProxyConfig } from "./types.js"; +import { Request as SmartyRequest, Response as SmartyResponse } from "./types.js"; type FetchFunction = typeof fetch; @@ -7,11 +7,11 @@ export default class HttpSender { private timeout: number; private debug: boolean; private fetchFn: FetchFunction | undefined; - private dispatcher: unknown; + private dispatcher: import("undici").Dispatcher | undefined; constructor( timeout: number = 10000, - proxyConfig?: ProxyConfig, + proxyConfig?: { url: string }, debug: boolean = false, fetchFn?: FetchFunction, ) { @@ -33,7 +33,7 @@ export default class HttpSender { throw new Error("No fetch implementation available. Provide one via the fetchFn constructor parameter."); } - private initProxy(config: ProxyConfig): void { + private initProxy(config: { url: string }): void { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { ProxyAgent } = require("undici"); @@ -61,8 +61,6 @@ export default class HttpSender { : JSON.stringify(request.payload); } - // "dispatcher" is an undici-specific extension not present in the standard - // RequestInit type, so we must bypass the type system to attach it. if (this.dispatcher) { (init as Record)["dispatcher"] = this.dispatcher; } diff --git a/src/RetrySender.ts b/src/RetrySender.ts index 988874b..5231d75 100644 --- a/src/RetrySender.ts +++ b/src/RetrySender.ts @@ -18,7 +18,7 @@ export default class RetrySender { } async send(request: Request): Promise { - let response = await this.inner.send(request); + let response = await this.trySend(request); for (let i = 0; i < this.maxRetries; i++) { const statusCode = response.statusCode; @@ -38,12 +38,23 @@ export default class RetrySender { } else { await this.backoff(i); } - response = await this.inner.send(request); + response = await this.trySend(request); } return response; } + private async trySend(request: Request): Promise { + try { + return await this.inner.send(request); + } catch (error) { + if (error && typeof error === "object" && "statusCode" in error) { + return error as Response; + } + throw error; + } + } + private async backoff(attempt: number): Promise { const backoffDuration = Math.min(attempt, this.maxBackoffDuration); console.log( diff --git a/src/types.ts b/src/types.ts index 9cafd72..13c3a4a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,10 +25,6 @@ export interface Signer { sign(request: Request): void; } -export interface ProxyConfig { - url: string; -} - export interface BaseLookup { inputId?: string | number | undefined; customParameters: Record; diff --git a/tests/fixtures/mock_senders.ts b/tests/fixtures/mock_senders.ts index fcbd3ea..56a3837 100644 --- a/tests/fixtures/mock_senders.ts +++ b/tests/fixtures/mock_senders.ts @@ -62,6 +62,7 @@ export class MockSenderWithStatusCodesAndHeaders { }; const response = buildSmartyResponse(mockResponse); this.currentStatusCodeIndex += 1; + if (response.statusCode >= 400) throw response; return response; } } diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 687a1fe..5f311e5 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -20,28 +20,28 @@ async function sendWithRetry(retries: number, inner: Sender, sleeper: Sleeper) { describe("Retry Sender tests", function () { it("test success does not retry", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([200]); + const inner = new MockSenderWithStatusCodesAndHeaders([200]); await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test client error does not retry", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([422]); + const inner = new MockSenderWithStatusCodesAndHeaders([422]); await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test will retry until success", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 200, 500]); + const inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 200, 500]); await sendWithRetry(10, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(4); }); it("test return response if retry limit exceeded", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 500, 500]); + const inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 500, 500]); const sleeper = new MockSleeper(); const response = await sendWithRetry(4, inner, sleeper); @@ -52,7 +52,7 @@ describe("Retry Sender tests", function () { }); it("test backoff does not exceed max", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([ + const inner = new MockSenderWithStatusCodesAndHeaders([ 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 200, ]); const sleeper = new MockSleeper(); @@ -63,14 +63,14 @@ describe("Retry Sender tests", function () { }); it("test empty status does not retry", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([]); + const inner = new MockSenderWithStatusCodesAndHeaders([]); await sendWithRetry(5, inner, new MockSleeper()); expect(inner.currentStatusCodeIndex).to.equal(1); }); it("test sleep on rate limit", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([429, 200]); + const inner = new MockSenderWithStatusCodesAndHeaders([429, 200]); const sleeper = new MockSleeper(); await sendWithRetry(5, inner, sleeper); @@ -79,7 +79,7 @@ describe("Retry Sender tests", function () { }); it("test rate limit error return", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "7" }); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "7" }); const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); @@ -88,7 +88,7 @@ describe("Retry Sender tests", function () { }); it("test retry after invalid value", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "a" }); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "a" }); const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); @@ -97,7 +97,7 @@ describe("Retry Sender tests", function () { }); it("test retry error", async function () { - let inner = new MockSenderWithStatusCodesAndHeaders([429], undefined, "Big Bad"); + const inner = new MockSenderWithStatusCodesAndHeaders([429], undefined, "Big Bad"); const sleeper = new MockSleeper(); const response = await sendWithRetry(10, inner, sleeper); From b2a4d8f6ced2d2f124ca578dd9102e2f40678bf8 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 11:29:12 -0600 Subject: [PATCH 07/16] Update CLAUDE.md to reflect Axios-to-Fetch migration --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e2a764..7192ffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ To run a single test file: npx mocha --require tsx/cjs tests/test_RetrySender.ts ``` -Build outputs dual formats via Rollup: `dist/cjs/` (CommonJS), `dist/esm/` (ESM), and `dist/types/` (declarations). Rollup preserves module structure (`preserveModules: true`). Axios and axios-retry are external dependencies (not bundled). +Build outputs dual formats via Rollup: `dist/cjs/` (CommonJS), `dist/esm/` (ESM), and `dist/types/` (declarations). Rollup preserves module structure (`preserveModules: true`). `undici` is the only external dependency (not bundled). ## Architecture @@ -41,7 +41,7 @@ CustomQuerySender → LicenseSender → BaseUrlSender → CustomHeaderSender → AgentSender → RetrySender → SigningSender → StatusCodeSender → HttpSender ``` -Each sender adds specific functionality (authentication, retries, headers, etc.). The `HttpSender` at the end uses Axios for actual HTTP transport. +Each sender adds specific functionality (authentication, retries, headers, etc.). The `HttpSender` at the end uses the Fetch API for HTTP transport (with optional `undici` `ProxyAgent` for proxy support in Node.js). All senders implement the `Sender` interface from `src/types.ts`, which also defines the core `Request` and `Response` contracts that flow through the chain. From 6689efa1905019625e5bfa55023e6155d5f295db Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 12:14:46 -0600 Subject: [PATCH 08/16] Fix Retry-After header case to match fetch API lowercase normalization, tighten debug type --- src/ClientBuilder.ts | 6 +++--- src/HttpSender.ts | 2 +- src/RetrySender.ts | 2 +- tests/test_RetrySender.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index 6cc7078..cba78fa 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -46,7 +46,7 @@ export default class ClientBuilder { private proxy: { url: string } | undefined; private customHeaders: Record; private appendHeaders: Record; - private debug: boolean | undefined; + private debug: boolean; private licenses: string[]; private customQueries: Map; @@ -68,7 +68,7 @@ export default class ClientBuilder { this.proxy = undefined; this.customHeaders = {}; this.appendHeaders = {}; - this.debug = undefined; + this.debug = false; this.licenses = []; this.customQueries = new Map(); } @@ -187,7 +187,7 @@ export default class ClientBuilder { const conflicts: string[] = []; if (this.maxTimeout !== 10000) conflicts.push("withMaxTimeout()"); if (this.proxy !== undefined) conflicts.push("withProxy()"); - if (this.debug !== undefined) conflicts.push("withDebug()"); + if (this.debug) conflicts.push("withDebug()"); if (conflicts.length > 0) throw new Error(`withSender() cannot be combined with: ${conflicts.join(", ")}. These options only apply to the built-in HTTP transport.`); } diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 718b750..9184bec 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -12,7 +12,7 @@ export default class HttpSender { constructor( timeout: number = 10000, proxyConfig?: { url: string }, - debug: boolean = false, + debug = false, fetchFn?: FetchFunction, ) { this.timeout = timeout; diff --git a/src/RetrySender.ts b/src/RetrySender.ts index 5231d75..20a6676 100644 --- a/src/RetrySender.ts +++ b/src/RetrySender.ts @@ -29,7 +29,7 @@ export default class RetrySender { if (statusCode === this.statusTooManyRequests) { let secondsToBackoff = 10; if (response.headers) { - const retryAfterHeader = response.headers["Retry-After"]; + const retryAfterHeader = response.headers["retry-after"]; if (Number.isInteger(Number(retryAfterHeader))) { secondsToBackoff = Number(retryAfterHeader); } diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 5f311e5..85b7245 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -79,7 +79,7 @@ describe("Retry Sender tests", function () { }); it("test rate limit error return", async function () { - const inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "7" }); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "7" }); const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); @@ -88,7 +88,7 @@ describe("Retry Sender tests", function () { }); it("test retry after invalid value", async function () { - const inner = new MockSenderWithStatusCodesAndHeaders([429], { "Retry-After": "a" }); + const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "a" }); const sleeper = new MockSleeper(); await sendWithRetry(10, inner, sleeper); From 82991ad1b5622c65e6b5a3383eab06c3c4393a76 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 12:27:15 -0600 Subject: [PATCH 09/16] Harden error types and validate baseUrl in HttpSender Narrow Response.error back to Error | null (was Error | string | null) by wrapping string errors in buildSmartyResponse's new normalizeError helper. Validate baseUrl is an absolute HTTP(S) URL before constructing a URL object. Safely wrap non-Error throwables in the fetch catch block. --- src/HttpSender.ts | 5 ++++- src/Response.ts | 4 ++-- src/types.ts | 2 +- src/util/buildSmartyResponse.ts | 12 +++++++++++- tests/test_RetrySender.ts | 3 ++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 9184bec..69950d1 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -96,11 +96,14 @@ export default class HttpSender { return smartyResponse; } catch (error) { if (error && typeof error === "object" && "statusCode" in error) throw error; - throw buildSmartyResponse(undefined, error as Error); + throw buildSmartyResponse(undefined, error instanceof Error ? error : new Error(String(error))); } } private buildUrl(request: SmartyRequest): string { + if (!request.baseUrl || !/^https?:\/\//i.test(request.baseUrl)) { + throw new Error(`Invalid baseUrl: "${request.baseUrl}". Expected an absolute HTTP(S) URL.`); + } const url = new URL(request.baseUrl); for (const [key, value] of Object.entries(request.parameters)) { url.searchParams.append(key, String(value)); diff --git a/src/Response.ts b/src/Response.ts index 3af4808..b893a92 100644 --- a/src/Response.ts +++ b/src/Response.ts @@ -3,13 +3,13 @@ import { Response as IResponse } from "./types.js"; export default class Response implements IResponse { statusCode: number; payload: object[] | object | string | null; - error: Error | string | null; + error: Error | null; headers: Record; constructor( statusCode: number, payload: object[] | object | string | null = null, - error: Error | string | null = null, + error: Error | null = null, headers: Record = {}, ) { this.statusCode = statusCode; diff --git a/src/types.ts b/src/types.ts index 13c3a4a..553c5af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,7 @@ export interface Request { export interface Response { statusCode: number; payload: object[] | object | string | null; - error: Error | string | null; + error: Error | null; headers: Record; } diff --git a/src/util/buildSmartyResponse.ts b/src/util/buildSmartyResponse.ts index a47daca..04a0501 100644 --- a/src/util/buildSmartyResponse.ts +++ b/src/util/buildSmartyResponse.ts @@ -7,8 +7,18 @@ interface HttpResponse { headers?: Record | undefined; } +function normalizeError(error: string | Error | undefined | null): Error | null { + if (error == null) return null; + return error instanceof Error ? error : new Error(error); +} + export function buildSmartyResponse(response?: HttpResponse, error?: Error): Response { if (response) - return new Response(response.status, response.data, response.error, response.headers); + return new Response( + response.status, + response.data, + normalizeError(response.error), + response.headers, + ); return new Response(0, null, error); } diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 85b7245..1e45812 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -102,6 +102,7 @@ describe("Retry Sender tests", function () { const response = await sendWithRetry(10, inner, sleeper); - expect(response.error).to.equal("Big Bad"); + expect(response.error).to.be.an.instanceOf(Error); + expect(response.error!.message).to.equal("Big Bad"); }); }); From c23108e4721c12f3e28c562bdc93bcfb5681710e Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:25:53 -0600 Subject: [PATCH 10/16] Improve HttpSender error handling: preserve status codes, fix proxy errors, use dynamic import - Preserve HTTP status codes when parseResponseBody throws (e.g. malformed JSON on a 4xx/5xx response) instead of defaulting to status 0 - Split proxy init into two try/catch blocks so invalid proxy URLs get a clear message instead of the misleading "install undici" error - Switch from require() to await import() for undici so proxy works in ESM contexts; store promise and await in send() --- src/HttpSender.ts | 43 ++++++++++++++++++++++++++++++---------- tests/test_HttpSender.ts | 19 +++++++++--------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/src/HttpSender.ts b/src/HttpSender.ts index 69950d1..a52a5d2 100644 --- a/src/HttpSender.ts +++ b/src/HttpSender.ts @@ -8,6 +8,7 @@ export default class HttpSender { private debug: boolean; private fetchFn: FetchFunction | undefined; private dispatcher: import("undici").Dispatcher | undefined; + private proxyReady: Promise | undefined; constructor( timeout: number = 10000, @@ -20,7 +21,7 @@ export default class HttpSender { this.fetchFn = fetchFn; if (proxyConfig) { - this.initProxy(proxyConfig); + this.proxyReady = this.initProxy(proxyConfig); } } @@ -30,19 +31,29 @@ export default class HttpSender { this.fetchFn = globalThis.fetch.bind(globalThis); return this.fetchFn; } - throw new Error("No fetch implementation available. Provide one via the fetchFn constructor parameter."); + throw new Error( + "No fetch implementation available. Provide one via the fetchFn constructor parameter.", + ); } - private initProxy(config: { url: string }): void { + private async initProxy(config: { url: string }): Promise { + let ProxyAgent: typeof import("undici").ProxyAgent; try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ProxyAgent } = require("undici"); - this.dispatcher = new ProxyAgent(config.url); + ({ ProxyAgent } = await import("undici")); } catch { throw new Error( "The 'undici' package is required for proxy support. Install it with: npm install undici", ); } + try { + this.dispatcher = new ProxyAgent(config.url); + } catch (error) { + const err = new Error( + `Invalid proxy URL: "${config.url}". The proxy URL must be an origin (scheme + host + port) with no path, query, or fragment.`, + ); + (err as Error & { cause: unknown }).cause = error; + throw err; + } } buildFetchArgs(request: SmartyRequest): { url: string; init: RequestInit } { @@ -56,9 +67,7 @@ export default class HttpSender { if (request.payload) { init.body = - typeof request.payload === "string" - ? request.payload - : JSON.stringify(request.payload); + typeof request.payload === "string" ? request.payload : JSON.stringify(request.payload); } if (this.dispatcher) { @@ -69,6 +78,8 @@ export default class HttpSender { } async send(request: SmartyRequest): Promise { + if (this.proxyReady) await this.proxyReady; + const fetchFn = this.resolveFetch(); const { url, init } = this.buildFetchArgs(request); @@ -77,8 +88,9 @@ export default class HttpSender { console.log("\r\n*******************************************\r\n"); } + let response: globalThis.Response | undefined; try { - const response = await fetchFn(url, init); + response = await fetchFn(url, init); const data = await this.parseResponseBody(response); const headers = Object.fromEntries(response.headers.entries()); @@ -96,7 +108,16 @@ export default class HttpSender { return smartyResponse; } catch (error) { if (error && typeof error === "object" && "statusCode" in error) throw error; - throw buildSmartyResponse(undefined, error instanceof Error ? error : new Error(String(error))); + + const wrappedError = error instanceof Error ? error : new Error(String(error)); + if (response) { + throw buildSmartyResponse({ + status: response.status, + error: wrappedError, + headers: Object.fromEntries(response.headers.entries()), + }); + } + throw buildSmartyResponse(undefined, wrappedError); } } diff --git a/tests/test_HttpSender.ts b/tests/test_HttpSender.ts index 95acfcd..17df024 100644 --- a/tests/test_HttpSender.ts +++ b/tests/test_HttpSender.ts @@ -97,18 +97,19 @@ describe("A fetch-based HTTP sender", function () { expect(init.signal).to.be.an.instanceOf(AbortSignal); }); - it("includes a dispatcher when proxy config is provided.", function () { + it("includes a dispatcher when proxy config is provided.", async function () { const request = new Request(); request.baseUrl = "https://example.com/api"; - const sender = new HttpSender( - 10000, - { url: "http://proxy:8080" }, - false, - mockFetchResponse(200), - ); - const { init } = sender.buildFetchArgs(request); + let capturedInit: RequestInit | undefined; + const capturingFetch: typeof fetch = async (_url, init) => { + capturedInit = init; + return new Response(JSON.stringify(null), { status: 200, headers: { "content-type": "application/json" } }); + }; + const sender = new HttpSender(10000, { url: "http://proxy:8080" }, false, capturingFetch); + + await sender.send(request); - expect((init as Record)["dispatcher"]).to.not.be.undefined; + expect((capturedInit as Record)["dispatcher"]).to.not.be.undefined; }); it("resolves with a SmartyResponse on a 2xx response.", async function () { From 821daed7acb55597bd0e543bc3ce73c40a82fc9f Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:26:16 -0600 Subject: [PATCH 11/16] Apply Prettier formatting and bump minimum Node version to 20 --- CLAUDE.md | 1 + UPGRADING.md | 10 +++++----- .../international_address_autocomplete.ts | 11 +++++++++-- examples/international_postal_code.ts | 19 ++++++++++++++++--- examples/international_street.ts | 6 +++++- examples/us_autocomplete_pro.ts | 6 +++++- examples/us_enrichment.ts | 6 +++++- examples/us_reverse_geo.ts | 6 +++++- examples/us_street.ts | 7 ++++++- examples/us_zipcode.ts | 7 ++++++- package.json | 2 +- src/ClientBuilder.ts | 4 +++- tests/international_street/test_Client.ts | 10 ++++++++-- tests/test_ClientBuilder.ts | 18 +++++++++++++++--- 14 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7192ffd..42572ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ npx prettier --write . # Format all files (respects .prettierignore) ``` Makefile targets (used by CI): + ```bash make test # fmt + test (installs deps if needed) make build # Rollup build diff --git a/UPGRADING.md b/UPGRADING.md index 1d28c54..e121b3d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -88,11 +88,11 @@ console.log(response.lookups[0].result); ## Import Patterns -| Style | Syntax | -| --- | --- | -| ESM default import | `import SmartySDK from "smartystreets-javascript-sdk"` | -| ESM named imports | `import { core, usStreet } from "smartystreets-javascript-sdk"` | -| CommonJS | `const SmartySDK = require("smartystreets-javascript-sdk")` | +| Style | Syntax | +| ---------------------- | ------------------------------------------------------------------- | +| ESM default import | `import SmartySDK from "smartystreets-javascript-sdk"` | +| ESM named imports | `import { core, usStreet } from "smartystreets-javascript-sdk"` | +| CommonJS | `const SmartySDK = require("smartystreets-javascript-sdk")` | | TypeScript type import | `import type { MatchStrategy } from "smartystreets-javascript-sdk"` | ## FAQ diff --git a/examples/international_address_autocomplete.ts b/examples/international_address_autocomplete.ts index 11a79ff..9fb80c9 100644 --- a/examples/international_address_autocomplete.ts +++ b/examples/international_address_autocomplete.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalAddressAutocomplete } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalAddressAutocomplete, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; @@ -41,7 +45,10 @@ function logSuggestions(response: LookupInternationalAddressAutocomplete, messag console.log("\n"); } -async function handleRequest(lookup: LookupInternationalAddressAutocomplete, lookupType: string): Promise { +async function handleRequest( + lookup: LookupInternationalAddressAutocomplete, + lookupType: string, +): Promise { try { const results = await client.send(lookup); logSuggestions(results, lookupType); diff --git a/examples/international_postal_code.ts b/examples/international_postal_code.ts index 7f31e7b..b672506 100644 --- a/examples/international_postal_code.ts +++ b/examples/international_postal_code.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalPostalCode } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalPostalCode, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; @@ -43,7 +47,10 @@ function displayResult(lookup: LookupInternationalPostalCode, message: string): console.log("\n"); } -async function handleResponse(lookup: LookupInternationalPostalCode, lookupType: string): Promise { +async function handleResponse( + lookup: LookupInternationalPostalCode, + lookupType: string, +): Promise { try { const result = await client.send(lookup); displayResult(result, lookupType); @@ -59,7 +66,13 @@ async function main(): Promise { // lookup1.addCustomParameter("input_id", 1234); // Lookup by locality, administrative area, and country - const lookup2 = new LookupInternationalPostalCode("Brazil", undefined, "SP", "Sao Paulo", "ID-8675309"); + const lookup2 = new LookupInternationalPostalCode( + "Brazil", + undefined, + "SP", + "Sao Paulo", + "ID-8675309", + ); await handleResponse(lookup1, "Postal code lookup"); await handleResponse(lookup2, "Locality and administrative area lookup"); diff --git a/examples/international_street.ts b/examples/international_street.ts index 6b78e41..3129238 100644 --- a/examples/international_street.ts +++ b/examples/international_street.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupInternationalStreet } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupInternationalStreet, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_autocomplete_pro.ts b/examples/us_autocomplete_pro.ts index 6e6842b..af3a018 100644 --- a/examples/us_autocomplete_pro.ts +++ b/examples/us_autocomplete_pro.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSAutocompletePro } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSAutocompletePro, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_enrichment.ts b/examples/us_enrichment.ts index 593274a..be7c53a 100644 --- a/examples/us_enrichment.ts +++ b/examples/us_enrichment.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSEnrichment } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSEnrichment, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_reverse_geo.ts b/examples/us_reverse_geo.ts index 72b49ba..f9042d1 100644 --- a/examples/us_reverse_geo.ts +++ b/examples/us_reverse_geo.ts @@ -1,4 +1,8 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSReverseGeo } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSReverseGeo, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_street.ts b/examples/us_street.ts index 12203ae..cc11022 100644 --- a/examples/us_street.ts +++ b/examples/us_street.ts @@ -1,4 +1,9 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSStreet, Batch } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSStreet, + Batch, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/examples/us_zipcode.ts b/examples/us_zipcode.ts index ac887d6..1f555d7 100644 --- a/examples/us_zipcode.ts +++ b/examples/us_zipcode.ts @@ -1,4 +1,9 @@ -import { ClientBuilder, BasicAuthCredentials, LookupUSZipcode, Batch } from "smartystreets-javascript-sdk"; +import { + ClientBuilder, + BasicAuthCredentials, + LookupUSZipcode, + Batch, +} from "smartystreets-javascript-sdk"; // for client-side requests (browser/mobile), use this code: // import { SharedCredentials } from "smartystreets-javascript-sdk"; diff --git a/package.json b/package.json index 347ee6a..a934e3e 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ }, "author": "Smarty SDK Team (https://www.smarty.com)", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "license": "Apache-2.0", "repository": { diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index cba78fa..04af16d 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -189,7 +189,9 @@ export default class ClientBuilder { if (this.proxy !== undefined) conflicts.push("withProxy()"); if (this.debug) conflicts.push("withDebug()"); if (conflicts.length > 0) - throw new Error(`withSender() cannot be combined with: ${conflicts.join(", ")}. These options only apply to the built-in HTTP transport.`); + throw new Error( + `withSender() cannot be combined with: ${conflicts.join(", ")}. These options only apply to the built-in HTTP transport.`, + ); } const httpSender = this.httpSender ?? new HttpSender(this.maxTimeout, this.proxy, this.debug); const statusCodeSender = new StatusCodeSender(httpSender); diff --git a/tests/international_street/test_Client.ts b/tests/international_street/test_Client.ts index 28a84e0..7e987a6 100644 --- a/tests/international_street/test_Client.ts +++ b/tests/international_street/test_Client.ts @@ -17,14 +17,20 @@ describe("An International Street client", function () { let mockSender = new MockSender(); let client = new Client(mockSender); - expect(() => client.send(new Lookup())).to.throw(errors.UnprocessableEntityError, "Country field is required."); + expect(() => client.send(new Lookup())).to.throw( + errors.UnprocessableEntityError, + "Country field is required.", + ); }); it("throws an error if sending a lookup with country but missing freeform and address1.", function () { let mockSender = new MockSender(); let client = new Client(mockSender); - expect(() => client.send(new Lookup("CA"))).to.throw(errors.UnprocessableEntityError, "Either freeform or address1 is required."); + expect(() => client.send(new Lookup("CA"))).to.throw( + errors.UnprocessableEntityError, + "Either freeform or address1 is required.", + ); }); it("correctly assigns request parameters based on lookup input.", function () { diff --git a/tests/test_ClientBuilder.ts b/tests/test_ClientBuilder.ts index 5d41263..c05c941 100644 --- a/tests/test_ClientBuilder.ts +++ b/tests/test_ClientBuilder.ts @@ -26,13 +26,23 @@ describe("ClientBuilder", function () { it("throws when withSender() is combined with withMaxTimeout().", function () { expect(() => - new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withMaxTimeout(5000).buildUsStreetApiClient() + new ClientBuilder(credentials) + .withSender({ + send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }), + }) + .withMaxTimeout(5000) + .buildUsStreetApiClient(), ).to.throw("withSender() cannot be combined with: withMaxTimeout()"); }); it("throws when withSender() is combined with withProxy().", function () { expect(() => - new ClientBuilder(credentials).withSender({ send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }) }).withProxy("localhost", 8080, "http").buildUsStreetApiClient() + new ClientBuilder(credentials) + .withSender({ + send: async () => ({ statusCode: 200, payload: [], error: null, headers: {} }), + }) + .withProxy("localhost", 8080, "http") + .buildUsStreetApiClient(), ).to.throw("withSender() cannot be combined with: withProxy()"); }); @@ -45,7 +55,9 @@ describe("ClientBuilder", function () { }, }; - const client = new ClientBuilder(credentials).withSender(capturingSender).buildUsStreetApiClient(); + const client = new ClientBuilder(credentials) + .withSender(capturingSender) + .buildUsStreetApiClient(); const lookup = new Lookup(); lookup.street = "1 Rosedale"; From 7a442ead63b644604fdc3fe37465f36f6ecb336d Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:41:52 -0600 Subject: [PATCH 12/16] Preserve original error messages in StatusCodeSender for network and unknown errors - Handle statusCode 0 (network errors) explicitly, keeping the original error (e.g. "fetch failed") instead of overwriting with "unexpected error" - Fall back to the existing error message in the default case when the response payload has no API error details --- src/StatusCodeSender.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/StatusCodeSender.ts b/src/StatusCodeSender.ts index f32b301..43a6d2d 100644 --- a/src/StatusCodeSender.ts +++ b/src/StatusCodeSender.ts @@ -20,6 +20,10 @@ export default class StatusCodeSender { .then(resolve) .catch((error: Response) => { switch (error.statusCode) { + case 0: + error.error = error.error ?? new DefaultError("Network error: unable to connect."); + break; + case 500: error.error = new InternalServerError(); break; @@ -36,7 +40,8 @@ export default class StatusCodeSender { const payload = error.payload as { errors?: { message?: string }[]; } | null; - error.error = new DefaultError(payload?.errors?.[0]?.message); + const message = payload?.errors?.[0]?.message; + error.error = new DefaultError(message ?? error.error?.message); } } reject(error); From e5aaa57d3641d043343d54902e64b74a139c96bd Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:48:08 -0600 Subject: [PATCH 13/16] Re-throw error responses in RetrySender after retries are exhausted trySend() intentionally converts thrown error responses to return values so the retry loop can inspect statusCode and decide whether to retry. But after the loop, error responses were silently returned through the success path. Now re-throws if the response has an error or still carries a retryable status code. --- src/RetrySender.ts | 2 ++ tests/test_RetrySender.ts | 26 +++++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/RetrySender.ts b/src/RetrySender.ts index 20a6676..1775adb 100644 --- a/src/RetrySender.ts +++ b/src/RetrySender.ts @@ -41,6 +41,8 @@ export default class RetrySender { response = await this.trySend(request); } + if (response.error || this.statusToRetry.includes(response.statusCode)) throw response; + return response; } diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 1e45812..110a3df 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -43,12 +43,16 @@ describe("Retry Sender tests", function () { it("test return response if retry limit exceeded", async function () { const inner = new MockSenderWithStatusCodesAndHeaders([500, 500, 500, 500, 500]); const sleeper = new MockSleeper(); - const response = await sendWithRetry(4, inner, sleeper); - expect(response); - expect(inner.currentStatusCodeIndex).to.equal(5); - expect(response.statusCode).to.equal(500); - expect(sleeper.sleepDurations).to.deep.equal([0, 1, 2, 3]); + try { + await sendWithRetry(4, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number; error: Error }; + expect(inner.currentStatusCodeIndex).to.equal(5); + expect(response.statusCode).to.equal(500); + expect(sleeper.sleepDurations).to.deep.equal([0, 1, 2, 3]); + } }); it("test backoff does not exceed max", async function () { @@ -100,9 +104,13 @@ describe("Retry Sender tests", function () { const inner = new MockSenderWithStatusCodesAndHeaders([429], undefined, "Big Bad"); const sleeper = new MockSleeper(); - const response = await sendWithRetry(10, inner, sleeper); - - expect(response.error).to.be.an.instanceOf(Error); - expect(response.error!.message).to.equal("Big Bad"); + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number; error: Error }; + expect(response.error).to.be.an.instanceOf(Error); + expect(response.error.message).to.equal("Big Bad"); + } }); }); From 5566e6a25c218f33f5424eb5e1ff99cbb1cd1a4e Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:52:17 -0600 Subject: [PATCH 14/16] Validate protocol in withProxy() for plain JS callers Without this guard, calling withProxy(host, port) without a protocol from plain JS produces "undefined://host:port" which silently passes through and fails later in ProxyAgent with a confusing error. --- src/ClientBuilder.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ClientBuilder.ts b/src/ClientBuilder.ts index 04af16d..0e9e121 100644 --- a/src/ClientBuilder.ts +++ b/src/ClientBuilder.ts @@ -111,6 +111,11 @@ export default class ClientBuilder { if (port === undefined) { this.proxy = { url: hostOrUrl }; } else { + if (!protocol) { + throw new Error( + 'withProxy() requires a protocol (e.g. "http" or "https") when using host/port arguments.', + ); + } let auth = ""; if (username && password) { auth = `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`; From 5217dd7b3f1b2a873162e11964b442044dd8331f Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:56:49 -0600 Subject: [PATCH 15/16] Simplify RetrySender post-loop error check to cover all error responses Replace the compound check (response.error || statusToRetry.includes) with a straightforward status code check: throw if the response is not a success. This also fixes non-retryable errors like 422 that were silently returning through the success path. --- src/RetrySender.ts | 2 +- tests/test_RetrySender.ts | 36 ++++++++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/RetrySender.ts b/src/RetrySender.ts index 1775adb..a608b32 100644 --- a/src/RetrySender.ts +++ b/src/RetrySender.ts @@ -41,7 +41,7 @@ export default class RetrySender { response = await this.trySend(request); } - if (response.error || this.statusToRetry.includes(response.statusCode)) throw response; + if (!response.statusCode || response.statusCode >= 400) throw response; return response; } diff --git a/tests/test_RetrySender.ts b/tests/test_RetrySender.ts index 110a3df..67429c8 100644 --- a/tests/test_RetrySender.ts +++ b/tests/test_RetrySender.ts @@ -28,9 +28,15 @@ describe("Retry Sender tests", function () { it("test client error does not retry", async function () { const inner = new MockSenderWithStatusCodesAndHeaders([422]); - await sendWithRetry(5, inner, new MockSleeper()); - expect(inner.currentStatusCodeIndex).to.equal(1); + try { + await sendWithRetry(5, inner, new MockSleeper()); + expect.fail("Expected an error to be thrown"); + } catch (error) { + const response = error as { statusCode: number }; + expect(response.statusCode).to.equal(422); + expect(inner.currentStatusCodeIndex).to.equal(1); + } }); it("test will retry until success", async function () { @@ -68,9 +74,13 @@ describe("Retry Sender tests", function () { it("test empty status does not retry", async function () { const inner = new MockSenderWithStatusCodesAndHeaders([]); - await sendWithRetry(5, inner, new MockSleeper()); - expect(inner.currentStatusCodeIndex).to.equal(1); + try { + await sendWithRetry(5, inner, new MockSleeper()); + expect.fail("Expected an error to be thrown"); + } catch { + expect(inner.currentStatusCodeIndex).to.equal(1); + } }); it("test sleep on rate limit", async function () { @@ -86,18 +96,24 @@ describe("Retry Sender tests", function () { const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "7" }); const sleeper = new MockSleeper(); - await sendWithRetry(10, inner, sleeper); - - expect(sleeper.sleepDurations).to.deep.equal([7]); + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch { + expect(sleeper.sleepDurations).to.deep.equal([7]); + } }); it("test retry after invalid value", async function () { const inner = new MockSenderWithStatusCodesAndHeaders([429], { "retry-after": "a" }); const sleeper = new MockSleeper(); - await sendWithRetry(10, inner, sleeper); - - expect(sleeper.sleepDurations).to.deep.equal([10]); + try { + await sendWithRetry(10, inner, sleeper); + expect.fail("Expected an error to be thrown"); + } catch { + expect(sleeper.sleepDurations).to.deep.equal([10]); + } }); it("test retry error", async function () { From 2e33b2ad0ddc5ecf2ec0911515e4d1e97f322742 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Thu, 2 Apr 2026 17:57:28 -0600 Subject: [PATCH 16/16] prettier --- tests/test_HttpSender.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_HttpSender.ts b/tests/test_HttpSender.ts index 17df024..121d33c 100644 --- a/tests/test_HttpSender.ts +++ b/tests/test_HttpSender.ts @@ -103,7 +103,10 @@ describe("A fetch-based HTTP sender", function () { let capturedInit: RequestInit | undefined; const capturingFetch: typeof fetch = async (_url, init) => { capturedInit = init; - return new Response(JSON.stringify(null), { status: 200, headers: { "content-type": "application/json" } }); + return new Response(JSON.stringify(null), { + status: 200, + headers: { "content-type": "application/json" }, + }); }; const sender = new HttpSender(10000, { url: "http://proxy:8080" }, false, capturingFetch);