fix(relay): strip stale upstream encoding headers on bifrost error response

The new !upstream.ok error path copies upstream.headers (which can carry a
content-length/content-encoding/transfer-encoding for the ORIGINAL upstream
body) onto the freshly re-serialized JSON error Response, causing a
byte-length mismatch. Route the headers through stripStaleEncodingHeaders()
before serializing the error body, and add a regression test that mocks an
upstream Response with an explicit content-length header.

Co-authored-by: OPX-Aminul <305739020+OPX-Aminul@users.noreply.github.com>
This commit is contained in:
Markus Hartung
2026-08-19 21:18:15 -03:00
parent aa3cc036a4
commit 957913627f
2 changed files with 57 additions and 2 deletions

View File

@@ -33,6 +33,7 @@ import {
import { getProviderPluginManifestEntryForModel } from "@omniroute/open-sse/config/providerPluginManifestRegistry.ts";
import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts";
import { finalizeReadableStream } from "./streamFinalizer";
import { stripStaleEncodingHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders.ts";
import {
clearBifrostFailure,
getActiveBifrostCooldown,
@@ -125,7 +126,7 @@ async function forwardToBifrost(
sanitizeErrorMessage(parsed.message),
parsed.responseBody
);
const errorHeaders = new Headers(headers);
const errorHeaders = stripStaleEncodingHeaders(headers);
errorHeaders.set("Content-Type", "application/json");
if (parsed.retryAfterMs && parsed.retryAfterMs > 0) {
errorHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000)));
@@ -174,7 +175,8 @@ async function forwardToBifrost(
startTime,
clientIp,
userAgent,
upstream.status >= 200 && upstream.status < 300 ? "success" : "error",
// upstream.ok is guaranteed true here (the !upstream.ok branch above returns early).
"success",
upstream.status
);

View File

@@ -163,6 +163,59 @@ test("relay route: normalizes HTML 502 from Bifrost into JSON error (Issue #1)",
restoreEnv();
});
test("relay route: strips stale upstream content-length before serializing JSON error body", async () => {
setupBifrostEnv();
const relayToken = seedRelayToken(`relay_err_${Date.now()}`);
// The upstream Response carries an EXPLICIT content-length for its own (HTML)
// body. Once the route replaces that body with a freshly-serialized JSON error,
// a stale content-length copied verbatim onto the outgoing Response would
// mismatch the real byte length of the new body.
globalThis.fetch = async () => {
const html = "<html><body>404 page not found, upstream sidecar unreachable</body></html>";
return new Response(html, {
status: 404,
headers: {
"content-type": "text/html",
"content-length": String(Buffer.byteLength(html)),
"content-encoding": "gzip",
"transfer-encoding": "chunked",
},
});
};
const { POST } = await import(
`../../../../src/app/api/v1/relay/chat/completions/route.ts?case=${Date.now()}-${Math.random()}`
);
const req = new Request("http://localhost/api/v1/relay/chat/completions", {
method: "POST",
headers: {
authorization: `Bearer ${relayToken.rawToken}`,
"content-type": "application/json",
"x-request-id": "relay-err-stale-length",
},
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }),
});
const res = await POST(req);
assert.equal(res.status, 404);
assert.equal(res.headers.get("content-encoding"), null, "stale content-encoding must be stripped");
assert.equal(res.headers.get("transfer-encoding"), null, "stale transfer-encoding must be stripped");
const raw = await res.text();
const declaredLength = res.headers.get("content-length");
if (declaredLength !== null) {
assert.equal(
Number(declaredLength),
Buffer.byteLength(raw),
"content-length, if present, must match the actual serialized JSON error body"
);
}
restoreEnv();
});
test("relay route: upstream 401 recorded as analytics error not success (Issue #3)", async () => {
setupBifrostEnv();
const relayToken = seedRelayToken(`relay_err_${Date.now()}`);