Skip to content

Commit 6fcb530

Browse files
WC-3584 Deny requests to the user worker when over free tier limits
Returns an html page (included as direct module, via esbuild) with a 429 status code, mirroring the current free tier limiting behavior. It also sets a new field in our analytics so we can observe free tier invocation denials. This page can evolve as we need it to, this is just my first pass at the response.
1 parent b3429d5 commit 6fcb530

File tree

5 files changed

+200
-1
lines changed

5 files changed

+200
-1
lines changed

packages/workers-shared/router-worker/src/analytics.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type Data = {
3333
userWorkerAhead?: boolean;
3434
// double6 - Routing performed based on the _routes.json (if provided)
3535
staticRoutingDecision?: STATIC_ROUTING_DECISION;
36+
// double7 - User worker invocation denied due to free tier limiting
37+
userWorkerFreeTierLimiting?: boolean;
3638

3739
// -- Blobs --
3840
// blob1 - Hostname of the request
@@ -88,6 +90,7 @@ export class Analytics {
8890
? -1
8991
: Number(this.data.userWorkerAhead),
9092
this.data.staticRoutingDecision ?? STATIC_ROUTING_DECISION.NOT_PROVIDED, // double6
93+
this.data.userWorkerFreeTierLimiting ? 1 : 0, // double7
9194
],
9295
blobs: [
9396
this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<html>
2+
<head>
3+
<title>This website is temporarily limited</title>
4+
</head>
5+
<body>
6+
<h1>This website has been temporarily rate limited</h1>
7+
<p>
8+
You cannot access this site because the owner has reached their plan
9+
limits. Check back later once traffic has gone down.
10+
</p>
11+
<p>
12+
If you are owner of this website, prevent this from happening again by
13+
upgrading your plan on the
14+
<a
15+
href="https://dash.cloudflare.com/?account=workers/plans"
16+
target="_blank"
17+
>Cloudflare Workers dashboard</a
18+
>.
19+
</p>
20+
</body>
21+
</html>

packages/workers-shared/router-worker/src/worker.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { setupSentry } from "../../utils/sentry";
44
import { mockJaegerBinding } from "../../utils/tracing";
55
import { Analytics, DISPATCH_TYPE, STATIC_ROUTING_DECISION } from "./analytics";
66
import { applyConfigurationDefaults } from "./configuration";
7-
import type AssetWorker from "../../asset-worker";
7+
import limitedResponse from "./limited-response.html";
8+
import type AssetWorker from "../../asset-worker/src/worker";
89
import type {
910
EyeballRouterConfig,
1011
JaegerTracing,
@@ -90,6 +91,15 @@ export default {
9091
"Fetch for user worker without having a user worker binding"
9192
);
9293
}
94+
if (eyeballConfig.limitedAssetsOnly) {
95+
analytics.setData({ userWorkerFreeTierLimiting: true });
96+
return new Response(limitedResponse, {
97+
status: 429,
98+
headers: {
99+
"Content-Type": "text/html",
100+
},
101+
});
102+
}
93103
analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER });
94104
userWorkerInvocation = true;
95105
return env.JAEGER.enterSpan("dispatch_worker", async (span) => {

packages/workers-shared/router-worker/tests/index.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,165 @@ describe("unit tests", async () => {
246246
const response = await worker.fetch(request, env, ctx);
247247
expect(await response.text()).toEqual("hello from user worker");
248248
});
249+
250+
describe("free tier limiting", () => {
251+
it("returns fetch from asset worker for assets", async () => {
252+
const request = new Request("https://example.com/asset");
253+
const ctx = createExecutionContext();
254+
255+
const env = {
256+
CONFIG: {
257+
has_user_worker: true,
258+
},
259+
EYEBALL_CONFIG: { limitedAssetsOnly: true },
260+
USER_WORKER: {
261+
async fetch(_: Request): Promise<Response> {
262+
return new Response("hello from user worker");
263+
},
264+
},
265+
ASSET_WORKER: {
266+
async fetch(_: Request): Promise<Response> {
267+
return new Response("hello from asset worker");
268+
},
269+
async unstable_canFetch(_: Request): Promise<boolean> {
270+
return true;
271+
},
272+
},
273+
} as Env;
274+
275+
const response = await worker.fetch(request, env, ctx);
276+
expect(await response.text()).toEqual("hello from asset worker");
277+
});
278+
279+
it("returns error page instead of user worker when no asset found", async () => {
280+
const request = new Request("https://example.com/asset");
281+
const ctx = createExecutionContext();
282+
283+
const env = {
284+
CONFIG: {
285+
has_user_worker: true,
286+
},
287+
EYEBALL_CONFIG: { limitedAssetsOnly: true },
288+
USER_WORKER: {
289+
async fetch(_: Request): Promise<Response> {
290+
return new Response("hello from user worker");
291+
},
292+
},
293+
ASSET_WORKER: {
294+
async fetch(_: Request): Promise<Response> {
295+
return new Response("hello from asset worker");
296+
},
297+
async unstable_canFetch(_: Request): Promise<boolean> {
298+
return false;
299+
},
300+
},
301+
} as Env;
302+
303+
const response = await worker.fetch(request, env, ctx);
304+
expect(response.status).toEqual(429);
305+
const text = await response.text();
306+
expect(text).not.toEqual("hello from user worker");
307+
expect(text).toContain("This website has been temporarily rate limited");
308+
});
309+
310+
it("returns error page instead of user worker for invoke_user_worker_ahead_of_assets", async () => {
311+
const request = new Request("https://example.com/asset");
312+
const ctx = createExecutionContext();
313+
314+
const env = {
315+
CONFIG: {
316+
has_user_worker: true,
317+
invoke_user_worker_ahead_of_assets: true,
318+
},
319+
EYEBALL_CONFIG: { limitedAssetsOnly: true },
320+
USER_WORKER: {
321+
async fetch(_: Request): Promise<Response> {
322+
return new Response("hello from user worker");
323+
},
324+
},
325+
ASSET_WORKER: {
326+
async fetch(_: Request): Promise<Response> {
327+
return new Response("hello from asset worker");
328+
},
329+
async unstable_canFetch(_: Request): Promise<boolean> {
330+
return true;
331+
},
332+
},
333+
} as Env;
334+
335+
const response = await worker.fetch(request, env, ctx);
336+
expect(response.status).toEqual(429);
337+
const text = await response.text();
338+
expect(text).not.toEqual("hello from user worker");
339+
expect(text).toContain("This website has been temporarily rate limited");
340+
});
341+
342+
it("returns error page instead of user worker for include rules", async () => {
343+
const request = new Request("https://example.com/api/asset");
344+
const ctx = createExecutionContext();
345+
346+
const env = {
347+
CONFIG: {
348+
has_user_worker: true,
349+
static_routing: {
350+
version: 1,
351+
include: ["/api/*"],
352+
},
353+
},
354+
EYEBALL_CONFIG: { limitedAssetsOnly: true },
355+
USER_WORKER: {
356+
async fetch(_: Request): Promise<Response> {
357+
return new Response("hello from user worker");
358+
},
359+
},
360+
ASSET_WORKER: {
361+
async fetch(_: Request): Promise<Response> {
362+
return new Response("hello from asset worker");
363+
},
364+
async unstable_canFetch(_: Request): Promise<boolean> {
365+
return true;
366+
},
367+
},
368+
} as Env;
369+
370+
const response = await worker.fetch(request, env, ctx);
371+
expect(response.status).toEqual(429);
372+
const text = await response.text();
373+
expect(text).not.toEqual("hello from user worker");
374+
expect(text).toContain("This website has been temporarily rate limited");
375+
});
376+
377+
it("returns fetch from asset worker for exclude rules", async () => {
378+
const request = new Request("https://example.com/api/asset");
379+
const ctx = createExecutionContext();
380+
381+
const env = {
382+
CONFIG: {
383+
has_user_worker: true,
384+
static_routing: {
385+
version: 1,
386+
include: ["/api/*"],
387+
exclude: ["/api/asset"],
388+
},
389+
},
390+
EYEBALL_CONFIG: { limitedAssetsOnly: true },
391+
USER_WORKER: {
392+
async fetch(_: Request): Promise<Response> {
393+
return new Response("hello from user worker");
394+
},
395+
},
396+
ASSET_WORKER: {
397+
async fetch(_: Request): Promise<Response> {
398+
return new Response("hello from asset worker");
399+
},
400+
async unstable_canFetch(_: Request): Promise<boolean> {
401+
return true;
402+
},
403+
},
404+
} as Env;
405+
406+
const response = await worker.fetch(request, env, ctx);
407+
expect(await response.text()).toEqual("hello from asset worker");
408+
});
409+
});
249410
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module "*.html" {
2+
const content: string;
3+
export default content;
4+
}

0 commit comments

Comments
 (0)