Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/http/requestJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function requestJson(
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await fetchImpl(url, init);
const raw = await response.json();
const raw = await readResponseBody(response);
return { response, raw };
} catch (error) {
lastError = error;
Expand All @@ -33,6 +33,46 @@ export async function requestJson(
});
}

async function readResponseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text.trim()) return null;

let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// Agnes occasionally returns plain-text or HTML error bodies.
}
return response.ok ? body : sanitizeResponseBody(body);
}

function sanitizeResponseBody(value: unknown, depth = 0): unknown {
if (depth >= 20) return "[TRUNCATED]";
if (typeof value === "string") {
return value
.replace(/\bBearer\s+[^\s<]+/gi, "Bearer [REDACTED]")
.replace(/\bsk-[A-Za-z0-9_-]+\b/g, "[REDACTED]")
.replace(
/([?&](?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)=)[^&#\s]+/gi,
"$1[REDACTED]",
);
}
if (Array.isArray(value)) {
return value.map((entry) => sanitizeResponseBody(entry, depth + 1));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
/^(?:(?:client|refresh|access|x)[_-])?(?:authorization|api[_-]?key|token|secret|password)$/i.test(key)
? "[REDACTED]"
: sanitizeResponseBody(entry, depth + 1),
]),
);
}
return value;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
54 changes: 54 additions & 0 deletions test/unit/request-json.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";

import { requestJson } from "../../dist/http/requestJson.js";

test("preserves and sanitizes a JSON HTTP error body", async () => {
const fetchImpl = async () =>
new Response(
JSON.stringify({
error: { message: "unsupported resolution and frame count" },
api_key: "sk-secret-value",
authorization: "Bearer secret-token",
client_secret: "client-secret-value",
refresh_token: "refresh-token-value",
callback: "https://example.test/callback?token=secret-query-value",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);

const { response, raw } = await requestJson(fetchImpl, "https://example.test", {});

assert.equal(response.status, 400);
assert.deepEqual(raw, {
error: { message: "unsupported resolution and frame count" },
api_key: "[REDACTED]",
authorization: "[REDACTED]",
client_secret: "[REDACTED]",
refresh_token: "[REDACTED]",
callback: "https://example.test/callback?token=[REDACTED]",
});
});

test("preserves a plain-text HTTP error body without retrying", async () => {
let calls = 0;
const fetchImpl = async () => {
calls += 1;
return new Response("invalid request for sk-secret-value", { status: 400 });
};

const { response, raw } = await requestJson(fetchImpl, "https://example.test", {});

assert.equal(response.status, 400);
assert.equal(raw, "invalid request for [REDACTED]");
assert.equal(calls, 1);
});

test("represents an empty HTTP error body as null", async () => {
const fetchImpl = async () => new Response(null, { status: 502 });

const { response, raw } = await requestJson(fetchImpl, "https://example.test", {});

assert.equal(response.status, 502);
assert.equal(raw, null);
});