Skip to content

Commit 1fc1c58

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp,billing): per-environment webhook ingress limit weighted by body size
All of an environment's webhook endpoints now share a rate limit, counted in tokens: a request costs one token for every 64 KiB of body it carries, so larger deliveries count for more. Requests over the limit get a `429` with `Retry-After`. The limit comes from the organization's plan (300, 1,000 and 3,000 tokens every 10 seconds on Free, Hobby and Pro), is shown on the limits page, and self-hosted installs set it with `WEBHOOK_INGRESS_ENV_RATE_LIMIT_TOKENS` and `WEBHOOK_INGRESS_ENV_RATE_LIMIT_WINDOW`. Mono-RevId: 19aa15366f76a34a5d68767aa511d47e897e7177
1 parent fb87cb1 commit 1fc1c58

18 files changed

Lines changed: 382 additions & 78 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: feature
4+
---
5+
6+
Each environment's webhook endpoints now share a rate limit that counts larger request bodies as more than one request, set by the organization's plan and shown on the limits page.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { z } from "zod";
2+
3+
const OptionalLimit = z.preprocess(
4+
(value) => (value === "" || value === null ? undefined : value),
5+
z.coerce.number().int().min(1).max(2_147_483_647).optional()
6+
);
7+
8+
const OptionalRetention = z.preprocess(
9+
(value) => (value === "" || value === null ? undefined : value),
10+
z.coerce.number().int().min(1).max(365).optional()
11+
);
12+
13+
const OptionalWindowSeconds = z.preprocess(
14+
(value) => (value === "" || value === null ? undefined : value),
15+
z.coerce.number().int().min(1).max(3600).optional()
16+
);
17+
18+
const OptionalBytes = z.preprocess(
19+
(value) => (value === "" || value === null ? undefined : value),
20+
z.coerce.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()
21+
);
22+
23+
const OptionalFlag = z.preprocess(
24+
(value) => (value === "on" ? true : undefined),
25+
z.literal(true).optional()
26+
);
27+
28+
export const WEBHOOK_LIMITS_INTENT = "set-webhook-limits";
29+
30+
const SetWebhookLimitsSchema = z.object({
31+
intent: z.literal(WEBHOOK_LIMITS_INTENT),
32+
maxWaitersPerEnvironment: OptionalLimit,
33+
maxWaitersPerEndpoint: OptionalLimit,
34+
concurrency: OptionalLimit,
35+
deliveryRetentionDays: OptionalRetention,
36+
deliveryRetentionStrict: OptionalFlag,
37+
endpointRateLimitTokens: OptionalLimit,
38+
endpointRateLimitWindowSeconds: OptionalWindowSeconds,
39+
environmentRateLimitTokens: OptionalLimit,
40+
environmentRateLimitWindowSeconds: OptionalWindowSeconds,
41+
queuedPayloadBytes: OptionalBytes,
42+
});
43+
44+
/** The back-office webhook limits form, parsed, or the field errors to show. */
45+
export function parseWebhookLimitsForm(formData: FormData) {
46+
return SetWebhookLimitsSchema.safeParse(Object.fromEntries(formData));
47+
}

‎apps/webapp/app/components/admin/backOffice/WebhookLimitsSection.server.ts‎

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,7 @@
11
import { Prisma } from "@trigger.dev/database";
2-
import { z } from "zod";
32
import { prisma } from "~/db.server";
43
import { logger } from "~/services/logger.server";
5-
import { WEBHOOK_LIMITS_INTENT } from "./WebhookLimitsSection";
6-
7-
const OptionalLimit = z.preprocess(
8-
(value) => (value === "" || value === null ? undefined : value),
9-
z.coerce.number().int().min(1).max(2_147_483_647).optional()
10-
);
11-
12-
const OptionalRetention = z.preprocess(
13-
(value) => (value === "" || value === null ? undefined : value),
14-
z.coerce.number().int().min(1).max(365).optional()
15-
);
16-
17-
const OptionalWindowSeconds = z.preprocess(
18-
(value) => (value === "" || value === null ? undefined : value),
19-
z.coerce.number().int().min(1).max(3600).optional()
20-
);
21-
22-
const OptionalFlag = z.preprocess(
23-
(value) => (value === "on" ? true : undefined),
24-
z.literal(true).optional()
25-
);
26-
27-
const SetWebhookLimitsSchema = z.object({
28-
intent: z.literal(WEBHOOK_LIMITS_INTENT),
29-
maxWaitersPerEnvironment: OptionalLimit,
30-
maxWaitersPerEndpoint: OptionalLimit,
31-
concurrency: OptionalLimit,
32-
deliveryRetentionDays: OptionalRetention,
33-
deliveryRetentionStrict: OptionalFlag,
34-
endpointRateLimitTokens: OptionalLimit,
35-
endpointRateLimitWindowSeconds: OptionalWindowSeconds,
36-
});
4+
import { parseWebhookLimitsForm } from "./WebhookLimitsForm";
375

386
export type WebhookLimitsActionResult =
397
| { ok: true }
@@ -44,7 +12,7 @@ export async function handleWebhookLimitsAction(
4412
orgId: string,
4513
adminUserId: string
4614
): Promise<WebhookLimitsActionResult> {
47-
const submission = SetWebhookLimitsSchema.safeParse(Object.fromEntries(formData));
15+
const submission = parseWebhookLimitsForm(formData);
4816
if (!submission.success) {
4917
return { ok: false, errors: submission.error.flatten().fieldErrors };
5018
}

‎apps/webapp/app/components/admin/backOffice/WebhookLimitsSection.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Form } from "@remix-run/react";
2+
import { WEBHOOK_LIMITS_INTENT } from "./WebhookLimitsForm";
23
import { useEffect, useState } from "react";
34
import { Button } from "~/components/primitives/Buttons";
45
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
@@ -11,7 +12,6 @@ import { Paragraph } from "~/components/primitives/Paragraph";
1112
import * as Property from "~/components/primitives/PropertyTable";
1213
import type { WebhookLimits, WebhookLimitsConfig } from "~/v3/webhookLimits";
1314

14-
export const WEBHOOK_LIMITS_INTENT = "set-webhook-limits";
1515
export const WEBHOOK_LIMITS_SAVED_VALUE = "webhook-limits";
1616

1717
type FieldErrors = Record<string, string[] | undefined> | null;
@@ -22,7 +22,10 @@ type NumericLimit =
2222
| "concurrency"
2323
| "deliveryRetentionDays"
2424
| "endpointRateLimitTokens"
25-
| "endpointRateLimitWindowSeconds";
25+
| "endpointRateLimitWindowSeconds"
26+
| "environmentRateLimitTokens"
27+
| "environmentRateLimitWindowSeconds"
28+
| "queuedPayloadBytes";
2629

2730
const FIELDS: Array<{ key: NumericLimit; label: string; hint: string }> = [
2831
{
@@ -55,6 +58,21 @@ const FIELDS: Array<{ key: NumericLimit; label: string; hint: string }> = [
5558
label: "Endpoint rate limit window (seconds)",
5659
hint: "The window the endpoint rate limit counts over, 1 to 3600 seconds.",
5760
},
61+
{
62+
key: "environmentRateLimitTokens",
63+
label: "Environment rate limit (tokens)",
64+
hint: "Tokens all of an environment's endpoints accept per window. A request costs one per started 64 KiB of body. More get 429 with Retry-After.",
65+
},
66+
{
67+
key: "environmentRateLimitWindowSeconds",
68+
label: "Environment rate limit window (seconds)",
69+
hint: "The window the environment rate limit counts over, 1 to 3600 seconds.",
70+
},
71+
{
72+
key: "queuedPayloadBytes",
73+
label: "Queued payload limit (bytes)",
74+
hint: "Payload bytes one environment may have waiting to be delivered. Past it, ingest answers 503 with Retry-After. 1073741824 is 1 GiB.",
75+
},
5876
];
5977

6078
type Props = {

‎apps/webapp/app/env.server.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2032,6 +2032,16 @@ const EnvironmentSchema = z
20322032
WEBHOOK_INGRESS_BODY_SIZE_LIMIT_MB: z.coerce.number().int().default(1),
20332033
WEBHOOK_INGRESS_RATE_LIMIT_WINDOW: z.string().default("10s"),
20342034
WEBHOOK_INGRESS_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100),
2035+
// Per-environment ingress limit across all of its endpoints. A request costs one token per 64 KiB
2036+
// of body, at least one.
2037+
WEBHOOK_INGRESS_ENV_RATE_LIMIT_TOKENS: z.coerce.number().int().positive().default(3_000),
2038+
WEBHOOK_INGRESS_ENV_RATE_LIMIT_WINDOW: z.string().default("10s"),
2039+
// The most webhook job payload bytes one environment may have queued or in flight.
2040+
WEBHOOK_QUEUED_PAYLOAD_BYTES_LIMIT: z.coerce
2041+
.number()
2042+
.int()
2043+
.nonnegative()
2044+
.default(1024 * 1024 * 1024),
20352045
WEBHOOK_INGRESS_IP_RATE_LIMIT_WINDOW: z.string().default("10s"),
20362046
WEBHOOK_INGRESS_IP_RATE_LIMIT_TOKENS: z.coerce.number().int().default(300),
20372047
WEBHOOK_FRONT_GATE_DEFAULT_TTL_SECONDS: z.coerce

‎apps/webapp/app/presenters/v3/LimitsPresenter.server.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export type LimitsResult = {
8181
webhookConcurrency: QuotaInfo | null;
8282
webhookDeliveryRetentionDays: QuotaInfo | null;
8383
webhookEndpointRateLimit: QuotaInfo | null;
84+
webhookEnvironmentRateLimit: QuotaInfo | null;
8485
};
8586
features: {
8687
hasStagingEnvironment: FeatureInfo;
@@ -436,6 +437,17 @@ export class LimitsPresenter extends BasePresenter {
436437
? { ...quota, unit: `per ${webhookLimits.limits.endpointRateLimitWindowSeconds}s` }
437438
: null;
438439
})(),
440+
webhookEnvironmentRateLimit: (() => {
441+
const quota = webhookQuota(
442+
"environmentRateLimitTokens",
443+
"Webhook environment rate limit",
444+
`Tokens all of an environment's webhook endpoints accept every ${webhookLimits.limits.environmentRateLimitWindowSeconds.toLocaleString()} seconds. A request costs one token per started 64 KiB of body. More get a 429 with Retry-After.`,
445+
0
446+
);
447+
return quota
448+
? { ...quota, unit: `per ${webhookLimits.limits.environmentRateLimitWindowSeconds}s` }
449+
: null;
450+
})(),
439451
},
440452
features: {
441453
hasStagingEnvironment: {

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ function QuotasSection({
552552
if (quotas.webhookConcurrency) quotaRows.push(quotas.webhookConcurrency);
553553
if (quotas.webhookDeliveryRetentionDays) quotaRows.push(quotas.webhookDeliveryRetentionDays);
554554
if (quotas.webhookEndpointRateLimit) quotaRows.push(quotas.webhookEndpointRateLimit);
555+
if (quotas.webhookEnvironmentRateLimit) quotaRows.push(quotas.webhookEnvironmentRateLimit);
555556

556557
const showSelfServe = useShowSelfServe();
557558

@@ -612,7 +613,8 @@ function QuotaRow({
612613
quota.name === "Charts per dashboard" ||
613614
quota.name === "Webhook waiters per endpoint" ||
614615
quota.name === "Webhook processing concurrency" ||
615-
quota.name === "Webhook endpoint rate limit";
616+
quota.name === "Webhook endpoint rate limit" ||
617+
quota.name === "Webhook environment rate limit";
616618
const isRetentionQuota = isDurationQuota || isPerItemQuota;
617619
const isQueueSizeQuota = quota.name === "Max queued runs";
618620
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;

‎apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import {
2626
MaxProjectsSection,
2727
} from "~/components/admin/backOffice/MaxProjectsSection";
2828
import { handleMaxProjectsAction } from "~/components/admin/backOffice/MaxProjectsSection.server";
29+
import { WEBHOOK_LIMITS_INTENT } from "~/components/admin/backOffice/WebhookLimitsForm";
2930
import {
30-
WEBHOOK_LIMITS_INTENT,
3131
WEBHOOK_LIMITS_SAVED_VALUE,
3232
WebhookLimitsSection,
3333
} from "~/components/admin/backOffice/WebhookLimitsSection";

‎apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
3737
return toWebhookHttpResponse(webhookHttpResponseFor(result));
3838
}
3939

40-
const body = await readWebhookIngressRequest(request);
40+
const body = await readWebhookIngressRequest(request, admitted);
4141
if (body instanceof Response) return body;
4242
const { rawBytes, headers } = body;
4343

‎apps/webapp/app/routes/webhooks.v1.ingest.$opaqueId_.w.$waiterToken.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
1818
return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: "POST" } });
1919
}
2020

21-
const body = await readWebhookIngressRequest(request);
21+
const body = await readWebhookIngressRequest(request, admitted);
2222
if (body instanceof Response) return body;
2323

2424
const result = await webhookEngine.ingestWaiter({

0 commit comments

Comments
 (0)