@webex/http-core 3.12.0-next.7 → 3.12.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ ---
2
+ type: Module Spec
3
+ title: 'http-core package surface specification'
4
+ description: Responsibilities, boundaries, design, invariants, and verification for the @webex/http-core package surface.
5
+ tags: [module, specification]
6
+ ---
7
+ <!-- sdd-generated-metadata
8
+ doc_kind: module-spec
9
+ generated_from: module-spec@0.3.0
10
+ generated_by: claude-code
11
+ approved_by: pending
12
+ updated_at: 2026-09-23T06:29:48Z
13
+ validation_status: pass-with-warnings
14
+ -->
15
+
16
+ # http-core package surface
17
+
18
+ This source-local document at `src/docs/README.md` owns the stable specification for the
19
+ **`@webex/http-core` package surface**: client construction, the interceptor extension contract, the
20
+ HTTP error taxonomy, the progress payload, and MIME detection.
21
+
22
+ Request execution itself belongs to the `src/request` sub-module and is specified there; this
23
+ document references it by contract rather than restating it.
24
+
25
+ Related context: [repository architecture](../../docs/architecture.md) ·
26
+ [documentation index](../../docs/index.md) · [agent instructions](../../AGENTS.md) ·
27
+ [specification registry](../../docs/specs/README.md)
28
+
29
+ ## Metadata
30
+
31
+ | Field | Value |
32
+ | ----------------- | ------------------------------------------------------------------------- |
33
+ | Owner | Cisco Webex for Developers |
34
+ | Source path | `src` |
35
+ | Resource kind | Package surface module |
36
+ | Status | Active |
37
+ | Last verified | 2026-09-23 |
38
+ | Module id | `src` |
39
+ | Parent spec | — |
40
+ | Doc kind | Module spec |
41
+ | Coverage score | 93.3% assessed 2026-09-23; 14 of 15 mandatory fields present — a characterization baseline is the one outstanding gap |
42
+ | Validation status | pass-with-warnings; validator `codex` assessed 2026-09-23; 0 Blocking, 1 Important, 1 Medium at repository scope — no Blocking finding against this module; characterization baseline still required for promotion |
43
+
44
+ ## Applicability
45
+
46
+ | Condition ID | Status | Evidence or reason | Owned section |
47
+ | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------- | ----------------------------- |
48
+ | `module.has_tiers` | N/A | The repository assigns no operational or review tiers | Tier |
49
+ | `module.has_ui` | N/A | No components or rendering; the module is a transport library | UI use-case flow |
50
+ | `module.crosses_service_boundaries` | Applicable | The exported clients issue network requests through `src/request/index.js` | Cross-boundary use-case flow |
51
+ | `module.holds_client_state` | N/A | No state store; `src/index.js` builds clients by currying, holding no mutable state between calls | Client state model |
52
+ | `module.enforces_domain_rules` | N/A | No domain entities or invariants; payloads are opaque to this module | Business rules and invariants |
53
+ | `module.is_concurrent_async` | Applicable | The request, protoprepareFetchOptions, setTimingsAndFetch, and detect exports are promise-returning; `src/request/utils.ts` and `src/lib/detect.js` are async and `src/index.js` wraps fetch | Concurrency and reactive flow |
54
+ | `module.owns_persistence` | N/A | No store, schema, or migration | Data, schema, and migration |
55
+ | `module.stateful_transitions` | N/A | Stateless per call; no lifecycle to transition through | State machine |
56
+ | `module.exposes_wire_protocol` | N/A | Speaks standard HTTP; defines no protocol or binary format of its own | Protocol and wire format |
57
+ | `module.ui_multi_screen` | N/A | Gated by module.has_ui, which is N/A | UI flow |
58
+ | `module.large_data_model` | N/A | Gated by module.owns_persistence, which is N/A | Data model |
59
+ | `module.returns_caller_errors` | Applicable | `src/interceptors/http-status.js` rejects with subtypes from `src/http-error-subtypes.js` | Caller-visible failure modes |
60
+ | `module.module_specific_conventions` | Applicable | `src/lib/xhr.js` is a vendored fork held to upstream style with a whole-file eslint-disable | Module-specific rules |
61
+ | `module.published_package` | Applicable | `package.json` declares main, browser, and deploy:npm | Export stability |
62
+ | `module.embedded_in_host` | N/A | Consumed as a dependency, not mounted into a host | Host integration and theming |
63
+ | `module.has_design_tradeoff` | Applicable | Curried client construction and failure-as-response normalization are both non-obvious | Key design trade-off |
64
+ | `module.has_submodules` | Applicable | `src/request/index.js` roots a child module that owns its own specification | Sub-modules |
65
+
66
+ ## Evidence register
67
+
68
+ | Evidence | What it establishes |
69
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
70
+ | `src/index.js` | The export surface, curried client construction, default options, and the `fetch` helpers |
71
+ | `src/http-error.js` | `HttpError` body parsing, message extraction, and non-enumerable response metadata |
72
+ | `src/http-error-subtypes.js` | The subtype tree, the status-to-class map, and `select()` category fallback |
73
+ | `src/interceptors/http-status.js` | Where a failing status becomes a rejection, and the two 404 bodies that do not |
74
+ | `src/lib/interceptor.js` | The interceptor extension contract and the verbose-logging gate |
75
+ | `src/lib/detect.js` | Accepted buffer types, the Blob shortcut, and the octet-stream fallback |
76
+ | `src/lib/xhr.js` | The vendored fork and its stated requirement to diverge minimally from upstream |
77
+ | `src/progress-event.js` | The readonly progress payload and the `lengthComputable` rule |
78
+ | `package.json` | Published entry points, the browser swap, dependencies, and every command |
79
+ | `test/integration/spec/http-error.js` | Subtype existence, status mapping, `select()`, and all message-parsing branches |
80
+ | `test/integration/spec/progress-event.js` | Progress payload fields and computability |
81
+ | `test/integration/spec/interceptor.js` | `logOptions` delegating to the resolved logger |
82
+ | `test/integration/spec/request.js` | End-to-end behavior including the network-error-to-`NetworkOrCORSError` path |
83
+ | `test/unit/spec/index.js` | `protoprepareFetchOptions` output shape and `setTimingsAndFetch` timing writes |
84
+ | `test/unit/spec/interceptors/http-status.js` | Both redirect passthroughs and both matching rejections |
85
+
86
+ ## Purpose and boundary
87
+
88
+ - **Responsibility:** present one HTTP client API to the rest of the SDK, and own everything about
89
+ that API except the act of sending a request — client construction and defaults, the extension
90
+ point consumers plug into, the error types they catch, and the payload shapes they observe.
91
+ - **In scope:** the exported surface of `src/index.js`; the `HttpError` taxonomy and its parsing;
92
+ the `Interceptor` base class; status-to-error conversion; `ProgressEvent`; MIME detection; the
93
+ vendored XHR fork consumed by the browser transport.
94
+ - **Out of scope:** request execution, interceptor chain folding, and the two platform transports.
95
+ Those belong to [`src/request`](../request/docs/README.md) and are referenced here as the
96
+ `http-core-request-transport` contract. Authentication token acquisition, retry policy, and
97
+ correlation headers are all owned by consuming packages through interceptors.
98
+ - **Consumers:** `@webex/webex-core`, `@webex/internal-plugin-device`,
99
+ `@webex/internal-plugin-encryption`, `@webex/internal-plugin-scheduler`, `@webex/test-users`, and
100
+ `@webex/helper-image`, each declaring the dependency in its own `package.json`.
101
+
102
+ ## Structure and key files
103
+
104
+ | Path | Responsibility |
105
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
106
+ | `src/index.js` | Package entry point. Curries `protorequest` into `defaults` and `request`, hides noisy option keys from logs, resolves the logger, and exports the public surface |
107
+ | `src/http-error.js` | `HttpError` base: parses a response body into a message and attaches response metadata non-enumerably |
108
+ | `src/http-error-subtypes.js` | Builds the subtype tree onto the base class and installs the `select()` status resolver. Authoritative for error identity |
109
+ | `src/interceptors/http-status.js` | The one place a response becomes a rejection. Also encodes the two 404 bodies treated as successes |
110
+ | `src/lib/interceptor.js` | The `Interceptor` base class: four pass-through lifecycle hooks, the `logOptions` helper, and an abstract `create()` |
111
+ | `src/lib/detect.js` | MIME detection for `Blob`, `ArrayBuffer`, and `Uint8Array` inputs |
112
+ | `src/lib/xhr.js` | Vendored fork of `naugtur/xhr`, used only by the browser transport. See [ADR-0001](../../docs/adr/0001-fork-xhr-for-frozen-object-environments.md) |
113
+ | `src/progress-event.js` | Constructs the readonly progress payload emitted during upload and download |
114
+ | `src/request/index.js` | Entry point of the `src/request` child module — see [Sub-modules](#sub-modules) |
115
+
116
+ ## Sub-modules
117
+
118
+ | Sub-module | Responsibility | Specification |
119
+ | ------------- | -------------------------------------------------------------------------------------------- | ---------------------------- |
120
+ | `src/request` | Applies the interceptor chain around a platform-specific transport and emits progress events | `src/request/docs/README.md` |
121
+
122
+ `src/request` provides the `http-core-request-transport` contract, which this module requires. Its
123
+ transports, option translation, and failure normalization are specified in
124
+ [its own document](../request/docs/README.md) and are not restated here.
125
+
126
+ ## Public surface
127
+
128
+ | Surface | Contract | Consumer | Compatibility commitment | Source |
129
+ | --- | --- | --- | --- | --- |
130
+ | `request` | `http-core-sdk` | All SDK plugins | Primary client. Pre-applied with `json: true` and a default `HttpStatusInterceptor` | `src/index.js` |
131
+ | `defaults` | `http-core-sdk` | Plugins building own clients | Curried factory; `defaults(options)` returns a new request function | `src/index.js` |
132
+ | `protoprepareFetchOptions` | `http-core-sdk` | Metrics submission paths | Curried; runs request interceptors and returns fetch-ready options without sending | `src/index.js` |
133
+ | `setTimingsAndFetch` | `http-core-sdk` | Metrics submission paths | Stamps `$timings.requestStart`/`networkStart` with the current time, then calls `fetch` | `src/index.js` |
134
+ | `Interceptor` | `http-core-sdk` | Plugins adding interceptors | Extension base class; the four hook names are a stable contract | `src/lib/interceptor.js` |
135
+ | `HttpStatusInterceptor` | `http-core-sdk` | Plugins overriding defaults | Accepts a custom error constructor via `options.error` or `options.ErrorConstructor` | `src/interceptors/http-status.js` |
136
+ | `HttpError` | `http-core-sdk` | Every caller handling errors | Base class plus the full subtype tree and `select()`, attached as static members | `src/http-error.js` |
137
+ | `ProgressEvent` | `http-core-progress-events` | Progress subscribers | Readonly `loaded`, `total`, `lengthComputable` | `src/progress-event.js` |
138
+ | `detect` | `http-core-sdk` | Callers detecting MIME types | `async`; accepts `Blob`, `ArrayBuffer`, or `Uint8Array` | `src/lib/detect.js` |
139
+
140
+ Every row routes to a stable contract id in `contract_catalog.definitions`. This module provides
141
+ `http-core-sdk`, `http-core-progress-events`, and the internal `http-core-xhr-fork`; it requires
142
+ `http-core-request-transport` and `webex-common-sdk`, both recorded in the Dependencies table below.
143
+ The repository-wide index of the same ids is
144
+ [Public and consumer surfaces](../../docs/architecture.md#public-and-consumer-surfaces).
145
+
146
+ Exact signatures stay in the native source declared by `package.json`. Note that the package exports
147
+ no TypeScript declarations, so consumers read these from the source rather than from a `.d.ts`.
148
+
149
+ ## Dependencies
150
+
151
+ | Dependency | Why it is required | Failure behavior |
152
+ | --------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
153
+ | `src/request` (`http-core-request-transport`) | Executes the request the curried client builds | Its promise is returned unchanged; failures surface as a `statusCode: 0` response |
154
+ | `@webex/common` (`Exception`) | Base class `HttpError` extends, providing SDK-wide error serialization | A load-time dependency; failure would prevent the module from importing |
155
+ | `file-type` | Buffer MIME detection in `src/lib/detect.js` | An unrecognized buffer yields `application/octet-stream` rather than an error |
156
+ | `lodash` | `curry`, `defaults`, `assign`, `isString`, `pick`, `isNumber` across the module | Load-time dependency |
157
+ | `global`, `is-function`, `parse-headers`, `xtend` | Runtime dependencies of the vendored fork in `src/lib/xhr.js` | Used only on the browser transport path |
158
+
159
+ ## Requirements
160
+
161
+ | ID | WHAT | WHY | Source evidence | Test or example evidence | Assumptions or gaps | Confidence |
162
+ | --------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------- | -------------------------------------------------------------- | ---------- |
163
+ | `MOD-001` | `request` is `protorequest` pre-applied with `json: true` and one `HttpStatusInterceptor` | Gives every SDK caller JSON handling and typed errors without configuring anything | `src/index.js` | `test/integration/spec/request.js` | none | Present |
164
+ | `MOD-002` | `defaults(options)` returns a new client function with those options merged underneath per-call options | Lets a plugin layer its own defaults without re-implementing the pipeline | `src/index.js` | none found | Gap: no test exercises `defaults()` directly | Present |
165
+ | `MOD-003` | A string first argument is treated as the URI, with options read from the third argument | Matches the `request` library's calling convention so callers can migrate unchanged | `src/index.js` | `test/integration/spec/request.js` | none | Present |
166
+ | `MOD-004` | `download`, `interceptors`, `logger`, and `upload` are redefined non-enumerable before defaults are merged | Keeps injected services and emitters out of serialized logs of the options object | `src/index.js` | none found | Gap: no test asserts these stay out of a serialized dump | Present |
167
+ | `MOD-005` | `options.json` is deleted when it is neither truthy nor explicitly `false` | Distinguishes "no opinion" from an explicit opt-out so the transport's own default applies | `src/index.js` | none found | Gap: no direct test | Present |
168
+ | `MOD-006` | The logger resolves as `options.logger`, then `this.logger`, then `console` | Guarantees every request has a logger without forcing callers to supply one | `src/index.js` | `test/integration/spec/interceptor.js` | none | Present |
169
+ | `MOD-007` | An `HttpError` subtype exists for each standard status code, plus `429` and `0` | Consumers branch on error class rather than parsing status codes | `src/http-error-subtypes.js` | `test/integration/spec/http-error.js` | none | Present |
170
+ | `MOD-008` | `select(statusCode)` returns the exact subtype, else the category default (`400`/`500`), else the base class | An unmapped status still produces a meaningful class instead of a bare `Error` | `src/http-error-subtypes.js` | `test/integration/spec/http-error.js` | none | Present |
171
+ | `MOD-009` | `4xx` subtypes descend from `BadRequest`, `5xx` from `InternalServerError`, and `NetworkOrCORSError` from the base | Lets a caller catch a whole class of failures with one `instanceof` | `src/http-error-subtypes.js` | `test/integration/spec/http-error.js` | none | Present |
172
+ | `MOD-010` | `parse()` extracts a message from the body by scanning `errorKeys`, recursing into nested objects, and stringifying otherwise | Webex services report errors under several different key names | `src/http-error.js` | `test/integration/spec/http-error.js` | none | Present |
173
+ | `MOD-011` | When no message can be extracted, `defaultMessage` is used | An error is never constructed with an empty message | `src/http-error.js` | `test/integration/spec/http-error.js` | none | Present |
174
+ | `MOD-012` | Response metadata is attached to the error as non-enumerable properties | Keeps full diagnostics reachable without making errors serialize enormous payloads | `src/http-error.js` | none found | Gap: no test asserts non-enumerability | Present |
175
+ | `MOD-013` | `HttpStatusInterceptor.onResponse` resolves below `400` and rejects at or above it with the selected subtype | Centralizes status-to-error conversion in exactly one place | `src/interceptors/http-status.js` | `test/unit/spec/interceptors/http-status.js`, `test/integration/spec/request.js` | none | Present |
176
+ | `MOD-014` | A `404` carrying `body.errorCode === 2000002` or `body.code === 404100` resolves as a success | Those bodies are redirect instructions the caller must act on, not failures | `src/interceptors/http-status.js` | `test/unit/spec/interceptors/http-status.js` | none | Present |
177
+ | `MOD-015` | `HttpStatusInterceptor` uses a constructor supplied via `options.error` or `options.ErrorConstructor`, defaulting to `HttpError` | Lets a plugin substitute a richer error type while reusing the status logic | `src/interceptors/http-status.js` | none found | Gap: neither option name is exercised by a test | Present |
178
+ | `MOD-016` | `Interceptor`'s four hooks default to pass-through, and `create()` throws unless overridden | A subclass implements only the hooks it needs, but cannot be instantiated without a factory | `src/lib/interceptor.js` | none found | Gap: no test asserts the `create()` throw or the pass-throughs | Present |
179
+ | `MOD-017` | `logOptions` logs only when `ENABLE_VERBOSE_NETWORK_LOGGING` is set and a logger resolves | Full option dumps include headers and bodies, so they must stay off by default | `src/lib/interceptor.js` | `test/integration/spec/interceptor.js` | none | Present |
180
+ | `MOD-018` | `ProgressEvent` exposes readonly `loaded`, `total`, and `lengthComputable`, the last true only when both are numbers, neither is `NaN`, and `total > 0` | Mirrors the browser `ProgressEvent` so callers write one handler for both platforms | `src/progress-event.js` | `test/integration/spec/progress-event.js` | none | Present |
181
+ | `MOD-019` | `detect()` rejects anything that is not a `Blob`, `ArrayBuffer`, or `Uint8Array`; returns `blob.type` for a `Blob`; falls back to `application/octet-stream` | Content-type inference must never guess wrongly or throw on an unknown format | `src/lib/detect.js` | none found | Gap: no direct test; exercised only indirectly through request tests | Present |
182
+ | `MOD-020` | `protoprepareFetchOptions` runs the request-interceptor chain and returns fetch-ready options without sending | Metrics paths need to build a request now and send it later | `src/index.js` | `test/unit/spec/index.js` | none | Present |
183
+ | `MOD-021` | `setTimingsAndFetch` overwrites `$timings.requestStart` and `networkStart` with the current time, then calls `fetch` | Timings captured when options were built would misreport latency at send time | `src/index.js` | `test/unit/spec/index.js` | none | Present |
184
+
185
+ ## Design overview
186
+
187
+ The module is organized around a single idea: **the options object is the program.** There is no
188
+ client class and no connection state. `src/index.js` curries `protorequest`, so a "client" is a
189
+ function with an options object already bound to it. `request` is that same function with the
190
+ package's own defaults applied; `defaults()` is the public way to make another one.
191
+
192
+ Three consequences follow. Configuration is merged with `lodash.defaults`, which fills absent keys
193
+ only — a per-call option always wins over a client default. There is nothing to construct or tear
194
+ down. And because the same object is threaded through interceptors and the transport and is mutated
195
+ along the way, `src/index.js` takes explicit care to mark `download`, `interceptors`, `logger`, and
196
+ `upload` non-enumerable before anything can serialize it.
197
+
198
+ **Error identity is built, not declared.** `src/http-error-subtypes.js` exports a function that
199
+ constructs the subtype classes and assigns them onto whatever base it is given, keyed by both status
200
+ code and class name. `src/http-error.js` calls it on `HttpError` at module load and re-exposes it as
201
+ `HttpError.makeSubTypes`, so a consumer can build a parallel tree on its own base class and hand it
202
+ to `HttpStatusInterceptor` through `options.error`. That is the mechanism behind `MOD-015`, and it is
203
+ why the taxonomy lives in a factory rather than in static class declarations.
204
+
205
+ **Message extraction is deliberately forgiving.** `parse()` in `src/http-error.js` accepts a string
206
+ body, a parsed object, or a string containing JSON, and searches a fixed list of candidate keys —
207
+ `error`, `errorString`, `response`, `errorResponse`, `message`, `msg` — recursing when the winning
208
+ candidate is itself an object. Webex services do not agree on one error-body shape, so the module
209
+ absorbs that variance rather than pushing it onto every caller.
210
+
211
+ `src/lib/xhr.js` is the one file that does not follow the module's conventions, by design. It is a
212
+ vendored fork carrying its own style and a whole-file lint exemption; see
213
+ [ADR-0001](../../docs/adr/0001-fork-xhr-for-frozen-object-environments.md).
214
+
215
+ ## Data flow and sequence coverage
216
+
217
+ The transport is HTTP, reached through a promise-returning function call. This module has three
218
+ distinct operation groups, which differ in actors and outcome and so are diagrammed separately:
219
+ client construction, error conversion, and the prepared-options path used for metrics.
220
+
221
+ | Operation group | Entry and outcome | Diagram or evidence | Failure and recovery coverage |
222
+ | ---------------------- | ----------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------ |
223
+ | Client construction | `defaults(opts)` or `request(opts)` → a promise for a response | Diagram below | Defaults merging cannot fail; downstream failures surface as responses |
224
+ | Error conversion | A response reaches `HttpStatusInterceptor` → resolve, or reject with a typed subtype | Diagram below | Covers `< 400`, the two 404 redirect bodies, `>= 400`, and `statusCode: 0` |
225
+ | Prepared fetch options | `protoprepareFetchOptions(opts)` → fetch-ready options; later `setTimingsAndFetch` sends | Diagram below | Request-interceptor rejection propagates; no send is attempted |
226
+
227
+ **Client construction and dispatch**
228
+
229
+ ```mermaid
230
+ sequenceDiagram
231
+ participant C as Caller
232
+ participant I as src/index.js
233
+ participant R as src/request (child module)
234
+ C->>I: request(options) or defaults(base)(options)
235
+ I->>I: normalize string URI form
236
+ I->>I: hide download/interceptors/logger/upload from enumeration
237
+ I->>I: merge defaults, drop ambiguous json, resolve logger
238
+ I->>R: _request(options)
239
+ R-->>I: Promise<response>
240
+ I-->>C: Promise<response>
241
+ ```
242
+
243
+ **Error conversion**
244
+
245
+ ```mermaid
246
+ sequenceDiagram
247
+ participant R as response
248
+ participant H as HttpStatusInterceptor
249
+ participant S as HttpError.select
250
+ participant C as Caller
251
+ R->>H: onResponse(options, response)
252
+ alt statusCode < 400
253
+ H-->>C: resolve(response)
254
+ else 404 with errorCode 2000002 or code 404100
255
+ H-->>C: resolve(response)
256
+ else statusCode >= 400
257
+ H->>S: select(statusCode)
258
+ S-->>H: exact subtype, else category default, else base
259
+ H-->>C: reject(new Subtype(response))
260
+ else statusCode 0 (network or CORS failure)
261
+ H->>S: select(0)
262
+ S-->>H: NetworkOrCORSError
263
+ H-->>C: reject(new NetworkOrCORSError(response))
264
+ end
265
+ ```
266
+
267
+ **Prepared fetch options**
268
+
269
+ ```mermaid
270
+ sequenceDiagram
271
+ participant C as Caller
272
+ participant I as src/index.js
273
+ participant U as src/request/utils.ts
274
+ participant F as fetch
275
+ C->>I: protoprepareFetchOptions(base)(options)
276
+ I->>U: prepareFetchOptions(options)
277
+ U-->>I: options with headers, emitters, keepalive
278
+ I-->>C: prepared options (nothing sent)
279
+ C->>I: setTimingsAndFetch(options)
280
+ I->>I: stamp $timings.requestStart and networkStart
281
+ I->>F: fetch(options.uri, options)
282
+ F-->>C: Promise<Response>
283
+ ```
284
+
285
+ ## Class and component relationships
286
+
287
+ ```mermaid
288
+ classDiagram
289
+ class Exception
290
+ class HttpError {
291
+ +errorKeys: Array
292
+ +defaultMessage: string
293
+ +parse(res)
294
+ +parseObject(body)
295
+ +select(statusCode)$
296
+ +makeSubTypes(Base)$
297
+ }
298
+ class BadRequest
299
+ class InternalServerError
300
+ class NetworkOrCORSError
301
+ class Interceptor {
302
+ +onRequest(options)
303
+ +onRequestError(options, reason)
304
+ +onResponse(options, response)
305
+ +onResponseError(options, reason)
306
+ +logOptions(options)
307
+ +create()$
308
+ }
309
+ class HttpStatusInterceptor {
310
+ +ErrorConstructor
311
+ +onResponse(options, response)
312
+ }
313
+ class ProgressEvent {
314
+ +loaded
315
+ +total
316
+ +lengthComputable
317
+ }
318
+ Exception <|-- HttpError
319
+ HttpError <|-- BadRequest
320
+ HttpError <|-- InternalServerError
321
+ HttpError <|-- NetworkOrCORSError
322
+ BadRequest <|-- Unauthorized
323
+ BadRequest <|-- NotFound
324
+ BadRequest <|-- TooManyRequests
325
+ InternalServerError <|-- ServiceUnavailable
326
+ InternalServerError <|-- GatewayTimeout
327
+ Interceptor <|-- HttpStatusInterceptor
328
+ HttpStatusInterceptor ..> HttpError : selects subtype
329
+ ```
330
+
331
+ Subtypes are abbreviated; `src/http-error-subtypes.js` is authoritative for the full tree.
332
+
333
+ ## Use cases and flows
334
+
335
+ | Use case | Actor or caller | Primary steps and outcome | Failure or boundary behavior | Evidence |
336
+ | -------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
337
+ | `UC-001` | SDK plugin | Call `request({uri, method})`; receive a response whose JSON body is already parsed | A failing status rejects with a typed subtype instead of resolving | `src/index.js`, `test/integration/spec/request.js` |
338
+ | `UC-002` | Plugin with its own defaults | Call `defaults({...})` once, keep the returned function, and call it per request | Per-call options override client defaults; client defaults override nothing set per call | `src/index.js` |
339
+ | `UC-003` | Plugin adding behavior | Subclass `Interceptor`, implement `create()` and the needed hooks, pass it in `options.interceptors` | Omitting `create()` throws; unimplemented hooks pass through untouched | `src/lib/interceptor.js` |
340
+ | `UC-004` | Caller handling failures | `catch` a rejection and branch with `instanceof HttpError.NotFound` or a parent such as `BadRequest` | An unmapped status still yields the category default, so the branch never misses | `src/http-error-subtypes.js`, `test/integration/spec/http-error.js` |
341
+ | `UC-005` | Metrics submission | Build options with `protoprepareFetchOptions`, hold them, then send later with `setTimingsAndFetch` | Timings are re-stamped at send so latency is measured from dispatch, not from build | `src/index.js`, `test/unit/spec/index.js` |
342
+ | `UC-006` | Caller uploading a buffer | Pass a buffer body; `detect()` resolves its MIME type and the Node transport sets `content-type` before dispatch | An unrecognized buffer becomes `application/octet-stream`; a wrong type is never guessed. The browser transport does not await the detection, so the header can miss the request — see `MOD-024` in the sub-module spec | `src/lib/detect.js`, `src/request/request.shim.js` |
343
+
344
+ ### Cross-boundary use-case flow
345
+
346
+ | Boundary | Transport | Ordering | Compatibility | Timeout and retry | Recovery |
347
+ | --------------------------------- | ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
348
+ | SDK plugin → Webex service | HTTP, via `src/request` | Request interceptors run in array order; response interceptors in reverse | Option vocabulary follows the `request` library, so it is stable across platforms | This module sets no timeout and performs no retry; `options.timeout` is honored by the transport, and retry belongs to callers | Failures arrive as rejections carrying the full response on the error object |
349
+ | Caller → `fetch` (metrics path) | HTTP, via `setTimingsAndFetch` | Request interceptors run at prepare time, not at send time | Returns the raw `fetch` promise, not a normalized response | No timeout, no retry, and no status-to-error conversion on this path | The caller handles the `fetch` rejection itself |
350
+
351
+ The metrics path is the important asymmetry: it bypasses `HttpStatusInterceptor` entirely, so a
352
+ `4xx` there resolves rather than rejects.
353
+
354
+ ## Concurrency and reactive flow
355
+
356
+ - **Execution model:** promise-based on the host event loop. There are no threads, workers, or
357
+ background jobs. Four exports are asynchronous: `request()` and `protoprepareFetchOptions()` return
358
+ promises created by the layer beneath them, `setTimingsAndFetch()` returns the `fetch` promise, and
359
+ `detect()` is `async`. The rest of the public surface is synchronous — `defaults()` returns a
360
+ function, `Interceptor.create()` and `HttpStatusInterceptor.create()` return (or throw) at once,
361
+ `HttpError.select()` returns a class, and `ProgressEvent` and `HttpError` are constructors.
362
+ - **Ordering guarantees:** within one request, request interceptors run in array order and response
363
+ interceptors in reverse, as folded by `src/request/utils.ts`. Across concurrent requests there is
364
+ no ordering guarantee and none is needed — no state is shared between them.
365
+ - **Idempotency and retry:** neither is implemented here. The module retries nothing and deduplicates
366
+ nothing; a caller that needs either owns it. This matters because `MOD-013` converts a `429` into
367
+ a `TooManyRequests` rejection but does not act on it.
368
+ - **Shared-state protection:** none is required. Each call owns its options object and its two
369
+ emitters. The only module-level mutable state is the subtype map installed onto `HttpError` at
370
+ load time, which is written once and read thereafter.
371
+ - **Blocking restrictions:** interceptor hooks are awaited in sequence, so a slow hook delays every
372
+ request that uses that interceptor. Hooks must not block the event loop.
373
+
374
+ ## Caller-visible failure modes
375
+
376
+ | Condition | Signal or result | Caller behavior | Retry or recovery | Evidence |
377
+ | --------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------------------------------------- |
378
+ | Status `>= 400` with a mapped subtype | Rejection with that subtype, e.g. `NotFound` | Branch on the class; read `statusCode`, `headers`, `body` | Caller-owned; nothing is retried here | `src/interceptors/http-status.js`, `test/integration/spec/request.js` |
379
+ | Status `>= 400` with no exact subtype | Rejection with the category default (`400`/`500`) | The broader `instanceof` still matches | Caller-owned | `src/http-error-subtypes.js`, `test/integration/spec/http-error.js` |
380
+ | Network, DNS, or CORS failure | Rejection with `NetworkOrCORSError` (`statusCode: 0`) | Treat as unreachable, not as a server response | Caller-owned | `test/integration/spec/request.js` |
381
+ | `404` with a Locus or App API redirect body | **Resolves** as a success | Must inspect the body and follow the redirect | Not a failure | `test/unit/spec/interceptors/http-status.js` |
382
+ | Error body in an unrecognized shape | Message is the stringified body | Message remains useful, though not concise | None needed | `src/http-error.js`, `test/integration/spec/http-error.js` |
383
+ | No extractable message | `defaultMessage` is used | Error is never empty | None needed | `src/http-error.js` |
384
+ | `detect()` given an unsupported type | Rejects with a plain `Error` | Not an `HttpError`; do not catch it with a subtype branch | Pass a supported buffer type | `src/lib/detect.js` |
385
+ | `Interceptor.create()` not overridden | Throws at call time | Fix the subclass | None | `src/lib/interceptor.js` |
386
+
387
+ ## Pitfalls and constraints
388
+
389
+ - **A `404` is not always a failure.** Two body shapes resolve as successes. Code that assumes every
390
+ `404` rejects will silently mis-handle Locus and App API redirects.
391
+ - **`statusCode: 0` is not an HTTP status.** It is this module's encoding of a transport-level
392
+ failure. Do not compare it numerically against real statuses or treat it as a server response.
393
+ - **Error subtype names are a published contract.** Consumers branch on `NotFound`,
394
+ `TooManyRequests`, and the rest. Renaming one, removing it, or changing its parent is breaking.
395
+ - **Response metadata on an error is non-enumerable.** `Object.keys(err)` and `JSON.stringify(err)`
396
+ will not show `statusCode`, `headers`, or `body`. Access them directly.
397
+ - **The options object is mutated in place.** `src/index.js` rewrites the caller's object rather
398
+ than copying it. Reusing one options object across two requests will carry state between them.
399
+ - **`ENABLE_VERBOSE_NETWORK_LOGGING` logs request bodies and headers.** The non-enumerable trick
400
+ hides injected services, not payloads. Keep it off outside debugging.
401
+ - **The metrics path skips status-to-error conversion.** `setTimingsAndFetch` calls `fetch`
402
+ directly, so no `HttpError` is ever produced on that path.
403
+ - **No timeout is applied by default.** `setDefaults` in the browser transport sets `timeout: 0`.
404
+ A caller that needs one must pass `options.timeout`.
405
+
406
+ ## Module-specific rules
407
+
408
+ - **Do:** keep `src/lib/xhr.js` as close to its upstream original as possible. Its header states the
409
+ rule, its `/* eslint-disable */` enforces it, and [ADR-0001](../../docs/adr/0001-fork-xhr-for-frozen-object-environments.md)
410
+ records why. Port upstream changes as discrete, reviewable diffs.
411
+ - **Do:** add new error subtypes through the factory in `src/http-error-subtypes.js`, registering
412
+ them under both the numeric status and the class name, so `select()` and the named static both work.
413
+ - **Do:** mark any new injected-service or emitter option non-enumerable in `src/index.js` alongside
414
+ the existing four, or it will leak into logged option dumps.
415
+ - **Do not:** re-enable repository lint rules on `src/lib/xhr.js` or reformat it to local style.
416
+ - **Do not:** add retry, backoff, or timeout policy to this module. It converts failures into typed
417
+ errors; reacting to them belongs to callers.
418
+ - **Do not:** reject from a transport for a network failure. Resolve a `statusCode: 0` response so
419
+ `HttpStatusInterceptor` remains the only place rejections originate.
420
+
421
+ ## Export stability
422
+
423
+ | Export or entry point | Consumer | Stability | Versioning and deprecation rule | Declaration or API report |
424
+ | -------------------------- | ---------------------------- | --------- | ---------------------------------------------------------------------------------------- | ------------------------- |
425
+ | `request` | All consuming SDK packages | Stable | Signature follows the `request` library convention; removing an option shape is breaking | `package.json` |
426
+ | `defaults` | Plugins building own clients | Stable | Must keep returning a callable client, not a configuration object | `package.json` |
427
+ | `HttpError` and subtypes | Every error-handling caller | Stable | Class names and parents are contract; add subtypes freely, never rename or reparent | `package.json` |
428
+ | `Interceptor` | Plugins adding interceptors | Stable | The four hook names and `create()` are contract; adding an optional hook is compatible | `package.json` |
429
+ | `HttpStatusInterceptor` | Plugins overriding defaults | Stable | Both `options.error` and `options.ErrorConstructor` are accepted and must stay accepted | `package.json` |
430
+ | `ProgressEvent` | Progress subscribers | Stable | Field names mirror the browser API; changing them breaks shared handlers | `package.json` |
431
+ | `detect` | Callers detecting MIME types | Stable | Must stay `async` and keep the octet-stream fallback rather than throwing | `package.json` |
432
+ | `protoprepareFetchOptions`, `setTimingsAndFetch` | Metrics paths | Stable | Narrow-purpose exports; `$timings` key names are relied on by metrics consumers | `package.json` |
433
+ | `dist/index.js` | npm consumers | Stable | The published entry point declared by `main`; `browser` swaps the transport | `package.json` |
434
+
435
+ The package ships no TypeScript declarations, so there is no `.d.ts` or API report to diff against.
436
+ Export changes are only visible by reading `src/index.js`, which makes review the sole gate.
437
+
438
+ ## Key design trade-off
439
+
440
+ | Chosen trade-off | Preserved invariant or benefit | Cost or limitation | Decision evidence |
441
+ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
442
+ | Normalize transport failures into a `statusCode: 0` response instead of rejecting | Exactly one place creates rejections, so every failure flows through the interceptor chain and reaches `onResponseError` | `0` is not a real status; callers who compare numerically, or who log status codes, see a value HTTP never produces | `src/interceptors/http-status.js` |
443
+ | Build clients by currying rather than constructing a class | A client is a plain function — nothing to instantiate, configure, or dispose, and defaults compose by merging | There is no object to introspect, so a client's effective configuration cannot be read back after it is built | `src/index.js` |
444
+ | Build the error taxonomy with a factory assigned onto a base class | A consumer can generate a parallel tree on its own base and inject it via `options.error` | Subtypes are invisible to static analysis and to editors, since they exist only after `makeSubTypes` runs | `src/http-error-subtypes.js` |
445
+ | Mirror the `request` library's option vocabulary on the public API | One API works on both platforms, and callers migrating from that library need no changes | The vocabulary of an unmaintained library is now a published contract, and the browser transport must reimplement it by hand | `package.json` |
446
+ | Treat two `404` bodies as successes | Locus and App API redirects reach the caller intact instead of being destroyed by status handling | A general-purpose HTTP client carries two service-specific magic numbers, and callers must know a `404` may resolve | `src/interceptors/http-status.js` |
447
+ | Vendor `naugtur/xhr` rather than depend on it | Browser requests work where the object graph is fully frozen | Third-party code is maintained in-repo, excluded from lint, and receives no upstream fixes automatically | `src/lib/xhr.js`, `docs/adr/0001-fork-xhr-for-frozen-object-environments.md` |
448
+
449
+ ## Verification
450
+
451
+ | Requirement or invariant | Test level | Positive evidence | Negative or boundary evidence | Gap |
452
+ | ------------------------ | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
453
+ | `MOD-001` | Integration | `test/integration/spec/request.js` | `test/integration/spec/request.js` (subtyped error on failure) | none |
454
+ | `MOD-002` | — | none found | none found | `defaults()` is untested; a regression in currying would not be caught |
455
+ | `MOD-003` | Integration | `test/integration/spec/request.js` | `test/integration/spec/request.js` (options.url normalized to uri) | none |
456
+ | `MOD-004` | — | none found | none found | Non-enumerability is unasserted; a log-leak regression would be silent |
457
+ | `MOD-005` | — | none found | none found | The three-way `json` branch is untested |
458
+ | `MOD-006` | Integration | `test/integration/spec/interceptor.js` | none found | The `console` fallback branch is uncovered |
459
+ | `MOD-007` | Integration | `test/integration/spec/http-error.js` | `test/integration/spec/http-error.js` (network/CORS subtype) | none |
460
+ | `MOD-008` | Integration | `test/integration/spec/http-error.js` | `test/integration/spec/http-error.js` (.select() fallback) | none |
461
+ | `MOD-009` | Integration | `test/integration/spec/http-error.js` | none found | none |
462
+ | `MOD-010` | Integration | `test/integration/spec/http-error.js` (string, JSON, stringified JSON) | `test/integration/spec/http-error.js` (recursive candidates) | none |
463
+ | `MOD-011` | Integration | `test/integration/spec/http-error.js` | `test/integration/spec/http-error.js` | none |
464
+ | `MOD-012` | — | none found | none found | Non-enumerability of response metadata is unasserted |
465
+ | `MOD-013` | Unit + Integration | `test/unit/spec/interceptors/http-status.js` | `test/integration/spec/request.js` | none |
466
+ | `MOD-014` | Unit | `test/unit/spec/interceptors/http-status.js` (both redirect codes) | `test/unit/spec/interceptors/http-status.js` (both near-miss codes) | none |
467
+ | `MOD-015` | — | none found | none found | Neither `options.error` nor `options.ErrorConstructor` is exercised |
468
+ | `MOD-016` | — | none found | none found | The abstract `create()` throw and the pass-through hooks are untested |
469
+ | `MOD-017` | Integration | `test/integration/spec/interceptor.js` | none found | The env-var-off branch is not asserted |
470
+ | `MOD-018` | Integration | `test/integration/spec/progress-event.js` | `test/integration/spec/progress-event.js` (computability) | none |
471
+ | `MOD-019` | — | none found | none found | No direct test; the type-guard rejection path is entirely uncovered |
472
+ | `MOD-020` | Unit | `test/unit/spec/index.js` | none found | none |
473
+ | `MOD-021` | Unit | `test/unit/spec/index.js` | none found | none |
474
+
475
+ **Coverage gaps worth acting on.** Seven requirements have no test at all: `MOD-002`, `MOD-004`,
476
+ `MOD-005`, `MOD-012`, `MOD-015`, `MOD-016`, and `MOD-019`. Two of them guard behavior that fails
477
+ silently rather than loudly — `MOD-004` and `MOD-012` both concern non-enumerability, so a
478
+ regression leaks credentials or bloats logs without any test turning red. `MOD-019` leaves
479
+ `detect()`'s type guard uncovered even though it is the module's only input-validation path.
480
+
481
+ Generator-side field measurement is complete for this module and every critical field is present.
482
+ What remains is not measurement: independent semantic validation by `codex` ran on 2026-09-23 and
483
+ returned `pass-with-warnings` with no Blocking finding against this module. Promotion from `Partial`
484
+ to `Specced` remains held because a characterization baseline should be recorded before any risky
485
+ modification.