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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,24 @@ jobs:
- run: pnpm test:coverage
# Bundle from the committed OpenAPI snapshot (deterministic, no network).
- run: pnpm build:bundle

# The HTTP layer hands an npm `undici` dispatcher to Node's bundled fetch (a different undici
# major inside Node 22). That contract is exactly what a Node major bump can break, so also run
# the suite on the newest supported major. Kept as a separate job so the required check name
# of the primary job stays stable.
compat:
name: Node 24 compatibility
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: pnpm
- run: pnpm install --frozen-lockfile
env:
ASKNEWS_NO_AGENT_SKILLS: "1"
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build:bundle
27 changes: 27 additions & 0 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,30 @@ OpenAPI query parameters and request-body properties share one option generator.
exclude only fields replaced by intentional positional arguments or aliases, and contract tests
prove the remaining schema fields are exposed. SSE responses are decoded incrementally. Human
streaming writes text deltas immediately; JSONL streaming emits complete event objects.

## Request timeouts

The default request timeout is 60,000 ms, or 900,000 ms (15 minutes) for DeepNews research.
`--timeout` takes precedence over `ASKNEWS_TIMEOUT_MS`; either explicit setting overrides the
operation default, including shorter or longer research budgets.

- **Buffered responses:** one deadline per HTTP attempt covers waiting for headers and reading
the entire body. Body chunks do not restart this deadline. The transport's headers timeout and
body **inactivity** timeout are also set to the effective budget; body inactivity alone is not
a total-duration limit. There is no separate five-minute headers/body cap.
- **SSE responses:** the same effective timeout bounds the initial wait and inactivity between
received chunks, including comment-only heartbeats. Activity resets the idle timer, so a healthy
stream can outlive the timeout. A non-SSE fallback is buffered under the existing idle signal.
- **Transport:** a request-scoped Undici dispatcher interceptor sets finite request-level
headers/body timers after native fetch supplies its options, avoiding dependence on whether a
Node version supplies request-level values that override Agent defaults. Connection protections
remain at Undici defaults. No process-wide timeout is reconfigured.
- **Retries and cleanup:** network timeouts do not trigger retries. The existing single OAuth
refresh retry after a 401 remains; its buffered retry gets a fresh per-attempt budget, and token
refresh has its own timeout. Thus this is not one wall-clock cap across authentication and
multiple attempts. An unused 401 body is cancelled before retrying. Request timers, SSE readers,
and the scoped dispatcher's connections are cleaned up on success and failure.

Timeouts and transport failures remain network errors (exit 4), distinct from API refusals
(exit 5 for 4xx, exit 6 for 5xx). A timeout cannot establish whether the server continued work;
retrying an expensive operation manually may incur another charge.
19 changes: 19 additions & 0 deletions docs/runbooks/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,24 @@ Package installation writes the embedded skill under detected agent directories.
home explicitly. Package managers may sanitize lifecycle environments; for a controlled package
install use `npm_config_asknews_agent_home=/controlled/home`.

## Local timeout regression checks

`pnpm check` includes deterministic loopback HTTP tests for dispatched timeout values, total
buffered deadlines, body stalls, SSE heartbeat/idle handling, error types, and cleanup. These
short tests do not claim to wait through the actual five-minute transport boundary.

For the optional real wall-clock proof (about 305 seconds, loopback only, no credentials or
billable API calls), allow at least 360 seconds in your outer process runner:

```bash
ASKNEWS_LOCAL_TIMEOUT_SMOKE=1 pnpm exec vitest run test/integration/http-timeout.integration.test.ts -t wall-clock
```

The smoke compares native fetch with default transport timers (300 seconds)
against the CLI's request-scoped dispatcher for both delayed headers and a delayed body. It prints
runtime versions and measured elapsed times. The fixed calls retain the default 900-second
research budget. Use `pnpm build:bundle` for a local build from the committed schema without
refreshing generated API contracts.

See [Schema refresh](schema-refresh.md) for generated-contract review and
[Private release preparation](release.md) for package verification and approval boundaries.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"cli-table3": "0.6.5",
"commander": "14.0.3",
"terminal-link": "^5.0.0",
"undici": "7.29.1",
"yaml": "2.8.3",
"zod": "3.25.76"
},
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

149 changes: 103 additions & 46 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { Agent, type Dispatcher } from "undici";
import type { CliConfig } from "./config.js";
import { ApiError, NetworkError, UsageError } from "./errors.js";
import { redact } from "./redact.js";
Expand Down Expand Up @@ -39,22 +40,17 @@ export async function executeOperationStream(
// A streamed response stays open for as long as the server keeps producing events, so the
// timeout must bound inactivity, not total duration: it is reset on every received chunk.
const idle = startIdleTimeout(url, timeoutMs);
const dispatcher = createRequestDispatcher(timeoutMs);
const send = (request: RequestInit) =>
sendRequest(url, { ...request, signal: idle.signal }, timeoutMs, dispatcher);
try {
let response = await sendRequest(config, url, { ...init, signal: idle.signal }, timeoutMs);
let response = await send(init);
if (response.status === 401 && refresh) {
idle.reset();
response = await retryWithRefreshedToken(
config,
url,
init,
refresh,
response,
timeoutMs,
idle.signal,
);
response = await retryWithRefreshedToken(init, refresh, response, send);
}
if (!response.ok) {
const text = await readBody(url, timeoutMs, () => response.text());
const text = await readBody(url, timeoutMs, () => response.text(), idle.signal);
const data = parseResponse(text, response.headers.get("content-type"));
const detail = formatErrorDetail(data, response.statusText);
throw new ApiError(
Expand All @@ -65,13 +61,19 @@ export async function executeOperationStream(
}
const body = response.body;
if (!body || !response.headers.get("content-type")?.includes("text/event-stream")) {
const text = await readBody(url, timeoutMs, () => response.text());
const text = await readBody(url, timeoutMs, () => response.text(), idle.signal);
onEvent(parseResponse(text, response.headers.get("content-type")));
return;
}
await readBody(url, timeoutMs, () => consumeServerSentEvents(body, onEvent, idle.reset));
await readBody(
url,
timeoutMs,
() => consumeServerSentEvents(body, onEvent, idle.reset),
idle.signal,
);
} finally {
idle.clear();
await dispatcher.destroy();
}
}

Expand Down Expand Up @@ -236,17 +238,34 @@ async function executeRequest<T>(
refresh?: TokenRefresher,
timeoutMs = config.timeoutMs,
): Promise<ApiResponse<T>> {
let response = await sendRequest(config, url, init, timeoutMs);
if (response.status === 401 && refresh) {
response = await retryWithRefreshedToken(config, url, init, refresh, response, timeoutMs);
}
const text = await readBody(url, timeoutMs, () => response.text());
const data = parseResponse(text, response.headers.get("content-type"));
if (!response.ok) {
const detail = formatErrorDetail(data, response.statusText);
throw new ApiError(`AskNews API returned ${response.status}: ${detail}`, response.status, data);
const dispatcher = createRequestDispatcher(timeoutMs);
let deadline: ReturnType<typeof startRequestTimeout> | undefined;
const send = (request: RequestInit) => {
// Preserve the existing single 401 refresh retry's fresh per-attempt budget.
deadline?.clear();
deadline = startRequestTimeout(timeoutMs);
return sendRequest(url, { ...request, signal: deadline.signal }, timeoutMs, dispatcher);
};
try {
let response = await send(init);
if (response.status === 401 && refresh) {
response = await retryWithRefreshedToken(init, refresh, response, send);
}
const text = await readBody(url, timeoutMs, () => response.text(), deadline?.signal);
const data = parseResponse(text, response.headers.get("content-type"));
if (!response.ok) {
const detail = formatErrorDetail(data, response.statusText);
throw new ApiError(
`AskNews API returned ${response.status}: ${detail}`,
response.status,
data,
);
}
return { data: data as T, headers: response.headers, status: response.status };
} finally {
deadline?.clear();
await dispatcher.destroy();
}
return { data: data as T, headers: response.headers, status: response.status };
}

function formatErrorDetail(data: unknown, statusText: string): string {
Expand All @@ -256,28 +275,61 @@ function formatErrorDetail(data: unknown, statusText: string): string {
return JSON.stringify(detail);
}

// Request-level headers/body timers take precedence over Agent defaults. Set finite
// values at dispatch time, after native fetch has built its (version-dependent) options.
// Scope the dispatcher to one execution; never modify the process-wide dispatcher.
function createRequestDispatcher(timeoutMs: number): Dispatcher {
return new Agent().compose(
(dispatch) => (options, handler) =>
dispatch({ ...options, headersTimeout: timeoutMs, bodyTimeout: timeoutMs }, handler),
);
}

function startRequestTimeout(timeoutMs: number): { signal: AbortSignal; clear: () => void } {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(new DOMException("Request deadline exceeded", "TimeoutError")),
timeoutMs,
);
timer.unref();
return { signal: controller.signal, clear: () => clearTimeout(timer) };
}

async function sendRequest(
config: CliConfig,
url: URL,
init: RequestInit,
timeoutMs = config.timeoutMs,
timeoutMs: number,
dispatcher: Dispatcher,
): Promise<Response> {
try {
return await fetch(url, { signal: AbortSignal.timeout(timeoutMs), ...init });
// Node 22's fetch types reference Undici 6; the dispatcher protocol is compatible
// with Undici 7 (also used by newer Node). Keep the cast at this native-fetch boundary.
const request = {
...init,
dispatcher: dispatcher as unknown as NonNullable<RequestInit["dispatcher"]>,
};
return await fetch(url, request);
} catch (error) {
throw new NetworkError(describeNetworkError(url, timeoutMs, error), redact(error));
const reason = init.signal?.aborted ? init.signal.reason : error;
throw new NetworkError(describeNetworkError(url, timeoutMs, reason), redact(reason));
}
}

// Runs a response-body read (buffered text or SSE consumption), converting abort/network
// failures into the same actionable NetworkError produced for request failures. Without this,
// a timeout firing mid-body surfaces as a bare "The operation was aborted" from undici.
async function readBody<T>(url: URL, timeoutMs: number, read: () => Promise<T>): Promise<T> {
async function readBody<T>(
url: URL,
timeoutMs: number,
read: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
try {
return await read();
} catch (error) {
if (error instanceof ApiError || error instanceof NetworkError) throw error;
throw new NetworkError(describeNetworkError(url, timeoutMs, error), redact(error));
const reason = signal?.aborted ? signal.reason : error;
throw new NetworkError(describeNetworkError(url, timeoutMs, reason), redact(reason));
}
}

Expand Down Expand Up @@ -307,21 +359,20 @@ function startIdleTimeout(
// On a 401, refresh the token once and retry. If the refresh yields no token (no refresh token, or
// the refresh itself fails), keep the original 401 response so the caller surfaces it unchanged.
async function retryWithRefreshedToken(
config: CliConfig,
url: URL,
init: RequestInit,
refresh: TokenRefresher,
unauthorized: Response,
timeoutMs = config.timeoutMs,
signal?: AbortSignal,
send: (init: RequestInit) => Promise<Response>,
): Promise<Response> {
const token = await refresh().catch(() => null);
if (!token) {
return unauthorized;
}
// Do not leave an unread 401 body/socket alive while issuing the one auth retry.
await unauthorized.body?.cancel().catch(() => {});
const headers = new Headers(init.headers);
headers.set("authorization", `Bearer ${token}`);
return sendRequest(config, url, { ...init, headers, ...(signal ? { signal } : {}) }, timeoutMs);
return send({ ...init, headers });
}

function describeNetworkError(url: URL, timeoutMs: number, error: unknown): string {
Expand Down Expand Up @@ -386,20 +437,26 @@ export async function consumeServerSentEvents(
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
onActivity?.();
buffer += decoder.decode(value, { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const event = parseEventBlock(block);
if (event !== undefined) onEvent(event);
try {
while (true) {
const { done, value } = await reader.read();
onActivity?.();
buffer += decoder.decode(value, { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const event = parseEventBlock(block);
if (event !== undefined) onEvent(event);
}
if (done) break;
}
if (done) break;
const finalEvent = parseEventBlock(buffer);
if (finalEvent !== undefined) onEvent(finalEvent);
} finally {
// Callback failures must also cancel the body, not leave a locked, unread stream.
await reader.cancel().catch(() => {});
reader.releaseLock();
}
const finalEvent = parseEventBlock(buffer);
if (finalEvent !== undefined) onEvent(finalEvent);
}

function parseEventBlock(block: string): unknown | undefined {
Expand Down
Loading
Loading