Skip to content

Commit 8f764bd

Browse files
committed
feat(fs-http): add timeout option with 30000ms default (Doctrine #8 library-author extension)
Closes 3-spy convergent finding 2026-04-30 on fs-http timeout surface absence. Default 30000ms applies when unset; override via options.timeout or per-call AxiosRequestConfig. Pass timeout: 0 to disable (consumer accepts Doctrine #8). Bumps fs-http to 0.2.0 (minor — observable behavior change on previously-unset timeouts). Refs: campaigns/fs-packages/2026-04-30-multi-spy-refresh-wave.md
1 parent 5c09552 commit 8f764bd

7 files changed

Lines changed: 96 additions & 3 deletions

File tree

packages/http/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @script-development/fs-http
22

3+
## 0.2.0 — 2026-04-30
4+
5+
### Minor Changes
6+
7+
- **fs-http**: Adds `timeout?: number` to `HttpServiceOptions` with a 30000ms default. Pass `timeout: 0` to disable (consumer accepts Doctrine #8 responsibility). Behavior change: previously-unset timeouts no longer hang indefinitely. Closes 3-spy convergent finding 2026-04-30.
8+
39
## 0.1.0
410

511
### Minor Changes

packages/http/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ Creates a new HTTP service instance.
4040
- `withCredentials` — Send cookies cross-origin (default: `true`)
4141
- `withXSRFToken` — Include XSRF token header (default: `false`)
4242
- `smartCredentials` — Auto-toggle `withCredentials` based on request host matching base URL host (default: `false`)
43+
- `timeout` — Request timeout in milliseconds (default: `30000`). Pass `0` to disable; pass any positive number to override.
44+
45+
### Timeout
46+
47+
The factory applies a **30000ms (30s) default timeout** to every request. This default is the Armory's compliance posture for the war-room **Doctrine #8 library-author extension** (CLAUDE.md, 2026-04-22):
48+
49+
> Library-author extension (2026-04-22) — Shared HTTP factory packages (e.g., `@script-development/fs-http`) must expose a compliant timeout surface: a default, a required option, or a documented contract plus consumer-level enforcement. Inheriting framework defaults at the library layer silently propagates the violation to every consumer territory.
50+
51+
To override the service-wide default, pass `timeout` in the options:
52+
53+
```typescript
54+
// Tighten for a fast-API service
55+
const http = createHttpService('https://api.example.com', {timeout: 5_000});
56+
```
57+
58+
To disable the default and accept Doctrine #8 responsibility at the consumer layer (e.g., AI streaming endpoints with their own timeout discipline), pass `timeout: 0`:
59+
60+
```typescript
61+
const http = createHttpService('https://ai.example.com', {timeout: 0});
62+
```
63+
64+
Per-request overrides remain available via the existing `AxiosRequestConfig.timeout` parameter on each method:
65+
66+
```typescript
67+
// Service default (30000ms) for most calls; per-call override for the long one
68+
await http.postRequest('/generate-report', payload, {timeout: 120_000});
69+
```
70+
71+
The constant is also exported as `DEFAULT_TIMEOUT_MS` for consumers that want to reference it explicitly.
4372

4473
### Request Methods
4574

packages/http/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-http",
3-
"version": "0.1.3",
3+
"version": "0.2.0",
44
"description": "Framework-agnostic HTTP service factory with middleware architecture",
55
"homepage": "https://packages.script.nl/packages/http",
66
"license": "MIT",

packages/http/src/http.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ const HEADERS_TO_TYPE: Record<string, string> = {
1818
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'application/xlsx',
1919
};
2020

21+
/**
22+
* Default request timeout in milliseconds (30s). Applied when
23+
* `HttpServiceOptions.timeout` is unset. Per Doctrine #8 (library-author
24+
* extension, 2026-04-22) — a shared HTTP factory must expose a compliant
25+
* timeout surface so consumer territories cannot silently inherit
26+
* indefinite hangs.
27+
*/
28+
export const DEFAULT_TIMEOUT_MS = 30_000;
29+
2130
const unregister = <T>(array: T[], item: T): UnregisterMiddleware => {
2231
return () => {
2332
const index = array.indexOf(item);
@@ -33,6 +42,7 @@ export const createHttpService = (baseURL: string, options?: HttpServiceOptions)
3342
withCredentials: options?.withCredentials ?? true,
3443
withXSRFToken: options?.withXSRFToken ?? false,
3544
headers: {Accept: 'application/json', ...options?.headers},
45+
timeout: options?.timeout ?? DEFAULT_TIMEOUT_MS,
3646
});
3747

3848
// Middleware stacks

packages/http/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export {createHttpService} from './http';
1+
export {DEFAULT_TIMEOUT_MS, createHttpService} from './http';
22
export type {
33
HttpService,
44
HttpServiceOptions,

packages/http/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export type HttpServiceOptions = {
1313
withCredentials?: boolean;
1414
withXSRFToken?: boolean;
1515
smartCredentials?: boolean;
16+
/**
17+
* Request timeout in milliseconds. Defaults to 30_000 (30s).
18+
* Set 0 to disable (caller takes responsibility per Doctrine #8).
19+
* Per-request override available via the `AxiosRequestConfig.timeout`
20+
* parameter on each method.
21+
*/
22+
timeout?: number;
1623
};
1724

1825
export type HttpService = {

packages/http/tests/http.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from 'axios';
22
import MockAdapter from 'axios-mock-adapter';
33
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
44

5-
import {createHttpService, isAxiosError} from '../src/index';
5+
import {DEFAULT_TIMEOUT_MS, createHttpService, isAxiosError} from '../src/index';
66

77
const BASE_URL = 'https://api.example.com';
88

@@ -73,6 +73,20 @@ describe('createHttpService', () => {
7373
expect(response.config.withXSRFToken).toBe(false);
7474
expect(response.config.headers.Accept).toBe('application/json');
7575
});
76+
77+
it('applies the 30000ms default timeout when timeout option is unset', async () => {
78+
// Arrange — Doctrine #8 library-author extension: factory must default to
79+
// a compliant timeout so consumers cannot silently inherit indefinite hangs.
80+
mock.onGet(/.*/).reply(200, {});
81+
const service = createHttpService(BASE_URL);
82+
83+
// Act
84+
const response = await service.getRequest('/test');
85+
86+
// Assert
87+
expect(DEFAULT_TIMEOUT_MS).toBe(30_000);
88+
expect(response.config.timeout).toBe(30_000);
89+
});
7690
});
7791

7892
describe('custom options', () => {
@@ -112,6 +126,33 @@ describe('createHttpService', () => {
112126
// Assert
113127
expect(response.config.withXSRFToken).toBe(true);
114128
});
129+
130+
it('overrides the default timeout when timeout option is provided', async () => {
131+
// Arrange
132+
mock.onGet(/.*/).reply(200, {});
133+
const service = createHttpService(BASE_URL, {timeout: 5_000});
134+
135+
// Act
136+
const response = await service.getRequest('/test');
137+
138+
// Assert
139+
expect(response.config.timeout).toBe(5_000);
140+
});
141+
142+
it('disables the timeout when timeout: 0 is provided (kills ?? -> || mutation)', async () => {
143+
// Arrange — `0` is falsy but explicitly defined. With `??` the caller's 0 is
144+
// honored (timeout disabled, consumer accepts Doctrine #8 responsibility).
145+
// With `||` the 0 would be coerced back to DEFAULT_TIMEOUT_MS — this test
146+
// discriminates the two and kills the operator-swap mutation.
147+
mock.onGet(/.*/).reply(200, {});
148+
const service = createHttpService(BASE_URL, {timeout: 0});
149+
150+
// Act
151+
const response = await service.getRequest('/test');
152+
153+
// Assert
154+
expect(response.config.timeout).toBe(0);
155+
});
115156
});
116157

117158
describe('standard request methods', () => {

0 commit comments

Comments
 (0)