feat+docs+test: D40 — X-OLP-Fallback-Detail header (issue #7)

ADR 0004 § Decision § Chain advancement step 4 promised a per-hop
failure detail debug header `X-OLP-Fallback-Detail`. From D9 through
D39 the engine logged per-hop events (D28 added correlation fields)
but never surfaced the failure trail on the response. D40 fulfills
the promise via Option A — ungated v0.1 emission. Phase 2 will
re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands.

**Per-hop tuple collection** (lib/fallback/engine.mjs)

executeWithFallback now collects `fallbackDetail` — an array of per-hop
tuples — and returns it on EVERY return shape (success / client-error /
AUTH_MISSING / non-trigger / chain-exhausted). Soft-trigger skipped
hops are also recorded with `trigger_type: 'soft'` (currently dead-
code-by-config per ADR 0004 Amendment 2; shape forward-compatible).

Tuple shape (reuses D28 log-event field shapes so logs and the header
pivot on the same keys):
```
{
  hop: <0-indexed hop number>,
  provider: <provider name>,
  model: <model name from IR>,
  code: <ProviderError code, engine-synthetic SOFT_TRIGGER, or 'UNKNOWN'>,
  error_message: <truncated to 200 chars with ellipsis on overflow>,
  trigger_type: 'hard' | 'soft' | 'client_error' | 'auth_missing' | 'unclassified'
}
```

**Header serialiser** (server.mjs)

- `FALLBACK_DETAIL_BYTE_CAP = 4096` (UTF-8 bytes).
- `serializeFallbackDetailHeader(fallbackDetail)` exported. Returns
  `null` for empty/null/undefined → caller omits the header.
- `jsonStringifyAscii` escapes every non-ASCII code point as `\uXXXX`
  to satisfy RFC 7230 §3.2.6 field-vchar (Node's HTTP header validator
  rejects multi-byte UTF-8). The D38 CONCURRENCY_LIMIT synthesised
  message contains a U+2014 em dash — without this escape, every
  CONCURRENCY_LIMIT response crashed at writeHead with
  "Invalid character in header content". The regression test pins
  this exact string.
- 4KB cap algorithm: builds candidates as `[...slice(0, kept), sentinel]`
  and measures the FULL serialised length (including sentinel) before
  comparing against the cap, so the result is guaranteed under cap.
  Tail tuples dropped one at a time. Sentinel form:
  `{ truncated: true, omitted_hops: N }`.

**Header emission** (server.mjs)

- `withFallbackDetailHeader(base, fallbackDetail)` wraps the base
  header object; emits the header only when serialiser returns
  non-null.
- Emitted on chain-exhausted, non-trigger-error, client-error,
  AUTH_MISSING, and success-with-prior-failure paths.
- ABSENT on clean primary success — verified by a dedicated HTTP
  integration test.

**Tests** (test-features.mjs): 452 → 468 (+16):

- Engine-level tuple shape: 2-hop/exhausted, 2-hop/success-with-prior-
  failure, 1-hop/success (empty array), 1-hop/fail, non-ProviderError-
  yields-UNKNOWN, 500-char-message → 200-char-with-ellipsis, client
  error → 1 tuple + client_error trigger type
- Serialiser: empty/null → null, small array round-trip, >4KB cap
  with `{truncated:true,omitted_hops:N}` sentinel, RFC 7230 newline/CR
  escaping, non-ASCII escaping (em dash regression guard for the D38
  CONCURRENCY_LIMIT synthesised message)
- HTTP integration: clean-1-hop-success (header absent), 2-hop-
  exhausted (2 tuples on the wire), 2-hop-success-with-prior-failure
  (1 tuple on the wire)

Pre-commit fold-in (per evidence-first checkpoint #4):

- **Reviewer Suggestion #2**: `jsonStringifyAscii` regex character
  class `[U+0080-U+FFFF]` contains an invisible U+0080 boundary marker
  that editors render as nothing, making the line easy to misread as
  the empty class `[-...]`. Folded in a 5-line comment block above
  the function body explaining the literal byte range and citing
  RFC 7230 §3.2.6.

Two reviewer suggestions not folded:
- **Suggestion #1**: AUTH_MISSING tuple path lacks a dedicated D40
  test. Code is structurally correct (tuple pushed before early-
  return); low priority. Future polish.
- **Suggestion #3**: defensive `err.code != null` guard. Extremely
  low priority — PROVIDER_ERROR_CODES is a closed enum with no
  falsy values.

**ADR amendments** (docs/adr/0004-fallback-engine.md)

- Amendment 5 added at top of amendments stack — full tuple schema,
  cap behavior, RFC 7230 hygiene, ungated v0.1 rationale, Phase 2
  follow-up.
- § Chain advancement step 4 updated to remove TBD / not-yet-
  implemented qualifiers and cross-reference Amendment 5.
- § Observability headers section gains the X-OLP-Fallback-Detail
  schema as IMPLEMENTED at v0.1.

**CHANGELOG.md** — D40 sub-entry appended under existing D38/D39
entries in Unreleased section. No package.json bump per
phase_rolling_mode.

Authority:
- ADR 0004 § Decision § Chain advancement step 4 — D40 fulfils
  the promise
- ADR 0004 Amendment 5 (this commit) — implementation contract
- ADR 0004 § Observability headers — updated to IMPLEMENTED state
- D18 (5 standard X-OLP-* headers) — D40 builds on this convention
- D28 (per-hop structured log fields) — D40 reuses the field shapes
- GitHub issue #7 — closed by this commit
- CC 开发铁律 v1.6 § 10.x — fresh-context opus reviewer independent
- CLAUDE.md release_kit_overlay phase_rolling_mode — no version bump

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE. Critical depth checks:
- Tuple pushed once per hop, BEFORE client-error / AUTH_MISSING
  early-return branches — both paths include the failing tuple
- fallbackDetail returned on all 5 return paths (verified line by line)
- Cap algorithm measures FULL serialised length (with sentinel)
  before comparing — no overshoot
- RFC 7230 compliance verified end-to-end: imported the function
  in Node, confirmed em-dash → —, unescaped form throws
  "Invalid character in header content"
- Serialiser handles null / undefined / [] gracefully → header
  absent on clean primary success (HTTP test pins this)
- Hygiene: 0 hits for personal markers, home paths, tokens
- 468/468 tests pass in reviewer's independent npm test run

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:44:11 +10:00
co-authored by Claude Opus 4.7
parent bdfea6884b
commit 04f797f917
5 changed files with 683 additions and 10 deletions
+123 -2
View File
@@ -272,6 +272,115 @@ function olpErrorHeaders({ startMs, model }) {
};
}
// ── X-OLP-Fallback-Detail (D40, issue #7) ─────────────────────────────────
/**
* 4KB UTF-8 byte cap on the X-OLP-Fallback-Detail header value. Per RFC 7230 §3.2.5
* intermediaries are not obligated to forward arbitrarily large header values;
* 4KB matches the conservative upper bound (well below the 8KB total-header
* default of common reverse proxies — nginx `large_client_header_buffers`,
* Apache `LimitRequestFieldSize`). Tuples beyond the cap are dropped and
* a sentinel { truncated:true, omitted_hops:N } is appended.
*/
export const FALLBACK_DETAIL_BYTE_CAP = 4096;
/**
* Serialises per-hop fallback failure tuples into the X-OLP-Fallback-Detail
* header value. Returns null if the input array is empty/missing (header
* should not be emitted in that case).
*
* D40 (issue #7) — see ADR 0004 § Observability headers.
*
* Cap behaviour:
* - JSON.stringify the tuples; if Buffer.byteLength <= 4096, return as-is.
* - Otherwise, drop tuples from the tail one at a time until the array PLUS
* a trailing { truncated:true, omitted_hops:N } sentinel fits under the cap.
* - If even a single tuple + sentinel cannot fit (extremely long error_message
* beyond engine truncation, e.g. very long provider/model names), return
* just the sentinel { truncated:true, omitted_hops:<all> } — never produce
* a value > 4096 bytes.
*
* RFC 7230 hygiene: JSON.stringify already escapes raw newlines (\n → \\n),
* carriage returns, and other control characters. In addition, we escape all
* non-ASCII code points to \uXXXX sequences because Node's HTTP header
* validator rejects multi-byte UTF-8 in field values (and RFC 7230 §3.2.6
* limits `field-vchar` to ASCII VCHAR / obs-text). Without this step, an
* em dash (U+2014) in a synthesised error message — e.g. the CONCURRENCY_LIMIT
* message produced in server.mjs `collectAllChunks` — would trigger
* `Invalid character in header content` from `res.writeHead`.
*
* @param {Array<object>|null|undefined} fallbackDetail — from FallbackResult.fallbackDetail
* @returns {string|null} — header value, or null to skip emission
*/
export function serializeFallbackDetailHeader(fallbackDetail) {
if (!Array.isArray(fallbackDetail) || fallbackDetail.length === 0) {
return null;
}
const full = jsonStringifyAscii(fallbackDetail);
if (Buffer.byteLength(full, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
return full;
}
// Cap exceeded — drop tail tuples until [...kept, sentinel] fits.
// Linear scan from the full array down to 0 kept tuples. Worst case O(n^2)
// on serialisation length, but n is bounded by chain length (small) so this
// is fine in practice.
for (let kept = fallbackDetail.length - 1; kept >= 0; kept--) {
const omitted = fallbackDetail.length - kept;
const sentinel = { truncated: true, omitted_hops: omitted };
const candidate = jsonStringifyAscii([...fallbackDetail.slice(0, kept), sentinel]);
if (Buffer.byteLength(candidate, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
return candidate;
}
}
// Even an array containing only the sentinel exceeds the cap — produce the
// shortest possible valid sentinel value. This branch should be unreachable
// for any realistic chain (the sentinel itself is ~45 bytes for omitted_hops
// up to 9999).
return jsonStringifyAscii([{ truncated: true, omitted_hops: fallbackDetail.length }]);
}
/**
* JSON.stringify wrapper that escapes every non-ASCII code point as \uXXXX
* so the result is safe to embed in an HTTP header value (RFC 7230 §3.2.6
* field-vchar). JSON itself accepts both literal Unicode and \uXXXX escapes,
* so JSON.parse round-trips correctly.
*
* Surrogate-pair handling: characters above U+FFFF (emoji etc.) are already
* emitted as JS surrogate pairs by the string iterator; each surrogate is
* a code unit in range 0xD8000xDFFF, which our >= 0x80 guard catches.
*
* @param {unknown} value
* @returns {string}
*/
function jsonStringifyAscii(value) {
// The replace pattern is the UTF-8 literal byte range for code points
// U+0080..U+FFFF (every non-ASCII BMP character). U+0080 is non-printable,
// so the source line can render as the empty character class "[-...]" in
// editors that hide it — the range is intentional and load-bearing for
// RFC 7230 §3.2.6 compliance.
return JSON.stringify(value).replace(/[€-￿]/g, (ch) => {
return '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
});
}
/**
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
* failure tuples are non-empty. Returns the base object unchanged otherwise.
*
* D40 (issue #7).
*
* @param {Record<string,string>} baseHeaders
* @param {Array<object>|null|undefined} fallbackDetail
* @returns {Record<string,string>}
*/
function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
const value = serializeFallbackDetailHeader(fallbackDetail);
if (value === null) return baseHeaders;
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
}
// ── Route handlers ────────────────────────────────────────────────────────
/**
@@ -894,6 +1003,7 @@ async function handleChatCompletions(req, res) {
fallbackHops,
originalError,
triedProviders,
fallbackDetail, // D40 (issue #7): per-hop failure tuples for X-OLP-Fallback-Detail
} = fallbackResult;
// ── Chain exhausted or non-trigger error ─────────────────────────────────
@@ -943,18 +1053,23 @@ async function handleChatCompletions(req, res) {
fallbackHops: fallbackHops ?? 0,
});
// Send error with standard OLP headers + optional exhausted header
// Send error with standard OLP headers + optional exhausted header +
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
// gating planned for Phase 2 with lib/keys.mjs.
const payload = JSON.stringify({
error: {
message: originalError?.message ?? 'Provider error',
type: 'provider_error',
},
});
const detailHeader = withFallbackDetailHeader({}, fallbackDetail);
res.writeHead(errStatus, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...errorOlpHeaders,
...exhaustedHeader,
...detailHeader,
});
res.end(payload);
return;
@@ -974,7 +1089,13 @@ async function handleChatCompletions(req, res) {
const cacheStatus = bypassCacheForServingHop ? 'bypass'
: (lastHopWasCached || (preCheckHit && fallbackHops === 0)) ? 'hit'
: 'miss';
const headers = olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops });
// D40 (issue #7): when at least one prior hop failed before this success,
// surface X-OLP-Fallback-Detail with the failure trail. Header is omitted
// when fallbackDetail is empty (single-hop success).
const headers = withFallbackDetailHeader(
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
fallbackDetail,
);
if (ir.stream) {
// Streaming response path: burst replay from buffered chunks.