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 4a621d2..53e2d3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ 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. +- "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. + +### 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..c772f74 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,53 @@ 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 }); +``` + +## 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 | 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,