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
26 changes: 26 additions & 0 deletions apps/gateway/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AppEnv } from "./app.js";
import { createApp } from "./app.js";
import type { LogFields, Logger, LogLevel } from "./logging.js";
import { HelmHttpError, handleError, openAIErrorEnvelope } from "./middleware/error-handler.js";
import { requestTimedOut } from "./middleware/limits.js";

interface Captured {
level: LogLevel;
Expand Down Expand Up @@ -126,6 +127,31 @@ describe("createApp: trace_id, logging, error handling", () => {
expect(lines.some((l) => l.level === "error")).toBe(true);
});

it("marks the request context as timed out while late route work continues", async () => {
const { logger } = fakeLogger();
const app = createApp({
logger,
limits: { maxBodyBytes: 1024, requestTimeoutMs: 5 },
genTraceId: () => "trace-timeout",
});
let lateTimedOut: boolean | null = null;
let lateDone: (() => void) | null = null;
const late = new Promise<void>((resolve) => {
lateDone = resolve;
});
app.get("/slow", async (c) => {
await new Promise((resolve) => setTimeout(resolve, 25));
lateTimedOut = requestTimedOut(c);
lateDone?.();
return c.text("late");
});

const res = await app.request("/slow");
expect(res.status).toBe(504);
await late;
expect(lateTimedOut).toBe(true);
});

it("does not leak Authorization header into logs or error bodies", async () => {
const { logger, lines } = fakeLogger();
const app = createApp({ logger });
Expand Down
8 changes: 7 additions & 1 deletion apps/gateway/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { Hono } from "hono";
import { readBuildInfo } from "./build-info.js";
import type { Logger } from "./logging.js";
import { handleError } from "./middleware/error-handler.js";
import { bodyLimit, type LimitsConfig, timeout } from "./middleware/limits.js";
import {
bodyLimit,
type LimitsConfig,
type RequestTimeoutState,
timeout,
} from "./middleware/limits.js";
import { normalizeHeaders } from "./middleware/normalize-headers.js";
import { requestLoggerMiddleware } from "./middleware/request-logger.js";
import { traceIdMiddleware } from "./middleware/trace-id.js";
Expand All @@ -26,6 +31,7 @@ export type AppEnv = {
Variables: {
trace_id: string;
logger: Logger;
request_timeout?: RequestTimeoutState;
};
};

Expand Down
25 changes: 23 additions & 2 deletions apps/gateway/src/middleware/limits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { makeHelmError } from "@helm/shared";
import type { MiddlewareHandler } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import type { AppEnv } from "../app.js";
import { HelmHttpError } from "./error-handler.js";

Expand All @@ -8,6 +8,19 @@ export interface LimitsConfig {
requestTimeoutMs: number;
}

export interface RequestTimeoutState {
signal: AbortSignal;
timedOut: boolean;
}

export function requestSignal(c: Context<AppEnv>): AbortSignal {
return c.get("request_timeout")?.signal ?? c.req.raw.signal;
}

export function requestTimedOut(c: Context<AppEnv>): boolean {
return c.get("request_timeout")?.timedOut === true;
}

function tooLarge(traceId: string): HelmHttpError {
return new HelmHttpError(
makeHelmError({
Expand Down Expand Up @@ -51,7 +64,15 @@ export function timeout(cfg: LimitsConfig): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const traceId = c.get("trace_id");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), cfg.requestTimeoutMs);
const state: RequestTimeoutState = {
signal: AbortSignal.any([c.req.raw.signal, controller.signal]),
timedOut: false,
};
c.set("request_timeout", state);
const timer = setTimeout(() => {
state.timedOut = true;
controller.abort();
}, cfg.requestTimeoutMs);
try {
await Promise.race([
next(),
Expand Down
20 changes: 15 additions & 5 deletions apps/gateway/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { streamSSE } from "hono/streaming";
import type { AppEnv } from "../app.js";
import { INTERNAL_API_KEY_ID } from "../internal-key.js";
import { HelmHttpError } from "../middleware/error-handler.js";
import { requestSignal, requestTimedOut } from "../middleware/limits.js";
import { type ServingAccount, stampServingAccount } from "../runtime/serving-account.js";
import type { WriteQueue } from "../runtime/write-queue.js";
import { downgradeClientFastModeIfDisallowed } from "./fast-mode.js";
Expand All @@ -45,6 +46,7 @@ import {
captureEnabled,
createSseCapture,
createStreamGenerationTimer,
decisionForTimedOutRequest,
type PayloadCaptureDeps,
persistPayload,
tokensFromUsage,
Expand Down Expand Up @@ -492,8 +494,9 @@ export function registerChatRoutes(app: Hono<AppEnv>, deps: ChatRouteDeps): void
// affected by anything that touches the decision after the response returns.
// With a write queue wired, the insert is deferred + batched off the hot path.
const persist = async (decision: DecisionRecord) => {
const decisionSnapshot = requestTimedOut(c) ? decisionForTimedOutRequest(decision) : decision;
const input: InsertTelemetryInput = {
decision: deps.redact(decision) as DecisionRecord,
decision: deps.redact(decisionSnapshot) as DecisionRecord,
apiKeyId: identity.keyId,
createdAt: new Date(),
};
Expand All @@ -517,20 +520,27 @@ export function registerChatRoutes(app: Hono<AppEnv>, deps: ChatRouteDeps): void
responseJson: string | null,
upstreamRequestJson: string | null,
) => {
const capturedResponseJson = requestTimedOut(c) ? null : responseJson;
if (deps.writes !== undefined) {
if (!captureEnabled(deps)) return;
deps.writes.enqueuePayload({
requestId: traceId,
requestJson,
responseJson,
responseJson: capturedResponseJson,
upstreamRequestJson,
createdAt: new Date(deps.now()),
});
return;
}
await persistPayload(
deps,
{ requestId: traceId, requestJson, responseJson, upstreamRequestJson, now: deps.now() },
{
requestId: traceId,
requestJson,
responseJson: capturedResponseJson,
upstreamRequestJson,
now: deps.now(),
},
(msg) => c.get("logger").log("warn", msg, { trace_id: traceId }),
);
};
Expand Down Expand Up @@ -720,7 +730,7 @@ export function registerChatRoutes(app: Hono<AppEnv>, deps: ChatRouteDeps): void
blockedModels: identity.caps?.blockedModels ?? null,
},
},
c.req.raw.signal,
requestSignal(c),
classifyOverrides,
);
// The subscription the pool selected (null for a configured/non-OAuth provider),
Expand Down Expand Up @@ -805,7 +815,7 @@ export function registerChatRoutes(app: Hono<AppEnv>, deps: ChatRouteDeps): void
try {
for await (const item of withHeartbeat(stream, {
heartbeatMs,
signal: c.req.raw.signal,
signal: requestSignal(c),
})) {
if (item.type === "beat") {
if (atEventBoundary(lastWrite)) await sse.write(HEARTBEAT_COMMENT);
Expand Down
12 changes: 8 additions & 4 deletions apps/gateway/src/routes/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { streamSSE } from "hono/streaming";
import type { AppEnv } from "../app.js";
import { type ConcurrencyGatePort, concurrencyReleaseGuard } from "../middleware/concurrency.js";
import { estimateRequestTokens } from "../middleware/estimate-tokens.js";
import { requestSignal, requestTimedOut } from "../middleware/limits.js";
import { stampServingAccount } from "../runtime/serving-account.js";
import { atEventBoundary, HEARTBEAT_COMMENT, withHeartbeat } from "./heartbeat.js";
import { resolveMemoryScope } from "./memory-scope.js";
Expand Down Expand Up @@ -224,7 +225,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
const acquired = await deps.concurrencyGate.acquire({
keyId: identity.keyId,
limit: identity.caps?.concurrencyLimit ?? null,
signal: c.req.raw.signal,
signal: requestSignal(c),
});
if (!acquired.ok) {
c.header("retry-after", String(acquired.retryAfterSeconds));
Expand Down Expand Up @@ -268,7 +269,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
const counted = await deps.countTokens(
{ ...(native as Record<string, unknown>), model: route.model },
identity,
c.req.raw.signal,
requestSignal(c),
);
return c.json(counted as Record<string, unknown>);
} catch {
Expand Down Expand Up @@ -340,7 +341,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
// PipelineError(invalid_request) for an empty request.
let result: PipelineRunResult;
try {
result = await deps.pipeline.run(ir, identity, c.req.raw.signal);
result = await deps.pipeline.run(ir, identity, requestSignal(c));
} catch (err) {
if (err instanceof PipelineError) {
return sendError(c, {
Expand Down Expand Up @@ -379,7 +380,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
try {
for await (const item of withHeartbeat(result.streamIR(), {
heartbeatMs,
signal: c.req.raw.signal,
signal: requestSignal(c),
})) {
if (item.type === "beat") {
if (atEventBoundary(lastWrite)) await sse.write(HEARTBEAT_COMMENT);
Expand Down Expand Up @@ -440,6 +441,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
decision: result.decision,
requestJson,
responseJson: captureBodies ? captured.join("") : null,
timedOut: requestTimedOut(c),
upstreamRequestJson: result.upstreamRequest ?? null,
},
(msg) => c.get("logger").log("warn", msg, { trace_id: traceId }),
Expand Down Expand Up @@ -471,6 +473,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
decision: result.decision,
requestJson,
responseJson: null,
timedOut: requestTimedOut(c),
upstreamRequestJson: result.upstreamRequest ?? null,
},
(msg) => c.get("logger").log("warn", msg, { trace_id: traceId }),
Expand All @@ -497,6 +500,7 @@ export function registerGeminiRoute(app: Hono<AppEnv>, deps: GeminiRouteDeps): v
decision: result.decision,
requestJson,
responseJson: captureBodies ? JSON.stringify(body) : null,
timedOut: requestTimedOut(c),
upstreamRequestJson: result.upstreamRequest ?? null,
},
(msg) => c.get("logger").log("warn", msg, { trace_id: traceId }),
Expand Down
11 changes: 7 additions & 4 deletions apps/gateway/src/routes/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { AppEnv } from "../app.js";
import { type ConcurrencyGatePort, concurrencyReleaseGuard } from "../middleware/concurrency.js";
import { estimateRequestTokens } from "../middleware/estimate-tokens.js";
import { requestSignal, requestTimedOut } from "../middleware/limits.js";
import type { GeminiRateLimiterPort } from "./gemini.js";
import {
type ImageAttempt,
Expand Down Expand Up @@ -146,7 +147,7 @@ export function registerImagesRoute(app: Hono<AppEnv>, deps: ImagesRouteDeps): v
const acquired = await deps.concurrencyGate.acquire({
keyId: identity.keyId,
limit: identity.caps?.concurrencyLimit ?? null,
signal: c.req.raw.signal,
signal: requestSignal(c),
});
if (!acquired.ok) {
c.header("retry-after", String(acquired.retryAfterSeconds));
Expand Down Expand Up @@ -255,13 +256,13 @@ export function registerImagesRoute(app: Hono<AppEnv>, deps: ImagesRouteDeps): v
contents: [{ role: "user", parts: [{ text: parsed.data.prompt }] }],
generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
},
{ signal: c.req.raw.signal, captureUpstream },
{ signal: requestSignal(c), captureUpstream },
);
upstream = mapGeminiToImages((native ?? {}) as Record<string, unknown>);
} else {
upstream = (await target.client.imageGeneration?.(
{ ...parsed.data, model: target.providerModel },
{ signal: c.req.raw.signal, captureUpstream },
{ signal: requestSignal(c), captureUpstream },
)) as Record<string, unknown>;
}
const usage = (upstream as { usage?: Record<string, unknown> }).usage ?? null;
Expand All @@ -277,7 +278,7 @@ export function registerImagesRoute(app: Hono<AppEnv>, deps: ImagesRouteDeps): v
permittedChain.targets,
deps.breaker,
attempt,
c.req.raw.signal,
requestSignal(c),
);

// 6b) Terminal failure. A client abort is a NON-provider fault → no record, no 5xx.
Expand All @@ -303,6 +304,7 @@ export function registerImagesRoute(app: Hono<AppEnv>, deps: ImagesRouteDeps): v
decision,
requestJson,
responseJson: null,
timedOut: requestTimedOut(c),
upstreamRequestJson: null,
},
log,
Expand Down Expand Up @@ -343,6 +345,7 @@ export function registerImagesRoute(app: Hono<AppEnv>, deps: ImagesRouteDeps): v
decision,
requestJson,
responseJson,
timedOut: requestTimedOut(c),
upstreamRequestJson: result.upstreamRequestJson,
},
log,
Expand Down
9 changes: 6 additions & 3 deletions apps/gateway/src/routes/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { AppEnv } from "../app.js";
import { type ConcurrencyGatePort, concurrencyReleaseGuard } from "../middleware/concurrency.js";
import { estimateRequestTokens } from "../middleware/estimate-tokens.js";
import { requestSignal, requestTimedOut } from "../middleware/limits.js";
import type { GeminiRateLimiterPort } from "./gemini.js";
import {
type ImageAttempt,
Expand Down Expand Up @@ -220,7 +221,7 @@ export function registerInteractionsRoute(app: Hono<AppEnv>, deps: InteractionsR
const acquired = await deps.concurrencyGate.acquire({
keyId: identity.keyId,
limit: identity.caps?.concurrencyLimit ?? null,
signal: c.req.raw.signal,
signal: requestSignal(c),
});
if (!acquired.ok) {
c.header("retry-after", String(acquired.retryAfterSeconds));
Expand Down Expand Up @@ -336,7 +337,7 @@ export function registerInteractionsRoute(app: Hono<AppEnv>, deps: InteractionsR
parsed.data.generation_config ?? null,
),
},
{ signal: c.req.raw.signal, captureUpstream },
{ signal: requestSignal(c), captureUpstream },
)) as Record<string, unknown>;
const interactionsBody = nativeToInteractions(native, `int_${traceId}`);
const usageBody = usageBodyFromNative(native);
Expand All @@ -348,7 +349,7 @@ export function registerInteractionsRoute(app: Hono<AppEnv>, deps: InteractionsR
};
};

const outcome = await runImageChain(geminiTargets, deps.breaker, attempt, c.req.raw.signal);
const outcome = await runImageChain(geminiTargets, deps.breaker, attempt, requestSignal(c));

// 6b) Terminal failure. A client abort is a NON-provider fault → no record.
if (!outcome.ok) {
Expand All @@ -373,6 +374,7 @@ export function registerInteractionsRoute(app: Hono<AppEnv>, deps: InteractionsR
decision,
requestJson,
responseJson: null,
timedOut: requestTimedOut(c),
upstreamRequestJson: null,
},
log,
Expand Down Expand Up @@ -412,6 +414,7 @@ export function registerInteractionsRoute(app: Hono<AppEnv>, deps: InteractionsR
decision,
requestJson,
responseJson,
timedOut: requestTimedOut(c),
upstreamRequestJson: result.upstreamRequestJson,
},
log,
Expand Down
Loading
Loading