Skip to content

Commit 20a6ca2

Browse files
authored
Merge pull request #159 from script-development/war-room/wr0049-fs-http-r2-r3-hardening
fs-http: document + pin the middleware contract (R2/R3, no runtime change)
2 parents 58b3a40 + 5d1bc7f commit 20a6ca2

4 files changed

Lines changed: 264 additions & 0 deletions

File tree

packages/http/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ Every registered middleware body is wrapped in `guarded()` **by default** (ADR-0
7070
- `registerResponseErrorMiddleware(fn, opts?)` — Returns unregister function
7171
- `guarded(fn, onError?)` — Manual middleware-body guard; still exported for the `{guard: false}` + manual-wrap case
7272

73+
#### Middleware contract
74+
75+
The behaviours below are the full middleware contract — pinned by `tests/middleware-contract.spec.ts` (and the guard-by-default suite in `tests/http.spec.ts`), so a regression fails CI. Written against fs-http **0.6.0** (guard-by-default, ADR-0037).
76+
77+
- **Guarded by default; opt out per call.** Every registered body is wrapped in `guarded()` internally, so a **synchronous** throw is loud-swallowed (`onMiddlewareError` / `console.error`) and cannot reject a resolved 200 nor mask the real API error. Register with `{guard: false}` to run the raw body unguarded — then a sync throw propagates: it rejects the request on the success paths, and on the error path it replaces (masks) the original `AxiosError`.
78+
- **Sync-only, fire-and-forget.** The interceptor loops run each middleware synchronously and are **never `await`ed**. A middleware body that returns a Promise is not awaited — a never-resolving body does not block response delivery, and post-`await` work in an async body races the caller's continuation. Keep middleware bodies synchronous; treat any async work as detached side-channel work.
79+
- **`guarded()` catches synchronous throws only — NOT async rejections.** Because the loop does not await and `guarded()`'s `try`/`catch` is synchronous, a rejected Promise returned by an async-bodied middleware is **not** caught by the guard and does **not** reach `onMiddlewareError`; it surfaces as an unhandled promise rejection (unchanged by ADR-0037). This is the one failure mode guard-by-default does not close — another reason to keep bodies synchronous.
80+
- **FIFO execution order.** Middleware runs in registration order, deterministically, for both the request and the response paths.
81+
- **Per-instance scoping.** Each `createHttpService(...)` owns independent middleware stacks. A middleware registered on service A never fires for service B. Pass one service instance around to share a stack; create multiple to isolate them.
82+
- **Registration is not idempotent.** Registering the same function reference twice fires it twice per request/response. Each registration returns its own `unregister` — call each to remove that registration; one `unregister()` removes only its own copy.
83+
- **Mutation is visible down the chain; response objects are not reused.** All middleware on one response receive the same response object, so a mutation from an earlier middleware is visible to a later one. Axios constructs a fresh response object per request, so a mutation on one response does not bleed into the next.
84+
7385
### Utilities
7486

7587
- `isAxiosError<T>(error)` — Type-safe axios error check

packages/http/src/types.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,46 @@ export type HttpService = {
7777
* to render and `URL.revokeObjectURL(...)` on cleanup.
7878
*/
7979
previewRequest: (endpoint: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<Blob>>;
80+
/**
81+
* Register a request middleware. The body runs synchronously in FIFO order
82+
* before each request leaves the service. See the shared middleware contract
83+
* below — the same invariants govern all three `register*` functions.
84+
*
85+
* Middleware contract (fs-http 0.6.0, ADR-0037; pinned by
86+
* `tests/middleware-contract.spec.ts`):
87+
* - **Guarded by default.** The body is wrapped in `guarded()`, so a
88+
* synchronous throw is loud-swallowed (`onMiddlewareError` / `console.error`)
89+
* and cannot reject a resolved 200 nor mask the real API error. Pass
90+
* `{guard: false}` to run the raw body — then a sync throw propagates.
91+
* - **Sync-only, fire-and-forget.** The loop never `await`s the body. A
92+
* Promise-returning body is not awaited; `guarded()` catches sync throws
93+
* only — an async rejection escapes as an unhandled rejection. Keep bodies
94+
* synchronous.
95+
* - **FIFO, per-instance, not idempotent.** Runs in registration order;
96+
* each service instance owns independent stacks; registering the same
97+
* reference twice fires it twice.
98+
* - **Mutation visible down the chain; response objects are not reused.**
99+
* @returns an idempotent `unregister` that removes this registration.
100+
*/
80101
registerRequestMiddleware: (fn: RequestMiddlewareFunc, opts?: RegisterMiddlewareOptions) => UnregisterMiddleware;
102+
/**
103+
* Register a response (success-path) middleware. Runs synchronously in FIFO
104+
* order on every resolved response, before the caller's `await` resumes. Same
105+
* middleware contract as {@link HttpService.registerRequestMiddleware}
106+
* (guarded-by-default, sync-only/fire-and-forget, FIFO, per-instance,
107+
* not idempotent, mutation-visible, response-not-reused).
108+
* @returns an idempotent `unregister` that removes this registration.
109+
*/
81110
registerResponseMiddleware: (fn: ResponseMiddlewareFunc, opts?: RegisterMiddlewareOptions) => UnregisterMiddleware;
111+
/**
112+
* Register a response-error middleware. Runs synchronously in FIFO order for
113+
* **axios errors only** (non-axios errors reject without invoking any error
114+
* middleware). Guarded by default: with the guard, a throwing body cannot
115+
* mask the original `AxiosError`; under `{guard: false}` a sync throw
116+
* replaces it. Same middleware contract as
117+
* {@link HttpService.registerRequestMiddleware}.
118+
* @returns an idempotent `unregister` that removes this registration.
119+
*/
82120
registerResponseErrorMiddleware: (
83121
fn: ResponseErrorMiddlewareFunc,
84122
opts?: RegisterMiddlewareOptions,

packages/http/tests/guarded.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ describe('guarded', () => {
9191
expect(onError).toHaveBeenCalledWith('string failure');
9292
});
9393

94+
it('is synchronous-only — a body returning a rejected promise is NOT caught (no onError)', () => {
95+
// Arrange — guarded()'s try/catch is synchronous. A body that returns a
96+
// rejected promise (i.e. an `async () => { throw }` body from the loop's
97+
// perspective) does NOT throw synchronously, so the catch never fires.
98+
// The promise is pre-handled so it never floats as an unhandled rejection
99+
// in the runner — this is a leak-safe stand-in for the async body, not a
100+
// suppression of the behavior under test.
101+
const onError = vi.fn<(error: unknown) => void>();
102+
const rejected = Promise.reject(new Error('async body'));
103+
rejected.catch(() => {});
104+
const wrapped = guarded<string>(() => rejected, onError);
105+
106+
// Act
107+
wrapped('x');
108+
109+
// Assert — the async rejection is invisible to guarded's sync try/catch.
110+
expect(onError).not.toHaveBeenCalled();
111+
});
112+
94113
describe('default onError', () => {
95114
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
96115

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type {AxiosResponse} from 'axios';
2+
3+
import axios from 'axios';
4+
import MockAdapter from 'axios-mock-adapter';
5+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
6+
7+
import type {ResponseMiddlewareFunc} from '../src/types';
8+
9+
import {createHttpService} from '../src/index';
10+
11+
// Contract pins for the fs-http middleware mechanism. Each test locks one
12+
// documented invariant (README §Middleware contract / the register* JSDoc) so a
13+
// future regression fails CI rather than silently drifting. Written against
14+
// fs-http 0.6.0 (guard-by-default, ADR-0037): throw-isolation itself is pinned
15+
// in http.spec.ts §"middleware guarding by default"; this file pins the
16+
// invariants that survive UNCHANGED across ADR-0037 — ordering, per-instance
17+
// scoping, non-idempotency, mutation visibility, response-object freshness, and
18+
// the sync-only / fire-and-forget execution shape (including the one gap
19+
// guard-by-default does NOT close: async rejections).
20+
21+
const BASE_URL = 'https://api.example.com';
22+
23+
let mock: MockAdapter;
24+
25+
beforeEach(() => {
26+
mock = new MockAdapter(axios);
27+
mock.onGet(/.*/).reply(200, {ok: true});
28+
});
29+
30+
afterEach(() => {
31+
mock.restore();
32+
});
33+
34+
describe('fs-http middleware contract', () => {
35+
describe('ordering', () => {
36+
it('runs response middleware in FIFO registration order', async () => {
37+
// Arrange
38+
const service = createHttpService(BASE_URL);
39+
const order: number[] = [];
40+
service.registerResponseMiddleware(() => order.push(1));
41+
service.registerResponseMiddleware(() => order.push(2));
42+
service.registerResponseMiddleware(() => order.push(3));
43+
44+
// Act
45+
await service.getRequest('/test');
46+
47+
// Assert — insertion order preserved on the response path (request path
48+
// is pinned in http.spec.ts; both share the same iteration shape).
49+
expect(order).toEqual([1, 2, 3]);
50+
});
51+
});
52+
53+
describe('per-instance scoping', () => {
54+
it('scopes middleware to the service instance it was registered on', async () => {
55+
// Arrange — two independent services; middleware only on A.
56+
const serviceA = createHttpService(BASE_URL);
57+
const serviceB = createHttpService(BASE_URL);
58+
const onA = vi.fn();
59+
serviceA.registerResponseMiddleware(onA);
60+
61+
// Act — a request through B must not touch A's stack.
62+
await serviceB.getRequest('/test');
63+
64+
// Assert
65+
expect(onA).not.toHaveBeenCalled();
66+
67+
// Act — a request through A does fire A's middleware.
68+
await serviceA.getRequest('/test');
69+
70+
// Assert
71+
expect(onA).toHaveBeenCalledTimes(1);
72+
});
73+
});
74+
75+
describe('non-idempotent registration', () => {
76+
it('fires a body once per registration — the same reference registered twice fires twice', async () => {
77+
// Arrange — each register* call wraps fn in a fresh guarded() wrapper,
78+
// so both registrations are live and both invoke fn.
79+
const service = createHttpService(BASE_URL);
80+
const fn = vi.fn();
81+
const unregisterFirst = service.registerResponseMiddleware(fn);
82+
service.registerResponseMiddleware(fn);
83+
84+
// Act
85+
await service.getRequest('/first');
86+
87+
// Assert — not deduped: two registrations => two calls.
88+
expect(fn).toHaveBeenCalledTimes(2);
89+
90+
// Act — one unregister removes only its own copy; the other stays live.
91+
unregisterFirst();
92+
fn.mockClear();
93+
await service.getRequest('/second');
94+
95+
// Assert
96+
expect(fn).toHaveBeenCalledTimes(1);
97+
});
98+
});
99+
100+
describe('mutation semantics', () => {
101+
it('makes a mutation from one middleware visible to a later middleware on the same response', async () => {
102+
// Arrange — same response object is threaded through the whole chain.
103+
const service = createHttpService(BASE_URL);
104+
let sawInjected: string | undefined;
105+
service.registerResponseMiddleware((response) => {
106+
(response as AxiosResponse & {injected?: string}).injected = 'from-A';
107+
});
108+
service.registerResponseMiddleware((response) => {
109+
sawInjected = (response as AxiosResponse & {injected?: string}).injected;
110+
});
111+
112+
// Act
113+
await service.getRequest('/test');
114+
115+
// Assert
116+
expect(sawInjected).toBe('from-A');
117+
});
118+
119+
it('does not reuse the response object across responses — a mutation does not bleed into the next request', async () => {
120+
// Pins axios's own per-request object-identity guarantee, not an fs-http
121+
// invariant — if this ever fails, triage upstream (axios), not src/http.ts.
122+
// Arrange
123+
const service = createHttpService(BASE_URL);
124+
const seen: AxiosResponse[] = [];
125+
service.registerResponseMiddleware((response) => {
126+
(response as AxiosResponse & {marked?: boolean}).marked = true;
127+
seen.push(response);
128+
});
129+
130+
// Act
131+
await service.getRequest('/first');
132+
await service.getRequest('/second');
133+
134+
// Assert — axios hands back a fresh response object per request.
135+
expect(seen).toHaveLength(2);
136+
expect(seen[0]).not.toBe(seen[1]);
137+
});
138+
});
139+
140+
describe('execution timing (sync-only, fire-and-forget)', () => {
141+
it('completes the synchronous middleware loop before the caller await resumes', async () => {
142+
// Arrange
143+
const service = createHttpService(BASE_URL);
144+
let ranDuringLoop = false;
145+
service.registerResponseMiddleware(() => {
146+
ranDuringLoop = true;
147+
});
148+
149+
// Act
150+
const response = await service.getRequest('/test');
151+
152+
// Assert — by the time the caller resumes, the loop has run to completion.
153+
expect(ranDuringLoop).toBe(true);
154+
expect(response.status).toBe(200);
155+
});
156+
157+
it('does not await an async middleware body — a never-resolving body does not block response delivery', async () => {
158+
// Arrange — a body that returns a forever-pending promise. If the
159+
// interceptor awaited middleware, getRequest would hang; it resolves
160+
// because the loop is fire-and-forget. The promise never rejects, so
161+
// there is no unhandled rejection.
162+
const service = createHttpService(BASE_URL);
163+
const neverResolves: ResponseMiddlewareFunc = () => new Promise<void>(() => {});
164+
service.registerResponseMiddleware(neverResolves);
165+
166+
// Act
167+
const response = await service.getRequest('/test');
168+
169+
// Assert
170+
expect(response.status).toBe(200);
171+
});
172+
173+
it('does NOT catch an async rejection — guard-by-default is sync-only; the request still resolves', async () => {
174+
// Arrange — models an `async () => { throw }` body's rejected RETURN.
175+
// guarded()'s try/catch is synchronous, so it never observes this
176+
// rejection: onMiddlewareError does not fire and the request resolves.
177+
// The rejection is pre-handled so the test runner never sees a floating
178+
// unhandled rejection — this is a leak-safe stand-in for the real
179+
// async body, not a suppression of the behavior under test.
180+
const onMiddlewareError = vi.fn();
181+
const service = createHttpService(BASE_URL, {onMiddlewareError});
182+
const rejected = Promise.reject(new Error('async middleware boom'));
183+
rejected.catch(() => {});
184+
const asyncRejecting: ResponseMiddlewareFunc = () => rejected;
185+
service.registerResponseMiddleware(asyncRejecting);
186+
187+
// Act
188+
const response = await service.getRequest('/test');
189+
190+
// Assert — the async rejection is invisible to guard-by-default.
191+
expect(response.status).toBe(200);
192+
expect(onMiddlewareError).not.toHaveBeenCalled();
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)