Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
132 changes: 127 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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,
Expand All @@ -361,15 +394,18 @@ export class HawkClient {
idempotent: boolean,
): Promise<Response> {
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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<Response> {
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 {
Expand Down
Loading
Loading