Skip to content

Commit 7b7a600

Browse files
committed
fix(webapp): precise S2 record cap + CORS 413 on session append
1 parent 61ca40b commit 7b7a600

5 files changed

Lines changed: 66 additions & 12 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Session `.in/append` returns readable 413s on oversize bodies (was failing browser fetches as opaque `TypeError: Failed to fetch`) and now rejects only records that would actually exceed S2's per-record ceiling, instead of guessing at a conservative pre-encoding cap.

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,18 @@ const ParamsSchema = z.object({
2727
// POST: server-side append of a single record to a session channel. Mirrors
2828
// the existing /realtime/v1/streams/:runId/:target/:streamId/append route,
2929
// scoped to a Session primitive.
30-
// S2 enforces a 1 MiB per-record limit (metered as
31-
// `8 + 2*H + Σ(header name+value) + body`). We cap the raw HTTP body at
32-
// 512 KiB so the JSON wrapper (`{"data":"...","id":"..."}`), string
33-
// escaping, and any future per-record header additions all stay comfortably
34-
// below S2's ceiling. See https://s2.dev/docs/limits.
35-
const MAX_APPEND_BODY_BYTES = 1024 * 512;
30+
//
31+
// The HTTP body cap here is just a DoS pre-guard — set generously at
32+
// 1 MiB so we don't buffer arbitrarily large inputs before we can
33+
// compute the wrapped size. The actual S2 per-record limit (verified
34+
// empirically against cloud S2) is enforced precisely inside
35+
// `S2RealtimeStreams.#appendPartByName` — it throws
36+
// `S2RecordTooLargeError` (a `ServiceValidationError` with status
37+
// 413) when the metered record size would exceed S2's 1 MiB ceiling
38+
// after JSON wrapping. That lets legitimate bodies up to ~1023 KiB
39+
// raw through (ASCII or low-escape content) while still rejecting
40+
// pathological all-quote content that would double on wrap.
41+
const MAX_APPEND_BODY_BYTES = 1024 * 1024;
3642

3743
const { action, loader } = createActionApiRoute(
3844
{

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ const ParamsSchema = z.object({
2424
io: z.enum(["out", "in"]),
2525
});
2626

27-
// S2 record body cap. Mirrors the public /realtime/v1/sessions/:s/:io/append
28-
// route — keep it well under S2's 1 MiB per-record limit so JSON wrapping,
29-
// string escaping, and any future per-record headers stay safe.
30-
const MAX_APPEND_BODY_BYTES = 1024 * 512;
27+
// HTTP body cap. Mirrors the public /realtime/v1/sessions/:s/:io/append
28+
// route — DoS pre-guard only. The actual S2 per-record limit is
29+
// enforced precisely by `S2RealtimeStreams.#appendPartByName`
30+
// (throws `S2RecordTooLargeError` with status 413 when the metered
31+
// record size would exceed S2's 1 MiB ceiling after JSON wrapping).
32+
const MAX_APPEND_BODY_BYTES = 1024 * 1024;
3133

3234
// POST: Append a single record to a Session channel from the dashboard
3335
// playground. Mirrors the public `POST /realtime/v1/sessions/:session/:io/append`

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@ import { StreamIngestor, StreamRecord, StreamResponder, StreamResponseOptions }
44
import { Logger, LogLevel } from "@trigger.dev/core/logger";
55
import { headerValue } from "@trigger.dev/core/v3";
66
import { randomUUID } from "node:crypto";
7+
import { ServiceValidationError } from "~/v3/services/common.server";
8+
9+
// S2's per-record metered-size limit. Verified empirically against
10+
// cloud S2: append succeeds at metered=1048576 and 422s at 1048577
11+
// with `"record must have metered size less than 1 MiB"` (the "less
12+
// than" wording is slightly off — the boundary is inclusive).
13+
//
14+
// Metered size formula:
15+
// metered = 8 (record overhead) + 2*H + Σ(header name + value) + body
16+
// where `body` is the unescaped record body length in UTF-8 bytes and
17+
// `H` is the number of S2 record headers.
18+
//
19+
// We attach no record headers (H=0), so the budget reduces to:
20+
// 8 + body ≤ 1048576 → body ≤ 1048568
21+
export const S2_MAX_METERED_BYTES = 1024 * 1024; // 1 MiB
22+
export const S2_RECORD_BASE_OVERHEAD_BYTES = 8;
23+
24+
/**
25+
* Thrown when a record's metered size would exceed S2's hard per-record
26+
* limit. Caught by the route handler and surfaced as 413.
27+
*/
28+
export class S2RecordTooLargeError extends ServiceValidationError {
29+
constructor(public readonly meteredBytes: number) {
30+
super(
31+
`Record metered size ${meteredBytes} bytes exceeds the S2 per-record limit of ${S2_MAX_METERED_BYTES} bytes. Reduce tool-output size or split into smaller parts.`,
32+
413
33+
);
34+
this.name = "S2RecordTooLargeError";
35+
}
36+
}
737

838
export type S2RealtimeStreamsOptions = {
939
// S2
@@ -181,8 +211,14 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
181211
async #appendPartByName(part: string, partId: string, s2Stream: string): Promise<void> {
182212
this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream });
183213

214+
const recordBody = JSON.stringify({ data: part, id: partId });
215+
const meteredBytes = Buffer.byteLength(recordBody, "utf8") + S2_RECORD_BASE_OVERHEAD_BYTES;
216+
if (meteredBytes > S2_MAX_METERED_BYTES) {
217+
throw new S2RecordTooLargeError(meteredBytes);
218+
}
219+
184220
const result = await this.s2Append(s2Stream, {
185-
records: [{ body: JSON.stringify({ data: part, id: partId }) }],
221+
records: [{ body: recordBody }],
186222
});
187223

188224
this.logger.debug(`S2 append result`, { result });

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,11 @@ export function createActionApiRoute<
777777
const contentLength = request.headers.get("content-length");
778778

779779
if (!contentLength || parseInt(contentLength) > maxContentLength) {
780-
return json({ error: "Request body too large" }, { status: 413 });
780+
return await wrapResponse(
781+
request,
782+
json({ error: "Request body too large" }, { status: 413 }),
783+
corsStrategy !== "none"
784+
);
781785
}
782786
}
783787

0 commit comments

Comments
 (0)