mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
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:
+123
-2
@@ -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 0xD800–0xDFFF, 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.
|
||||
|
||||
Reference in New Issue
Block a user