Skip to content
Merged
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
34 changes: 26 additions & 8 deletions packages/jouska/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,8 +1323,8 @@ const forwardAuthSchema = z.object({
* identity reaches the upstream (`x-user-id` is the usual case).
*/
copyResponseHeaders: authHeaderNames('forwardAuth.copyResponseHeaders').nonempty().default([]),
/** Deadline for the auth exchange, shorter than any upstream attempt default. */
timeoutMs: z.number().int().positive().max(5_000).default(2_000),
/** Deadline for the auth exchange, shorter than any upstream attempt default. `0` disables it. */
timeoutMs: z.number().int().min(0).max(5_000).default(2_000),
/**
* Serve the upstream even when the auth endpoint cannot be reached. Absent
* means fail closed — the default exists so that an auth outage is an outage,
Expand Down Expand Up @@ -1905,13 +1905,16 @@ const routeBehaviour = {
* then a dead socket, and the event reported a successful 200. The body now
* has deadlines of its own; see `firstChunkTimeoutMs` and
* `streamIdleTimeoutMs`.
*
* The ceiling is 120s rather than 30s because an upstream may be slow to
* answer at all: a cold-starting container or a queued request can take a
* minute to produce headers, and there is nothing this proxy can do about it
* except wait or give up.
*
* `0` disables the deadline: no head timer is armed at all. The number means
* "off", not "fire immediately" — which is what a raw `setTimeout(0)` would
* do, and why the runtime guards the value instead of passing it through.
*/
timeoutMs: z.number().int().positive().max(120_000).default(10_000),
timeoutMs: z.number().int().min(0).max(120_000).default(10_000),
/**
* Ceiling on all attempts combined, including backoff — still to headers.
*
Expand All @@ -1926,8 +1929,11 @@ const routeBehaviour = {
* not for the transmission of the whole response" — for its entire history,
* and a total-duration cap is what makes a long streamed answer fail for no
* reason. The idle deadlines below are the bound instead.
*
* `0` disables the budget entirely: the walk has no overall deadline and runs
* until a per-attempt or body deadline ends it.
*/
totalTimeoutMs: z.number().int().positive().max(300_000).default(30_000),
totalTimeoutMs: z.number().int().min(0).max(300_000).default(30_000),
/**
* How long to wait for the **first byte of the body** after headers arrive.
*
Expand All @@ -1939,8 +1945,10 @@ const routeBehaviour = {
*
* A non-streaming response sends headers and body together, so this never
* fires for one.
*
* `0` disables the deadline — the first byte may take as long as it takes.
*/
firstChunkTimeoutMs: z.number().int().positive().max(600_000).default(60_000),
firstChunkTimeoutMs: z.number().int().min(0).max(600_000).default(60_000),
/**
* How long the body may go without a byte once it has started.
*
Expand All @@ -1952,8 +1960,11 @@ const routeBehaviour = {
* jouska never injects keep-alives of its own. Feeding this deadline from
* inside the proxy would guarantee it never fires, which is the opposite of
* knowing whether the upstream is alive.
*
* `0` disables the deadline — a stream that goes quiet is then the client's
* problem to notice, not the proxy's.
*/
streamIdleTimeoutMs: z.number().int().positive().max(600_000).default(60_000),
streamIdleTimeoutMs: z.number().int().min(0).max(600_000).default(60_000),
/**
* Extra attempts after the first failure. Only idempotent methods retry.
*
Expand Down Expand Up @@ -2456,7 +2467,14 @@ export const configSchema = z
*/
.superRefine((config, ctx) => {
config.routes.forEach((entry, index) => {
if (entry.timeoutMs > entry.totalTimeoutMs) {
// A `0` on either side means that deadline is disabled — an off deadline
// cannot be exceeded, so the contradiction only exists between two
// numbers that are both actually armed.
if (
entry.timeoutMs > 0 &&
entry.totalTimeoutMs > 0 &&
entry.timeoutMs > entry.totalTimeoutMs
) {
ctx.addIssue({
code: 'custom',
path: ['routes', index, 'timeoutMs'],
Expand Down
8 changes: 6 additions & 2 deletions packages/jouska/src/internal/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ const runForwardAuth = async (
headers.set('x-forwarded-method', request.method);
headers.set('x-forwarded-uri', `${requestUrl.pathname}${requestUrl.search}`);

const signals: AbortSignal[] = [AbortSignal.timeout(config.timeoutMs)];
// `timeoutMs: 0` disables the deadline; `AbortSignal.timeout(0)` would abort
// immediately rather than never, so the signal is omitted instead.
const authDeadline = config.timeoutMs === 0 ? undefined : AbortSignal.timeout(config.timeoutMs);
const signals: AbortSignal[] = authDeadline === undefined ? [] : [authDeadline];
if (request.signal !== null && request.signal !== undefined) {
signals.push(request.signal);
}
Expand All @@ -68,7 +71,8 @@ const runForwardAuth = async (
new Request(config.url, {
method: request.method,
headers,
signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0]!,
signal:
signals.length === 0 ? null : signals.length > 1 ? AbortSignal.any(signals) : signals[0]!,
}),
);
} catch (error) {
Expand Down
32 changes: 23 additions & 9 deletions packages/jouska/src/internal/forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,13 @@ export const forward = async ({
// retry: the ratio is retries over requests, and only the walked ones belong
// in the denominator.
limits?.onRequest();
const remaining = (): number => route.totalTimeoutMs - (Date.now() - startedAt);
// `totalTimeoutMs: 0` disables the overall budget, so the remaining time is
// unbounded rather than immediately negative — a plain subtraction would
// break out of the walk before the first attempt.
const remaining = (): number =>
route.totalTimeoutMs === 0
? Number.POSITIVE_INFINITY
: route.totalTimeoutMs - (Date.now() - startedAt);
let lastError: unknown;
let held: ForwardResult | undefined;

Expand Down Expand Up @@ -423,7 +429,10 @@ export const forward = async ({
limits?.onRetry();
}

const budget = Math.min(route.timeoutMs, remaining());
// `timeoutMs: 0` disables the head deadline; the attempt then answers only
// to the overall budget that is still left.
const headCap = route.timeoutMs === 0 ? Number.POSITIVE_INFINITY : route.timeoutMs;
const budget = Math.min(headCap, remaining());
if (budget <= 0) {
lastError = new TotalTimeoutError(
`upstream did not respond within totalTimeoutMs=${route.totalTimeoutMs}`,
Expand Down Expand Up @@ -595,13 +604,18 @@ const attemptFetch = async ({
const controller = new AbortController();
// Fires only while the headers are outstanding; cleared below the moment they
// arrive, which is what makes this cancellable where `AbortSignal.timeout` is
// not.
let headDeadline: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
headDeadline = undefined;
controller.abort(
new HeadTimeoutError(`upstream did not send response headers within timeoutMs=${budget}`),
);
}, budget);
// not. An infinite budget means both deadlines are disabled (`timeoutMs: 0`
// with an unspent overall budget) — no timer is armed, because a runtime
// clamps an oversized `setTimeout` delay down to ~1ms, turning "never" into
// "immediately".
let headDeadline: ReturnType<typeof setTimeout> | undefined = Number.isFinite(budget)
? setTimeout(() => {
headDeadline = undefined;
controller.abort(
new HeadTimeoutError(`upstream did not send response headers within timeoutMs=${budget}`),
);
}, budget)
: undefined;
const clearHeadDeadline = (): void => {
if (headDeadline !== undefined) {
clearTimeout(headDeadline);
Expand Down
6 changes: 6 additions & 0 deletions packages/jouska/src/internal/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,12 +660,18 @@ const flights = new Map<string, { done: Promise<void>; release: () => void }>();
* fetches on its own, exactly as it would have without the lock. Resolving
* says nothing about whether an entry appeared; the waiter re-reads the cache
* and decides that for itself, which is why the flight carries no payload.
*
* `0` disables the bound: the waiter waits the leader out for as long as it
* takes, which is the reading `totalTimeoutMs: 0` has everywhere else.
*/
export const joinFlight = (key: Request, totalTimeoutMs: number): Promise<void> => {
const flight = flights.get(key.url);
if (flight === undefined) {
return Promise.resolve();
}
if (totalTimeoutMs === 0) {
return flight.done;
}
let timer: ReturnType<typeof setTimeout> | null = null;
return Promise.race([
flight.done,
Expand Down
13 changes: 11 additions & 2 deletions packages/jouska/src/internal/stream-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ export class StreamDeadlineError extends Error {
export interface WatchStreamOptions {
/** The upstream body to monitor. */
body: ReadableStream<Uint8Array>;
/** Deadline for the first byte, measured from response headers. */
/** Deadline for the first byte, measured from response headers. `0` disables it. */
firstChunkTimeoutMs: number;
/** Deadline between bytes, once the first has arrived. */
/** Deadline between bytes, once the first has arrived. `0` disables it. */
streamIdleTimeoutMs: number;
/**
* Cuts the upstream connection when a deadline expires.
Expand Down Expand Up @@ -153,6 +153,11 @@ export const watchStream = ({
return body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
start(controller) {
// `0` disables the deadline — `setTimeout(fn, 0)` fires on the next tick
// and would read as an instant abort rather than "never".
if (firstChunkTimeoutMs === 0) {
return;
}
arm(
firstChunkTimeoutMs,
'first_chunk_timeout',
Expand All @@ -162,6 +167,10 @@ export const watchStream = ({
},
transform(chunk, controller) {
bytes += chunk.byteLength;
if (streamIdleTimeoutMs === 0) {
controller.enqueue(chunk);
return;
}
arm(
streamIdleTimeoutMs,
'idle_timeout',
Expand Down
23 changes: 23 additions & 0 deletions packages/jouska/test/integration/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,29 @@ describe('streaming responses', () => {
expect(events[0]!.stream).toBeUndefined();
});

it('treats zero deadlines as disabled, not instantaneous', async () => {
// A raw `setTimeout(0)` fires on the next tick, which would have aborted
// every attempt before the fetch even ran. The stream here also pauses
// mid-body, which any armed body deadline would have cut.
const upstream = sseUpstream({
frames: ['data: a\n\n', 'data: b\n\n'],
gapMs: 150,
});
const { app, events } = proxied(
route({ timeoutMs: 0, totalTimeoutMs: 0, firstChunkTimeoutMs: 0, streamIdleTimeoutMs: 0 }),
upstream.fetchImpl,
);

const response = await app.request('https://p.dev/v1/messages');
const seen = await read(response);

expect(response.status).toBe(200);
expect(seen.error).toBeUndefined();
expect(seen.text).toBe('data: a\n\ndata: b\n\n');
expect(upstream.aborted()).toBe(false);
await expect(events[0]!.stream).resolves.toMatchObject({ outcome: 'complete' });
});

it('cuts a stream that never sends a first byte, and says so', async () => {
const upstream = sseUpstream({ stallAfter: 0 });
const { app, events } = proxied(
Expand Down
33 changes: 33 additions & 0 deletions packages/jouska/test/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,39 @@ describe('defineConfig', () => {
expect(config.routes[0]!.streamIdleTimeoutMs).toBe(60_000);
});

it('accepts zero as a disabled deadline', () => {
// `0` means the deadline is off, not instantaneous — which is what a raw
// `setTimeout(0)` would have made it, and why the runtime guards the value.
const config = defineConfig({
routes: [
{
match: { path: '/a' },
upstream: 'o.test',
timeoutMs: 0,
totalTimeoutMs: 0,
firstChunkTimeoutMs: 0,
streamIdleTimeoutMs: 0,
},
],
});
expect(config.routes[0]!.timeoutMs).toBe(0);
expect(config.routes[0]!.totalTimeoutMs).toBe(0);
expect(config.routes[0]!.firstChunkTimeoutMs).toBe(0);
expect(config.routes[0]!.streamIdleTimeoutMs).toBe(0);
});

it('does not read a per-attempt deadline as exceeding a disabled budget', () => {
// With `totalTimeoutMs: 0` there is no budget to exceed, so the
// contradiction the cross-field check exists for cannot arise.
expect(() =>
defineConfig({
routes: [
{ match: { path: '/a' }, upstream: 'o.test', timeoutMs: 60_000, totalTimeoutMs: 0 },
],
}),
).not.toThrow();
});

it('defaults both body deadlines to a minute', () => {
const config = defineConfig({ routes: [{ match: { path: '/a' }, upstream: 'o.test' }] });
// The same figure nginx uses for `proxy_read_timeout`, measured the same way.
Expand Down
25 changes: 25 additions & 0 deletions packages/jouska/test/unit/stream-watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,31 @@ describe('watchStream', () => {
expect(reports[0]!.outcome).toBe('complete');
});

it('lets a stream that goes silent live when both deadlines are disabled', async () => {
// `0` means the deadline is off. `setTimeout(0)` would have fired on the
// next tick and read as an instant abort, which is why the monitor guards
// the value rather than arming the timer anyway. The upstream here pauses
// 150ms mid-stream — long enough that any armed deadline, including one
// misread as 0, would have cut it.
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode('data: a\n\n'));
// oxlint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 150));
controller.enqueue(encoder.encode('data: b\n\n'));
controller.close();
},
});
const { stream, reports, aborts } = watched(body, 0, 0);
const seen = await drain(stream);

expect(seen.text).toBe('data: a\n\ndata: b\n\n');
expect(seen.error).toBeUndefined();
expect(reports[0]!.outcome).toBe('complete');
expect(aborts).toEqual([]);
});

it('fails the stream when no first byte arrives, and cuts the upstream', async () => {
const { stream, reports, aborts } = watched(upstream(FRAMES, 10, { stallAfter: 0 }), 50, 1_000);
const seen = await drain(stream);
Expand Down
10 changes: 5 additions & 5 deletions workers/admin-panel/web/src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,22 +390,22 @@ export const t = {
timeoutMs: {
label: '单次尝试等响应头',
unit: '毫秒',
help: '只管到上游发出响应头为止;正文有自己的两个时限。',
help: '只管到上游发出响应头为止;正文有自己的两个时限。填 0 表示不设限。',
},
totalTimeoutMs: {
label: '重试总时限',
unit: '毫秒',
help: '所有尝试加退避的总上限,同样只管到响应头。',
help: '所有尝试加退避的总上限,同样只管到响应头。填 0 表示不设限。',
},
firstChunkTimeoutMs: {
label: '等正文第一个字节',
unit: '毫秒',
help: '响应头之后等首字节;模型思考很久属于正常,这里要给够。',
help: '响应头之后等首字节;模型思考很久属于正常,这里要给够。填 0 表示不设限。',
},
streamIdleTimeoutMs: {
label: '正文空闲时限',
unit: '毫秒',
help: '两个字节之间最长静默;只要还在滴数据就一直转发,没有总时长上限。',
help: '两个字节之间最长静默;只要还在滴数据就一直转发,没有总时长上限。填 0 表示不设限。',
},
retries: {
label: '额外重试次数',
Expand Down Expand Up @@ -551,7 +551,7 @@ export const t = {
copyResponseHeaders: '从鉴权响应抄进上游请求的头',
copyResponseHeadersHelp: '比如 `x-user-id`——鉴权端点认完人之后,用这些头告诉上游「是谁」。',
timeoutMs: '鉴权请求超时',
timeoutMsHelp: '超过就按端点不可用处理。留空按默认的 2000 毫秒。',
timeoutMsHelp: '超过就按端点不可用处理。留空按默认的 2000 毫秒;填 0 表示不设限。',
failOpen: '端点不可达时放行',
failOpenHelp:
'关闭时端点挂了返回 503;打开后端点挂了所有请求直接放行——只在「可用性高于准入」时才考虑。',
Expand Down
10 changes: 5 additions & 5 deletions workers/admin-panel/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,12 @@ export interface FieldRisk {

/* ---------- schema 边界(对齐 config.ts) ---------- */

/** number 字段的取值范围与默认值,与 routeBehaviour 一致。 */
/** number 字段的取值范围与默认值,与 routeBehaviour 一致。超时类字段 min 为 0:0 表示不设限。 */
export const NUMERIC_BOUNDS = {
timeoutMs: { min: 1, max: 120_000, default: 10_000 },
totalTimeoutMs: { min: 1, max: 300_000, default: 30_000 },
firstChunkTimeoutMs: { min: 1, max: 600_000, default: 60_000 },
streamIdleTimeoutMs: { min: 1, max: 600_000, default: 60_000 },
timeoutMs: { min: 0, max: 120_000, default: 10_000 },
totalTimeoutMs: { min: 0, max: 300_000, default: 30_000 },
firstChunkTimeoutMs: { min: 0, max: 600_000, default: 60_000 },
streamIdleTimeoutMs: { min: 0, max: 600_000, default: 60_000 },
retries: { min: 0, max: 100, default: 0 },
retryBackoffMs: { min: 0, max: 5_000, default: 100 },
/** 委托鉴权子请求的时限;schema 上限 5000,默认 2000。 */
Expand Down
Loading