From a0e8e15e98116abd807d852daa3c60f9cff7c5bb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:54:25 +0530 Subject: [PATCH 1/3] fix(client): enforce default whole-request timeout of 30s Every request now runs under a timeoutMs deadline (default 30000ms) that bounds the whole logical request including all retry attempts and backoff sleeps, so a stuck daemon can no longer hang consumers. The caller-supplied AbortSignal composes with the deadline via a manual signal combiner (AbortSignal.any needs Node 20.3; engines is >=18) and its abort reason propagates unchanged. Deadlines reject with a TimeoutError DOMException; aborts are never retried. timeoutMs: 0 disables the deadline. --- CHANGELOG.md | 3 + README.md | 30 ++++++++ src/client.ts | 132 +++++++++++++++++++++++++++++++-- test/client.test.ts | 176 +++++++++++++++++++++++++++++++++++++++++++- test/helpers.ts | 35 +++++++++ 5 files changed, 370 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a621d2..0e6e24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `HawkClient` now enforces a whole-request deadline (`timeoutMs`, default 30000ms) on every API call, including all retry attempts and backoff sleeps, so a stuck daemon can no longer hang consumers indefinitely. Deadlines reject with a `TimeoutError` `DOMException`; caller-supplied `AbortSignal`s compose with the deadline and propagate their own reason. Pass `timeoutMs: 0` to disable. + ## [0.1.0] — 2026-07-25 ### Added diff --git a/README.md b/README.md index bfa1b2a..4ce1b0c 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,36 @@ Idempotent requests (GET/DELETE) retry on 429/500/502/503/504. Non-idempotent requests (POST `/v1/chat`) retry only on 429, since a 5xx may mean the daemon already began processing the request. +## Timeouts + +Every request has a whole-request deadline of `timeoutMs` milliseconds +(default: `30000`), so a hung daemon can never block a caller indefinitely. +The deadline is a *logical-request* deadline: it bounds the entire request, +including every retry attempt and backoff sleep — retries never extend it. +When it elapses, the call rejects with a `DOMException` whose `name` is +`"TimeoutError"` (the `APIError` hierarchy covers HTTP status errors; +transport-level aborts propagate as-is): + +```ts +try { + await client.chat({ prompt: "Hello!" }); +} catch (err) { + if (err instanceof Error && err.name === "TimeoutError") { + console.log("daemon did not respond in time"); + } +} +``` + +A per-call `AbortSignal` composes with the deadline: whichever fires first +aborts the request, and the caller's own abort reason propagates unchanged. +For `chatStream`, the deadline covers obtaining the SSE response; consuming +the returned stream is caller-controlled. Disable the deadline with +`timeoutMs: 0`: + +```ts +const client = new HawkClient({ timeoutMs: 5000 }); +``` + ## API reference | Method | Description | diff --git a/src/client.ts b/src/client.ts index 9bc7f79..3d24900 100644 --- a/src/client.ts +++ b/src/client.ts @@ -41,6 +41,12 @@ import { userAgent } from "./version.js"; /** defaultBaseURL is the daemon address used when none is configured. */ export const defaultBaseURL = "http://127.0.0.1:4590"; +/** + * defaultTimeoutMs is the whole-request deadline applied to every API call + * when `timeoutMs` is not configured (mirrors hawk-sdk-python's 30s default). + */ +export const defaultTimeoutMs = 30000; + /** ClientOptions configures the HawkClient. */ export interface ClientOptions { /** baseURL sets the daemon base URL (default: http://127.0.0.1:4590). */ @@ -52,6 +58,26 @@ export interface ClientOptions { * performs no retries by default; pass defaultRetryConfig() for production. */ retry?: RetryConfig; + /** + * timeoutMs sets a whole-request deadline in milliseconds + * (default: 30000). The deadline is a *logical-request* deadline: it bounds + * the entire request including every retry attempt and backoff sleep, so + * retries never extend it. When the deadline elapses, the in-flight fetch + * is aborted and the call rejects with the signal's abort reason — a + * `DOMException` whose `name` is `"TimeoutError"` (the platform's standard + * timeout error; the `APIError` hierarchy only covers HTTP status errors, + * transport-level aborts propagate as-is). Distinguish it from caller + * cancellation by checking `err.name === "TimeoutError"`. + * + * A caller-supplied `AbortSignal` composes with the deadline: whichever + * fires first aborts the request, and the caller's own abort reason is + * propagated unchanged. For `chatStream`, the deadline covers obtaining + * the response (SSE headers); consuming the returned stream is + * caller-controlled. For `chatWithTools`, each round's HTTP request gets + * its own deadline; tool execution is not bounded. Pass `0` to disable + * the deadline entirely. + */ + timeoutMs?: number; /** fetch overrides the fetch implementation (useful for testing). */ fetch?: typeof fetch; } @@ -73,12 +99,14 @@ export class HawkClient { private readonly baseURL: string; private readonly apiKey: string; private readonly retry?: RetryConfig; + private readonly timeoutMs: number; private readonly fetchImpl: typeof fetch; constructor(opts: ClientOptions = {}) { this.baseURL = (opts.baseURL ?? defaultBaseURL).replace(/\/+$/, ""); this.apiKey = opts.apiKey ?? ""; this.retry = opts.retry; + this.timeoutMs = opts.timeoutMs ?? defaultTimeoutMs; this.fetchImpl = opts.fetch ?? fetch; } @@ -353,6 +381,11 @@ export class HawkClient { * send executes a request, applying retry logic when a RetryConfig is set. * `idempotent` must be false for requests that are not safe to blindly * resend after a 5xx (e.g. POST /v1/chat). + * + * `timeoutMs` is a whole-request deadline: one signal composed from the + * caller's signal and the deadline governs every attempt and every backoff + * sleep, so the retry loop can never extend the deadline. Aborts (caller + * cancellation or deadline exceeded) are never retried. */ private async send( method: string, @@ -361,15 +394,18 @@ export class HawkClient { idempotent: boolean, ): Promise { const cfg = this.retry; + const deadline = + this.timeoutMs > 0 ? deadlineSignal(this.timeoutMs) : undefined; + const signal = composeSignals(init.signal, deadline); const requestInit: RequestInit = { method, headers: init.headers, body: init.body, - signal: init.signal, + signal, }; if (!cfg) { - return await this.fetchImpl(url, requestInit); + return await this.fetchOnce(url, requestInit, signal); } let lastResp: Response | undefined; @@ -378,12 +414,16 @@ export class HawkClient { for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) { let resp: Response; try { - resp = await this.fetchImpl(url, requestInit); + resp = await this.fetchOnce(url, requestInit, signal); } catch (err) { + // Aborted (caller cancellation or deadline exceeded) — never retry. + if (signal?.aborted) { + throw err; + } // Network error — retryable. lastErr = err; if (attempt < cfg.maxRetries) { - await sleep(backoffDurationMs(cfg, attempt), init.signal); + await sleep(backoffDurationMs(cfg, attempt), signal); continue; } throw lastErr; @@ -411,7 +451,7 @@ export class HawkClient { } // Drain body before retry to allow connection reuse. await resp.body?.cancel().catch(() => {}); - await sleep(backoff, init.signal); + await sleep(backoff, signal); continue; } } @@ -421,6 +461,88 @@ export class HawkClient { } throw lastErr; } + + /** + * fetchOnce runs a single attempt. When the request's signal is aborted, + * the rejection is normalized to the signal's abort reason — a + * `TimeoutError` `DOMException` for an elapsed deadline, or the caller's + * own reason for cancellation — regardless of how the runtime's fetch + * reports aborts. + */ + private async fetchOnce( + url: string, + requestInit: RequestInit, + signal?: AbortSignal, + ): Promise { + try { + return await this.fetchImpl(url, requestInit); + } catch (err) { + if (signal?.aborted) { + throw signal.reason ?? err; + } + throw err; + } + } +} + +/** + * deadlineSignal returns a signal that aborts with a `TimeoutError` + * `DOMException` after `ms` milliseconds. The timer is unref'd so a pending + * deadline never keeps the Node event loop alive. + */ +function deadlineSignal(ms: number): AbortSignal { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort( + new DOMException( + `hawk-sdk: request timed out after ${ms}ms`, + "TimeoutError", + ), + ); + }, ms); + timer.unref?.(); + return controller.signal; +} + +/** + * composeSignals returns a signal that aborts when either input aborts, + * propagating the reason of whichever fired first. This is a manual + * implementation of `AbortSignal.any()` (added in Node 20.3) so the SDK + * keeps working on Node 18, per package.json `engines`. Listeners are + * removed as soon as either signal fires. + */ +function composeSignals( + caller: AbortSignal | undefined, + deadline: AbortSignal | undefined, +): AbortSignal | undefined { + if (!caller) { + return deadline; + } + if (!deadline) { + return caller; + } + if (caller.aborted) { + return caller; + } + if (deadline.aborted) { + return deadline; + } + const controller = new AbortController(); + const onCallerAbort = () => { + cleanup(); + controller.abort(caller.reason); + }; + const onDeadlineAbort = () => { + cleanup(); + controller.abort(deadline.reason); + }; + const cleanup = () => { + caller.removeEventListener("abort", onCallerAbort); + deadline.removeEventListener("abort", onDeadlineAbort); + }; + caller.addEventListener("abort", onCallerAbort, { once: true }); + deadline.addEventListener("abort", onDeadlineAbort, { once: true }); + return controller.signal; } function paginationParams(opts?: ListOptions): URLSearchParams | undefined { diff --git a/test/client.test.ts b/test/client.test.ts index 2b8d3c1..9744990 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -5,8 +5,10 @@ import { HawkClient, NotFoundError, AuthenticationError, + defaultRetryConfig, + defaultTimeoutMs, } from "../src/index.js"; -import { json, startServer } from "./helpers.js"; +import { json, startServer, startStalledServer } from "./helpers.js"; test("health returns daemon status", async () => { const server = await startServer((req, res) => { @@ -293,3 +295,175 @@ test("maps 401 to AuthenticationError", async () => { await server.close(); } }); + +// --- timeouts ------------------------------------------------------------ + +test("defaultTimeoutMs is 30000", () => { + assert.equal(defaultTimeoutMs, 30000); +}); + +test( + "GET rejects with TimeoutError when the daemon never responds", + { timeout: 5000 }, + async () => { + const server = await startStalledServer(); + try { + const client = new HawkClient({ baseURL: server.url, timeoutMs: 150 }); + const start = Date.now(); + await assert.rejects( + () => client.health(), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + assert.match(err.message, /timed out after 150ms/); + return true; + }, + ); + const elapsed = Date.now() - start; + assert.ok( + elapsed < 2000, + `expected rejection shortly after 150ms, took ${elapsed}ms`, + ); + } finally { + await server.close(); + } + }, +); + +test( + "POST rejects with TimeoutError when the daemon never responds", + { timeout: 5000 }, + async () => { + const server = await startStalledServer(); + try { + const client = new HawkClient({ baseURL: server.url, timeoutMs: 150 }); + await assert.rejects( + () => client.chat({ prompt: "Hello!" }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + return true; + }, + ); + } finally { + await server.close(); + } + }, +); + +test( + "chatStream rejects with TimeoutError when the daemon never responds", + { timeout: 5000 }, + async () => { + const server = await startStalledServer(); + try { + const client = new HawkClient({ baseURL: server.url, timeoutMs: 150 }); + await assert.rejects( + () => client.chatStream({ prompt: "Hello!" }), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + return true; + }, + ); + } finally { + await server.close(); + } + }, +); + +test( + "caller-supplied AbortSignal aborts immediately with its own reason", + { timeout: 5000 }, + async () => { + const server = await startStalledServer(); + try { + const client = new HawkClient({ baseURL: server.url, timeoutMs: 30000 }); + const controller = new AbortController(); + const reason = new Error("caller cancelled"); + const pending = client.health(controller.signal); + setTimeout(() => controller.abort(reason), 50); + const start = Date.now(); + await assert.rejects( + () => pending, + (err: unknown) => { + // The caller's abort reason propagates unchanged. + assert.equal(err, reason); + return true; + }, + ); + const elapsed = Date.now() - start; + assert.ok( + elapsed < 2000, + `expected prompt cancellation, took ${elapsed}ms`, + ); + } finally { + await server.close(); + } + }, +); + +test( + "timeout bounds the whole logical request including retries", + { timeout: 5000 }, + async () => { + let hits = 0; + const server = await startServer((_req, res) => { + hits++; + // 429 is retryable and Retry-After forces a deterministic 10s backoff + // before the second attempt. + json(res, 429, { error: "slow down" }, { "Retry-After": "10" }); + }); + try { + const client = new HawkClient({ + baseURL: server.url, + timeoutMs: 150, + retry: { ...defaultRetryConfig(), maxRetries: 5 }, + }); + const start = Date.now(); + await assert.rejects( + () => client.health(), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "TimeoutError"); + return true; + }, + ); + const elapsed = Date.now() - start; + // The deadline must cut the 10s backoff sleep, not reset per attempt. + assert.ok( + elapsed < 2000, + `expected deadline to fire during backoff, took ${elapsed}ms`, + ); + assert.equal( + hits, + 1, + "no second attempt should be sent after the deadline", + ); + } finally { + await server.close(); + } + }, +); + +test("timeoutMs 0 disables the deadline", async () => { + // A stalled server plus timeoutMs: 0 must not reject on its own; the + // caller's signal still applies. + const server = await startStalledServer(); + try { + const client = new HawkClient({ baseURL: server.url, timeoutMs: 0 }); + const controller = new AbortController(); + const pending = client.health(controller.signal); + setTimeout(() => controller.abort(), 100); + await assert.rejects( + () => pending, + (err: unknown) => { + assert.ok(err instanceof Error); + assert.equal(err.name, "AbortError"); + return true; + }, + ); + } finally { + await server.close(); + } +}); diff --git a/test/helpers.ts b/test/helpers.ts index 5cf5ebc..7aaff6b 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -10,6 +10,7 @@ import { type ServerResponse, } from "node:http"; import type { AddressInfo } from "node:net"; +import { createServer as createNetServer, type Socket } from "node:net"; export type Handler = ( req: IncomingMessage, @@ -42,6 +43,40 @@ export async function startServer(handler: Handler): Promise { }; } +/** + * startStalledServer launches a TCP server that accepts connections and + * reads requests but never responds — a daemon hang at the socket level. + * close() destroys any still-open sockets so the test server always shuts + * down promptly. + */ +export async function startStalledServer(): Promise { + const sockets = new Set(); + const server = createNetServer((socket) => { + sockets.add(socket); + socket.on("data", () => { + // Read and discard the request; never respond. + }); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", () => { + // Client-side aborts surface here; ignore. + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${addr.port}`, + close: () => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + return new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + }, + }; +} + /** json writes a JSON response with the given status and headers. */ export function json( res: ServerResponse, From 4d69ee494f2c533a0cbf3c050faa1cea901abdaf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:55:26 +0530 Subject: [PATCH 2/3] ci: add npm release workflow and pin action SHAs Add a release workflow modeled on hawk-sdk-go's tag-triggered release workflow, adapted for npm: setup-node with registry-url, npm publish --provenance with NODE_AUTH_TOKEN, and a softprops/action-gh-release step; permissions include id-token: write for provenance. Pin the floating actions/checkout@v4 and actions/setup-node@v4 tags in ci.yml to their v4.4.0 commit SHAs, resolved and verified via git ls-remote. --- .github/workflows/ci.yml | 8 +++---- .github/workflows/release.yml | 44 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 6 +++++ 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0cad4c..8bac2d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ jobs: name: lint + typecheck + test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm @@ -25,8 +25,8 @@ jobs: name: security runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: npm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8d11162 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +# Release workflow for hawk-sdk-typescript (npm library). +# Triggered by release-please when it pushes a v* tag. +# Modeled on hawk-sdk-go's release workflow, adapted for npm publishing. + +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + id-token: write # OIDC token for npm provenance. + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + registry-url: https://registry.npmjs.org + + # prepublishOnly (package.json) runs clean + build before publishing. + - name: Publish to npm + run: npm publish --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + with: + generate_release_notes: true + draft: false + prerelease: auto + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6e24f..e4dc638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Release workflow (`.github/workflows/release.yml`): publishes to npm with provenance and creates a GitHub Release when a `v*` tag is pushed. + +### Changed +- CI actions are pinned to full commit SHAs instead of floating major tags, matching the sibling SDK repos. + ### Fixed - `HawkClient` now enforces a whole-request deadline (`timeoutMs`, default 30000ms) on every API call, including all retry attempts and backoff sleeps, so a stuck daemon can no longer hang consumers indefinitely. Deadlines reject with a `TimeoutError` `DOMException`; caller-supplied `AbortSignal`s compose with the deadline and propagate their own reason. Pass `timeoutMs: 0` to disable. From 46300a7c74dbc8041dd3ca500f652c796d0b8a00 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 16 Aug 2026 01:56:38 +0530 Subject: [PATCH 3/3] docs: add SDK defaults divergence notes to README Document the actual retry, backoff, jitter, and timeout defaults of the Go, TypeScript, and Python SDKs with file and symbol references, so the drift between them is visible. No code defaults were changed. --- CHANGELOG.md | 1 + README.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dc638..53e2d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Release workflow (`.github/workflows/release.yml`): publishes to npm with provenance and creates a GitHub Release when a `v*` tag is pushed. +- "Defaults & divergences across SDKs" README section documenting how retry, backoff, jitter, and timeout defaults differ between the Go, TypeScript, and Python SDKs. ### Changed - CI actions are pinned to full commit SHAs instead of floating major tags, matching the sibling SDK repos. diff --git a/README.md b/README.md index 4ce1b0c..c772f74 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,23 @@ the returned stream is caller-controlled. Disable the deadline with const client = new HawkClient({ timeoutMs: 5000 }); ``` +## Defaults & divergences across SDKs + +The three Hawk SDKs (Go, TypeScript, Python) share wire behavior but have +drifted in transport defaults. Actual current values: + +| Default | TypeScript (this SDK) | Go | Python | +| --- | --- | --- | --- | +| Retries | **Off** — opt in with `{ retry: defaultRetryConfig() }` (`src/client.ts`) | **Off** — opt in with `WithRetry(DefaultRetryConfig())` (`client.go`) | **On** — `retry_config or DEFAULT_RETRY_CONFIG` (`src/hawk/client.py`) | +| Initial backoff | 1s (`src/retry.ts`, `defaultRetryConfig`) | 1s (`retry.go`, `DefaultRetryConfig`) | 0.5s (`src/hawk/retry.py`, `RetryConfig`) | +| Backoff jitter | Full jitter: `rand(0, backoff)` (`src/retry.ts`, `backoffDurationMs`) | Full jitter: `rand(0, backoff)` (`retry.go`, `backoffDuration`) | Equal + jitter: `backoff + rand(0, backoff/2)` (`src/hawk/retry.py`, `_compute_backoff`) | +| Request timeout | Whole-request deadline, 30s, includes retries (`src/client.ts`, `timeoutMs`) | `ResponseHeaderTimeout: 5s`, headers only (`client.go`) | httpx timeout, 30s (`src/hawk/client.py`, `DEFAULT_TIMEOUT`) | + +Max retries (3), max backoff (30s), retryable statuses (429/500/502/503/504), +and the non-idempotent rule (only 429 is retried for POST `/v1/chat`) are +identical in all three SDKs. This table documents current behavior; it is not +a compatibility contract between the SDKs. + ## API reference | Method | Description |