1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import { Counters } from "./resources.ts";
6
7/** Hits allowed per key per window. */
8export const LIMIT = 5;
9
10/** Length of a fixed window, in seconds. */
11export const WINDOW_SECONDS = 60;
12
13const KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
14
15const redisKey = (key: string) => `ratelimit:${key}`;
16
17const asNumber = (value: unknown) => {
18 const parsed = typeof value === "number" ? value : Number(value);
19 return Number.isFinite(parsed) ? parsed : 0;
20};
21
22/**
23 * A fixed-window rate limiter on Cloud Run, counting in Memorystore.
24 *
25 * - `POST /hit/:key` — count one hit against `key`. `200` while the key is
26 * within {@link LIMIT} hits per {@link WINDOW_SECONDS}, `429` (with
27 * `retry-after`) once it is over.
28 * - `GET /count/:key` — the current count and seconds until the window
29 * resets, without counting a hit.
30 *
31 * Memorystore only has a private IP, so the service joins the `default`
32 * VPC with Direct VPC egress (`template.vpcAccess`). `PRIVATE_RANGES_ONLY`
33 * routes just the private ranges (the Redis IP) through the VPC; every
34 * other request still leaves the normal way.
35 *
36 * `invokerIamDisabled: true` makes the API public. Drop it and Cloud Run
37 * requires a Google identity token on every request.
38 */
39export default class Api extends GCP.Function<Api>()(
40 "Api",
41 {
42 main: import.meta.url,
43 invokerIamDisabled: true,
44 template: {
45 vpcAccess: {
46 egress: "PRIVATE_RANGES_ONLY",
47 networkInterfaces: [{ network: "default", subnetwork: "default" }],
48 },
49 },
50 },
51 Effect.gen(function* () {
52 const counters = yield* Counters;
53 // RESP client over the instance's private IP, port, and AUTH string.
54 // Memorystore has no data-plane IAM, so this grants no role.
55 const redis = yield* GCP.Redis.ReadWriteRedis(counters);
56
57 /**
58 * One round trip: start the window if the key is new (`SET NX EX`
59 * sets the TTL only on creation), count the hit (`INCR` keeps the
60 * TTL), and read the time left in the window.
61 */
62 const hit = (key: string) =>
63 redis
64 .pipeline([
65 ["SET", redisKey(key), "0", "EX", WINDOW_SECONDS, "NX"],
66 ["INCR", redisKey(key)],
67 ["TTL", redisKey(key)],
68 ])
69 .pipe(
70 Effect.map(([, count, ttl]) => ({
71 count: asNumber(count),
72 ttl: asNumber(ttl),
73 })),
74 Effect.orDie,
75 );
76
77 const peek = (key: string) =>
78 redis
79 .pipeline([
80 ["GET", redisKey(key)],
81 ["TTL", redisKey(key)],
82 ])
83 .pipe(
84 Effect.map(([count, ttl]) => ({
85 count: asNumber(count ?? 0),
86 ttl: asNumber(ttl),
87 })),
88 Effect.orDie,
89 );
90
91 const body = (key: string, count: number, ttl: number) => ({
92 key,
93 count,
94 limit: LIMIT,
95 remaining: Math.max(0, LIMIT - count),
96 limited: count > LIMIT,
97 // TTL is -2 for a missing key; there is no window to reset.
98 resetInSeconds: ttl >= 0 ? ttl : null,
99 });
100
101 return {
102 fetch: Effect.gen(function* () {
103 const request = yield* HttpServerRequest;
104 const url = new URL(request.originalUrl);
105 const [route, key, ...rest] = url.pathname.split("/").filter(Boolean);
106
107 if (request.method === "GET" && route === undefined) {
108 return HttpServerResponse.text("ok");
109 }
110
111 if (key === undefined || rest.length > 0 || !KEY_PATTERN.test(key)) {
112 return yield* HttpServerResponse.json(
113 { error: "not found" },
114 { status: 404 },
115 );
116 }
117
118 if (request.method === "POST" && route === "hit") {
119 const { count, ttl } = yield* hit(key);
120 const result = body(key, count, ttl);
121 return yield* HttpServerResponse.json(result, {
122 status: result.limited ? 429 : 200,
123 headers: result.limited
124 ? { "retry-after": String(Math.max(ttl, 1)) }
125 : {},
126 });
127 }
128
129 if (request.method === "GET" && route === "count") {
130 const { count, ttl } = yield* peek(key);
131 return yield* HttpServerResponse.json(body(key, count, ttl));
132 }
133
134 return yield* HttpServerResponse.json(
135 { error: "not found" },
136 { status: 404 },
137 );
138 }),
139 };
140 }).pipe(Effect.provide(GCP.Redis.ReadWriteRedisHttp)),
141) {}