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
51 changes: 51 additions & 0 deletions valhalla/jawn/src/lib/proxy/ProviderClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,45 @@ export function buildTargetUrl(originalUrl: URL, apiBase: string): URL {
return new URL(`${apiBaseUrl.origin}${pathname}${originalUrl.search}`);
}

// Hard cap so a hostile or misconfigured provider cannot make the proxy wait
// for an unbounded amount of time before retrying.
const MAX_RETRY_AFTER_MS = 60_000;

/**
* Parse an RFC 7231 `Retry-After` header value into milliseconds.
*
* Accepts either a non-negative integer number of seconds or an HTTP-date.
* Returns `null` when the header is absent, malformed, or already in the past
* — in which case the caller should fall back to its default backoff strategy.
* The returned value is clamped to {@link MAX_RETRY_AFTER_MS} to prevent abuse.
*/
export function parseRetryAfter(
value: string | null | undefined,
now: number = Date.now()
): number | null {
if (!value) return null;
const trimmed = value.trim();
if (trimmed === "") return null;

// delta-seconds form (RFC 7231 §7.1.3 first alternative)
if (/^\d+$/.test(trimmed)) {
const seconds = Number.parseInt(trimmed, 10);
if (Number.isNaN(seconds) || seconds < 0) return null;
return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS);
}

// HTTP-date form (RFC 7231 §7.1.3 second alternative)
const date = Date.parse(trimmed);
if (Number.isNaN(date)) return null;
const delta = date - now;
if (delta <= 0) return null;
return Math.min(delta, MAX_RETRY_AFTER_MS);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function callProviderWithRetry(
callProps: CallProps,
retryOptions: RetryOptions
Expand All @@ -82,6 +121,18 @@ export async function callProviderWithRetry(
lastResponse = res;
// Throw an error if the status code is 429
if (res.status === 429 || res.status === 500 || res.status === 522) {
// For 429 the provider can explicitly tell us how long to wait via
// the Retry-After header. Honour it as a minimum delay before the
// next retry kicks in; async-retry's exponential backoff still
// applies on top, so this only ever lengthens the wait.
if (res.status === 429) {
const retryAfterMs = parseRetryAfter(
res.headers.get("retry-after")
);
if (retryAfterMs !== null && retryAfterMs > 0) {
await sleep(retryAfterMs);
}
}
throw new Error(`Status code ${res.status}`);
}
return res;
Expand Down
50 changes: 50 additions & 0 deletions valhalla/jawn/src/lib/proxy/__tests__/ProviderClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { parseRetryAfter } from "../ProviderClient";

describe("parseRetryAfter", () => {
// Frozen reference point so the HTTP-date cases are deterministic.
const NOW = Date.UTC(2026, 4, 16, 12, 0, 0); // 2026-05-16T12:00:00Z

it("returns null for missing or empty values", () => {
expect(parseRetryAfter(null)).toBeNull();
expect(parseRetryAfter(undefined)).toBeNull();
expect(parseRetryAfter("")).toBeNull();
expect(parseRetryAfter(" ")).toBeNull();
});

it("parses delta-seconds form into milliseconds", () => {
expect(parseRetryAfter("0")).toBe(0);
expect(parseRetryAfter("1")).toBe(1_000);
expect(parseRetryAfter("30")).toBe(30_000);
});

it("trims surrounding whitespace before parsing seconds", () => {
expect(parseRetryAfter(" 5 ")).toBe(5_000);
});

it("caps very large delta-seconds at 60 seconds", () => {
// 1 hour Retry-After should be clamped so a hostile provider cannot freeze
// the proxy for an unbounded amount of time.
expect(parseRetryAfter("3600")).toBe(60_000);
});

it("rejects non-numeric, non-date strings", () => {
expect(parseRetryAfter("soon")).toBeNull();
expect(parseRetryAfter("1.5")).toBeNull();
expect(parseRetryAfter("-1")).toBeNull();
});

it("parses HTTP-date form into milliseconds from `now`", () => {
const fiveSecondsFromNow = new Date(NOW + 5_000).toUTCString();
expect(parseRetryAfter(fiveSecondsFromNow, NOW)).toBe(5_000);
});

it("returns null for HTTP-date values in the past", () => {
const tenSecondsAgo = new Date(NOW - 10_000).toUTCString();
expect(parseRetryAfter(tenSecondsAgo, NOW)).toBeNull();
});

it("caps HTTP-date values far in the future at 60 seconds", () => {
const oneHourFromNow = new Date(NOW + 3_600_000).toUTCString();
expect(parseRetryAfter(oneHourFromNow, NOW)).toBe(60_000);
});
});