1# gcp-memorystore-redis
2
3A fixed-window rate limiter on Cloud Run, counting in Memorystore for
4Redis. Each key gets 5 hits per 60-second window; after that the API
5answers `429` until the window resets.
6
7| Route | Returns |
8| ------------------ | --------------------------------------------------------------------------- |
9| `POST /hit/:key` | `200` (or `429` + `retry-after` once over the limit) with the counter below |
10| `GET /count/:key` | the counter, without counting a hit |
11
12```json
13{
14 "key": "user-42",
15 "count": 6,
16 "limit": 5,
17 "remaining": 0,
18 "limited": true,
19 "resetInSeconds": 41
20}
21```
22
23A hit is one Redis round trip: `SET key 0 EX 60 NX` starts the window
24(the TTL is only set when the key is new), `INCR` counts the hit and keeps
25the TTL, and `TTL` reports the time left. When the key expires the
26window starts over.
27
28## Architecture
29
30- `GCP.Redis.Instance` `Counters` — BASIC tier (one node, no replica),
31 1 GiB, Redis AUTH on, on the project's `default` network, in
32 the stack's region.
33- `GCP.Function` `Api` — public Cloud Run service
34 (`invokerIamDisabled: true`) with Direct VPC egress onto the `default`
35 network and subnet (`template.vpcAccess`, `PRIVATE_RANGES_ONLY`).
36
37Memorystore only has a private IP. Direct VPC egress gives each revision
38an interface on the network, so traffic to private ranges (the Redis IP)
39goes through the VPC and everything else leaves the normal way. There is
40no Serverless VPC Access connector to create or pay for.
41
42## Bindings
43
44| Binding | Role | Injects |
45| ------------------------------------ | ---- | ----------------------------------------- |
46| `GCP.Redis.ReadWriteRedis(counters)` | none | `redis://:AUTH@host:port` for `Counters` |
47
48Memorystore has no data-plane IAM: RESP authenticates with the
49instance's AUTH string, so the binding grants the runtime service
50account no role. At deploy time Alchemy reads the instance's private IP,
51port, and AUTH string (`instances.getAuthString`, with the deployer's
52credentials) and carries the URL to the service through its runtime
53context. The client is `alchemy/Redis`, so `get`, `set`, `incr`,
54`expire`, `pipeline`, and `send` behave the same as on any other Redis
55provider. `GCP.Redis.ReadRedis` / `WriteRedis` (with `*Http` layers) are
56the narrower clients.
57
58## Deploy
59
60Credentials come from your alchemy profile: run `alchemy profile` once and pick GCP (*Service account JSON* for a key file, or *Stored* for an access token or key kept in `~/.alchemy/credentials`, plus a default region), then deploy with `--profile <name>`.
61
62```sh
63pnpm deploy --profile <name>
64```
65
66Creating the instance takes about four minutes; the service deploys
67once it is ready.
68
69```sh
70curl -X POST "$URL/hit/user-42"
71curl "$URL/count/user-42"
72```
73
74## Test
75
76```sh
77ALCHEMY_PROFILE=<name> bun test
78```
79
80Deploys the stack, checks the instance out of band (READY, BASIC, 1 GiB,
81AUTH on, private IP), hits a key until it is rate limited — asserting the
82count, the window TTL, and the `429` — then destroys the stack and checks
83that the instance and the service are gone. Needs Docker (the
84service image is built locally). Expect around 15 minutes, most of it
85Memorystore create and delete.
86
87## Destroy
88
89```sh
90pnpm destroy --profile <name>
91```
92
93Deletes the service, its image repository and service account, and the
94Redis instance. Deleting the instance also takes several minutes;
95`destroy` waits until it is gone.