@alxia/redis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/cache-store.d.ts +18 -0
- package/dist/cache-store.d.ts.map +1 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/idempotency.d.ts +47 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +13 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +18 -0
- package/docs/guide/caches-and-locks.md +204 -0
- package/docs/guide/connecting.md +165 -0
- package/docs/guide/idempotency.md +255 -0
- package/docs/guide/rate-limits.md +198 -0
- package/docs/guide/response-cache.md +129 -0
- package/docs/guide/testing.md +135 -0
- package/docs/roadmap.md +46 -0
- package/docs/troubleshooting.md +613 -0
- package/package.json +70 -0
package/docs/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @alxia/redis documentation
|
|
2
|
+
|
|
3
|
+
The [package README](../README.md) is the short version. This folder is
|
|
4
|
+
the long one: a guide page per area, with the options, defaults, errors and
|
|
5
|
+
a realistic example for each.
|
|
6
|
+
|
|
7
|
+
## Guide
|
|
8
|
+
|
|
9
|
+
| Page | Read it when |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build the plugins, failing fast when Redis is down, closing it, or naming keys so features never share them |
|
|
12
|
+
| [Rate limits](guide/rate-limits.md) | sharing an `@alxia/rate-limit` count across processes, understanding why GCRA lets a burst through, choosing `name`, or resetting a key |
|
|
13
|
+
| [Response cache](guide/response-cache.md) | sharing `@alxia/cache` responses across processes, invalidating them everywhere, or knowing what is stored in Redis |
|
|
14
|
+
| [Idempotency](guide/idempotency.md) | making a `POST` run once per `Idempotency-Key`, reading the `409`, `422` and `400`, scoping keys by user, or ordering it with a rate limit or an auth check |
|
|
15
|
+
| [Caches and locks](guide/caches-and-locks.md) | reading typed caches and a lock from the context, catching `LOCK_HELD`, or using `redis()` beside `@alxia/cache` |
|
|
16
|
+
| [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or pinning the refusals in the types |
|
|
17
|
+
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit, a cache or a replay does not behave as you expected |
|
|
18
|
+
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Caches and locks
|
|
2
|
+
|
|
3
|
+
This page covers `redis`: a plugin that puts the client, typed caches and a
|
|
4
|
+
lock in the context of every route declared after it.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { alxia } from '@alxia/core';
|
|
8
|
+
import { redis } from '@alxia/redis';
|
|
9
|
+
import { connectRedis, defineCache } from '@nxgt/redis';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
13
|
+
|
|
14
|
+
const User = z.object({ id: z.string(), name: z.string() });
|
|
15
|
+
const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
|
|
16
|
+
|
|
17
|
+
const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
|
|
18
|
+
|
|
19
|
+
const app = alxia()
|
|
20
|
+
.use(redis(connection.client, { caches: { users } }))
|
|
21
|
+
.get('/users/:id', async ({ caches, params, reply }) => {
|
|
22
|
+
const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
|
|
23
|
+
return reply.ok(user);
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The signature
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
function redis<Caches>(client: RedisClient, options?: RedisContextOptions<Caches>); // a plugin
|
|
31
|
+
|
|
32
|
+
interface RedisContextOptions<Caches> {
|
|
33
|
+
/** `@nxgt/redis` cache definitions, by the name routes read them under. */
|
|
34
|
+
readonly caches?: Caches;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What routes after `redis()` read. */
|
|
38
|
+
interface RedisContext<Caches> {
|
|
39
|
+
readonly redis: RedisClient;
|
|
40
|
+
readonly caches: BoundCaches<Caches>;
|
|
41
|
+
lock<T>(key: string, work: () => Promise<T> | T, options?: LockOptions): Promise<T>;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Option | Type | Default | Effect |
|
|
46
|
+
| --- | --- | --- | --- |
|
|
47
|
+
| `caches` | `Record<string, CacheDefinition>` | none | each definition, bound to the client once, under `ctx.caches.<name>` |
|
|
48
|
+
|
|
49
|
+
| In the context | What it is |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `redis` | Bun's `RedisClient`, untouched: every command Bun has |
|
|
52
|
+
| `caches.<name>` | the `@nxgt/redis` `BoundCache` of that definition, typed by its schema |
|
|
53
|
+
| `lock(key, work, options?)` | `@nxgt/redis`'s `withLock`: `work` under a lock every process respects |
|
|
54
|
+
|
|
55
|
+
The caches are bound when `redis(…)` is called, not per request.
|
|
56
|
+
|
|
57
|
+
## Caches
|
|
58
|
+
|
|
59
|
+
A cache is described with `defineCache` from `@nxgt/redis` — a `name`, a
|
|
60
|
+
`key` function, a `ttl` in **seconds**, and a zod `schema` — and read under
|
|
61
|
+
the name you give it in `caches`:
|
|
62
|
+
|
|
63
|
+
| `caches.<name>` | |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `get(params)` | the value, or `undefined`: a miss, an expiry, or a stored value the schema no longer accepts |
|
|
66
|
+
| `set(params, value, { ttl }?)` | checks `value` against the schema, then stores it |
|
|
67
|
+
| `remember(params, load, { ttl }?)` | the value if it is there; otherwise what `load` gives, stored |
|
|
68
|
+
| `delete(params)` | `true` when something was there |
|
|
69
|
+
| `keyFor(params)` | the Redis key it uses: `<name>:<key(params)>` |
|
|
70
|
+
|
|
71
|
+
A realistic case, a profile read through the cache and forgotten on write:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { alxia } from '@alxia/core';
|
|
75
|
+
import { redis } from '@alxia/redis';
|
|
76
|
+
import { connectRedis, defineCache } from '@nxgt/redis';
|
|
77
|
+
import { z } from 'zod';
|
|
78
|
+
|
|
79
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
80
|
+
|
|
81
|
+
const Profile = z.object({ id: z.string(), name: z.string(), plan: z.enum(['free', 'pro']).default('free') });
|
|
82
|
+
const profiles = defineCache({ name: 'profile', key: (id: string) => id, ttl: 600, schema: Profile });
|
|
83
|
+
const table = new Map<string, z.input<typeof Profile>>([['1', { id: '1', name: 'Ada' }]]);
|
|
84
|
+
|
|
85
|
+
const app = alxia()
|
|
86
|
+
.use(redis(connection.client, { caches: { profiles } }))
|
|
87
|
+
.get('/profiles/:id', async ({ caches, params, reply }) => {
|
|
88
|
+
const profile = await caches.profiles.remember(params.id, async () => table.get(params.id) ?? { id: params.id, name: '?' });
|
|
89
|
+
return reply.ok(profile); // plan is filled in: 'free'
|
|
90
|
+
})
|
|
91
|
+
.put('/profiles/:id', { body: Profile.omit({ id: true }) }, async ({ caches, params, body, reply }) => {
|
|
92
|
+
table.set(params.id, { id: params.id, ...body });
|
|
93
|
+
await caches.profiles.delete(params.id); // the next read loads it again
|
|
94
|
+
return reply(204, undefined);
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`remember` holds no lock: two requests missing at once both call `load`.
|
|
99
|
+
Wrap it in `lock` where loading is expensive or must happen once. The
|
|
100
|
+
caches' errors, their schemas and their traps are `@nxgt/redis`'s: see its
|
|
101
|
+
documentation.
|
|
102
|
+
|
|
103
|
+
## Locks
|
|
104
|
+
|
|
105
|
+
`lock(key, work, options?)` takes `lock:<key>` in Redis, runs `work`, and
|
|
106
|
+
releases the lock — only if it still holds it.
|
|
107
|
+
|
|
108
|
+
| Option | Type | Default | Effect |
|
|
109
|
+
| --- | --- | --- | --- |
|
|
110
|
+
| `ttl` | `number` | `30_000` | **milliseconds** the lock is held before Redis drops it |
|
|
111
|
+
| `wait` | `number` | `0` | milliseconds to keep trying to take a held lock |
|
|
112
|
+
| `retryDelay` | `number` | `50` | milliseconds between tries |
|
|
113
|
+
|
|
114
|
+
A lock that is held, or that expires before `work` returns, throws a
|
|
115
|
+
`RedisError`. Uncaught, the route answers `500 {"error":"internal"}`; catch
|
|
116
|
+
it to answer something better:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { alxia } from '@alxia/core';
|
|
120
|
+
import { redis } from '@alxia/redis';
|
|
121
|
+
import { connectRedis, RedisError } from '@nxgt/redis';
|
|
122
|
+
|
|
123
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
124
|
+
|
|
125
|
+
const sendInvoices = async () => 12; // the work that must not run twice at once
|
|
126
|
+
|
|
127
|
+
const app = alxia()
|
|
128
|
+
.use(redis(connection.client))
|
|
129
|
+
.post('/invoices/run', async ({ lock, reply }) => {
|
|
130
|
+
try {
|
|
131
|
+
const sent = await lock('invoices', sendInvoices, { ttl: 60_000 });
|
|
132
|
+
return reply(200, { sent });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof RedisError && error.code === 'LOCK_HELD') {
|
|
135
|
+
return reply(409, { error: 'already_running' as const });
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| `error.code` | When | Message |
|
|
143
|
+
| --- | --- | --- |
|
|
144
|
+
| `LOCK_HELD` | someone else holds it, and `wait` ran out | ``The lock "invoices" is held by somebody else, and this call did not wait for it — pass `wait` to keep trying`` |
|
|
145
|
+
| `LOCK_LOST` | `work` returned after `ttl` had passed | `The lock "invoices" expired before its work finished: it ran longer than the 60000ms ttl, so it may have run beside another holder` |
|
|
146
|
+
|
|
147
|
+
`LOCK_HELD` means nothing ran. `LOCK_LOST` means `work` did run, possibly
|
|
148
|
+
beside another holder: size `ttl` above the slowest run you accept.
|
|
149
|
+
|
|
150
|
+
## The client
|
|
151
|
+
|
|
152
|
+
`redis` is Bun's `RedisClient`, for every command the plugin does not wrap:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { alxia } from '@alxia/core';
|
|
156
|
+
import { redis } from '@alxia/redis';
|
|
157
|
+
import { connectRedis } from '@nxgt/redis';
|
|
158
|
+
|
|
159
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
160
|
+
|
|
161
|
+
const app = alxia()
|
|
162
|
+
.use(redis(connection.client))
|
|
163
|
+
.post('/articles/:id/views', async ({ redis, params, reply }) => {
|
|
164
|
+
const views = await redis.incr(`views:${params.id}`);
|
|
165
|
+
return reply(200, { views });
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## With `@alxia/cache`
|
|
170
|
+
|
|
171
|
+
`@alxia/cache`'s `cache()` adds `cache` to the context — the response
|
|
172
|
+
cache's `{ tag, skip }`. `redis()` adds `caches`, so the two sit side by side
|
|
173
|
+
on one route, declared in either order:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import { cache } from '@alxia/cache';
|
|
177
|
+
import { alxia } from '@alxia/core';
|
|
178
|
+
import { redis } from '@alxia/redis';
|
|
179
|
+
import { connectRedis, defineCache } from '@nxgt/redis';
|
|
180
|
+
import { z } from 'zod';
|
|
181
|
+
|
|
182
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
183
|
+
const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: z.object({ id: z.string(), name: z.string() }) });
|
|
184
|
+
const loadUser = async (id: string) => ({ id, name: 'Ada' });
|
|
185
|
+
|
|
186
|
+
const app = alxia()
|
|
187
|
+
.use(redis(connection.client, { caches: { users } }))
|
|
188
|
+
.use(cache({ ttl: 60 }))
|
|
189
|
+
.get('/users/:id', async ({ caches, cache, params, reply }) => {
|
|
190
|
+
cache.tag(`user:${params.id}`); // the response cache
|
|
191
|
+
return reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))); // the Redis cache
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Code written for the earlier name — `ctx.cache.users` — no longer compiles;
|
|
196
|
+
[Troubleshooting](../troubleshooting.md#property-cache-does-not-exist-on-type---rediscontext-) has the
|
|
197
|
+
message and the rename.
|
|
198
|
+
|
|
199
|
+
## Next
|
|
200
|
+
|
|
201
|
+
- [Connecting](connecting.md) — the client, and closing it.
|
|
202
|
+
- [Testing](testing.md) — a route with a cache, in a spec.
|
|
203
|
+
- [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) — caches,
|
|
204
|
+
locks and pub/sub in full.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Connecting
|
|
2
|
+
|
|
3
|
+
This page covers the one thing every export of `@alxia/redis` takes first:
|
|
4
|
+
a Bun `RedisClient`, how to open it, when to make the plugins with it, and
|
|
5
|
+
how to close it.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { redis } from '@alxia/redis';
|
|
10
|
+
import { connectRedis } from '@nxgt/redis';
|
|
11
|
+
|
|
12
|
+
const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
13
|
+
|
|
14
|
+
const app = alxia()
|
|
15
|
+
.use(redis(connection.client))
|
|
16
|
+
.get('/ping', async ({ redis, reply }) => reply(200, await redis.ping()));
|
|
17
|
+
|
|
18
|
+
app.listen({ port: 3000 });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The client
|
|
22
|
+
|
|
23
|
+
Every export takes Bun's own `RedisClient` as its first argument:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore
|
|
27
|
+
redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore
|
|
28
|
+
idempotency(client: RedisClient, options: IdempotencyOptions) // a plugin
|
|
29
|
+
redis(client: RedisClient, options?: RedisContextOptions) // a plugin
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Any `RedisClient` will do. `connectRedis` from
|
|
33
|
+
[`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) is the usual
|
|
34
|
+
way: it shares one client per URI across the process, connects it once
|
|
35
|
+
even when two calls race, and gives back a `RedisConnection` with
|
|
36
|
+
`client`, `ping()` and `close()`. A client you open yourself works the
|
|
37
|
+
same:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { redisStore } from '@alxia/redis';
|
|
41
|
+
import { RedisClient } from 'bun';
|
|
42
|
+
|
|
43
|
+
const client = new RedisClient(Bun.env['REDIS_URL']);
|
|
44
|
+
const store = redisStore(client, { name: 'api' });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
One client is enough for the whole app: the rate-limit store, the cache
|
|
48
|
+
store, idempotency and `redis()` all send ordinary commands over it. None
|
|
49
|
+
of them subscribes, so none needs a connection of its own.
|
|
50
|
+
|
|
51
|
+
## Make the plugins after you connect
|
|
52
|
+
|
|
53
|
+
Each export binds the client it is given when it is called. Open the
|
|
54
|
+
connection first, then build the app:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { alxia } from '@alxia/core';
|
|
58
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
59
|
+
import { idempotency, redisStore } from '@alxia/redis';
|
|
60
|
+
import { connectRedis } from '@nxgt/redis';
|
|
61
|
+
|
|
62
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
63
|
+
|
|
64
|
+
const app = alxia()
|
|
65
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
|
|
66
|
+
.use(idempotency(connection.client, { name: 'orders' }))
|
|
67
|
+
.post('/orders', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In a test, that means building the app in `beforeAll`, once Redis is up —
|
|
71
|
+
see [Testing](testing.md).
|
|
72
|
+
|
|
73
|
+
## When Redis is down
|
|
74
|
+
|
|
75
|
+
`@alxia/redis` does not catch Redis's errors. A command that fails while a
|
|
76
|
+
request runs fails that request: the app answers
|
|
77
|
+
`500 {"error":"internal"}` and logs Bun's error, code
|
|
78
|
+
`ERR_REDIS_CONNECTION_CLOSED`: `Max reconnection attempts reached` for the
|
|
79
|
+
first command once Bun's client has given up reconnecting, then
|
|
80
|
+
`Connection has failed` for the ones after it
|
|
81
|
+
([troubleshooting](../troubleshooting.md#rediserror-connection-has-failed)).
|
|
82
|
+
A short outage recovers on its own while the client is still retrying;
|
|
83
|
+
after it has given up, do not count on it coming back — restart the process.
|
|
84
|
+
There is no fail-open mode: a rate limit that cannot count does not let the
|
|
85
|
+
request through, and an idempotent route that cannot take its key does not
|
|
86
|
+
run. The response cache is the exception, as `@alxia/cache` decides it: a
|
|
87
|
+
cached route whose `redisCacheStore` cannot answer runs and answers
|
|
88
|
+
`X-Cache: MISS`, with the outage's first error logged; only its
|
|
89
|
+
invalidations reject.
|
|
90
|
+
|
|
91
|
+
At startup, Bun's client reconnects by default, so a `connectRedis` to a
|
|
92
|
+
server that is not there keeps trying for about half a minute before it
|
|
93
|
+
rejects. For a check that should fail at once, pass `autoReconnect: false`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { connectRedis } from '@nxgt/redis';
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const check = await connectRedis(Bun.env['REDIS_URL']!, { autoReconnect: false });
|
|
100
|
+
await check.close();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Redis is not reachable', error);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`connectRedis` shares one client per URI, so every later call for the same
|
|
108
|
+
URI must pass the same options, or it throws
|
|
109
|
+
`TypeError: connectRedis: this URI is already connected with other options. Pass the same options everywhere, or close the first connection.` Close the
|
|
110
|
+
check before the app connects, as above, or pass the same options
|
|
111
|
+
everywhere.
|
|
112
|
+
|
|
113
|
+
`connection.ping()` never throws, and is a health route in one line:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { alxia } from '@alxia/core';
|
|
117
|
+
import { connectRedis } from '@nxgt/redis';
|
|
118
|
+
|
|
119
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
120
|
+
|
|
121
|
+
const app = alxia().get('/health', async ({ reply }) => {
|
|
122
|
+
const redis = await connection.ping();
|
|
123
|
+
return redis.ok ? reply(200, { redis: 'up', ms: redis.latencyMs }) : reply(503, { redis: 'down' });
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Closing
|
|
128
|
+
|
|
129
|
+
Nothing here listens to a signal. Close the connection when the process
|
|
130
|
+
stops:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { connectRedis } from '@nxgt/redis';
|
|
134
|
+
|
|
135
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
136
|
+
|
|
137
|
+
process.on('SIGTERM', async () => {
|
|
138
|
+
await connection.close();
|
|
139
|
+
process.exit(0);
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`closeRedis()` from `@nxgt/redis` closes every client the process opened
|
|
144
|
+
through `connectRedis` — the end of a test file, say.
|
|
145
|
+
|
|
146
|
+
## Naming keys
|
|
147
|
+
|
|
148
|
+
Every export that writes takes a `name`, prepended to its keys, so several
|
|
149
|
+
apps and several features can share one Redis:
|
|
150
|
+
|
|
151
|
+
| Export | Keys it writes |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| `redisStore(client, { name })` | `<name>:<limit>/<windowMs>:<key>`, and `<name>:policies` |
|
|
154
|
+
| `redisCacheStore(client, { name })` | `<name>:response:<key>`, and `<name>:tag:<tag>`, which expires with its longest-kept response |
|
|
155
|
+
| `idempotency(client, { name })` | `<name>:<route>:<scope>:<Idempotency-Key>` |
|
|
156
|
+
| `redis(client, { caches })` | each cache's own `<name>:<key>`, and `lock:<key>` |
|
|
157
|
+
|
|
158
|
+
Two features given the same `name` share their keys; give each its own.
|
|
159
|
+
|
|
160
|
+
## Next
|
|
161
|
+
|
|
162
|
+
- [Rate limits](rate-limits.md) — `redisStore`.
|
|
163
|
+
- [Response cache](response-cache.md) — `redisCacheStore`.
|
|
164
|
+
- [Idempotency](idempotency.md) — `idempotency`.
|
|
165
|
+
- [Caches and locks](caches-and-locks.md) — `redis`.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Idempotency
|
|
2
|
+
|
|
3
|
+
This page covers `idempotency`: a plugin that runs a `POST` or `PATCH`
|
|
4
|
+
once per `Idempotency-Key`, replays its response to every repeat, and
|
|
5
|
+
refuses the repeats it cannot answer — across every process sharing a
|
|
6
|
+
Redis.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { idempotency } from '@alxia/redis';
|
|
11
|
+
import { connectRedis } from '@nxgt/redis';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
15
|
+
const Payment = z.object({ amount: z.number().int().positive() });
|
|
16
|
+
|
|
17
|
+
const app = alxia()
|
|
18
|
+
.use(idempotency(connection.client, { name: 'payments' }))
|
|
19
|
+
.post('/payments', { body: Payment }, ({ body, reply }) =>
|
|
20
|
+
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
21
|
+
);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type: application/json' -d '{"amount":10}'
|
|
26
|
+
# 201 {"id":"9a…","amount":10}
|
|
27
|
+
curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type: application/json' -d '{"amount":10}'
|
|
28
|
+
# 201 {"id":"9a…","amount":10} Idempotent-Replayed: true — the route did not run
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Only the routes declared **after** `use(idempotency(…))` are guarded.
|
|
32
|
+
|
|
33
|
+
## The signature
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
function idempotency(client: RedisClient, options: IdempotencyOptions); // a plugin
|
|
37
|
+
|
|
38
|
+
interface IdempotencyOptions {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly ttl?: number;
|
|
41
|
+
readonly lease?: number;
|
|
42
|
+
readonly wait?: number;
|
|
43
|
+
readonly methods?: readonly string[];
|
|
44
|
+
readonly header?: string;
|
|
45
|
+
readonly required?: boolean;
|
|
46
|
+
readonly scope?: (ctx: BaseContext) => string | undefined;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Option | Type | Default | Effect |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| `name` | `string` | required | the prefix of every key it stores |
|
|
53
|
+
| `ttl` | `number` | `86_400` (a day) | **seconds** a finished response is replayed. A whole number, at least 1 |
|
|
54
|
+
| `lease` | `number` | `10_000` | **milliseconds** a running request holds its key unless renewed; renewed every third of it while the route runs. A whole number, at least 1 |
|
|
55
|
+
| `wait` | `number` | `0` | **milliseconds** a repeat waits for a running first request before its `409`. A whole number, 0 or more |
|
|
56
|
+
| `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
|
|
57
|
+
| `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
|
|
58
|
+
| `required` | `boolean` | `false` | refuse a guarded request with no key, with a `400` |
|
|
59
|
+
| `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` scopes it to everyone |
|
|
60
|
+
|
|
61
|
+
`name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
|
|
62
|
+
wrong one throws at startup:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
TypeError: defineIdempotency: "payments" has a ttl of 1.5; it is a whole number of seconds, and must be at least 1
|
|
66
|
+
TypeError: defineIdempotency: "payments" has a lease of 500.5; it is a whole number of milliseconds, and must be at least 1
|
|
67
|
+
TypeError: defineIdempotency: an idempotent operation needs a name, for its keys
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`wait` is checked on the first guarded request: a wrong one makes every
|
|
71
|
+
guarded request a `500`, logging
|
|
72
|
+
`TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more`.
|
|
73
|
+
|
|
74
|
+
## What each request gets
|
|
75
|
+
|
|
76
|
+
| Request | Answer |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| a guarded method with a new key | the route runs; its response is kept and sent |
|
|
79
|
+
| the same key, method, path, query and body again, within `ttl` | the first response — status, headers, body — with `Idempotent-Replayed: true`; the route does not run |
|
|
80
|
+
| the same key while the first still runs | `409 { error: 'idempotency_in_progress', retryAfter }`, with `Retry-After` — after up to `wait` ms |
|
|
81
|
+
| the same key with another method, path, query or body | `422 { error: 'idempotency_key_reused' }` |
|
|
82
|
+
| a key that is not 1 to 255 printable ASCII characters (no space) | `400 { error: 'idempotency_key_invalid' }` |
|
|
83
|
+
| no key, with `required: true` | `400 { error: 'idempotency_key_missing' }` |
|
|
84
|
+
| no key, without `required` | the route runs, unguarded |
|
|
85
|
+
| a method not in `methods` | the route runs, unguarded |
|
|
86
|
+
|
|
87
|
+
Every refusal is typed as `IdempotencyErrorBody` and is part of each
|
|
88
|
+
guarded route's type, so `@alxia/client` reads it:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
interface IdempotencyErrorBody {
|
|
92
|
+
readonly error:
|
|
93
|
+
| 'idempotency_key_missing'
|
|
94
|
+
| 'idempotency_key_invalid'
|
|
95
|
+
| 'idempotency_in_progress'
|
|
96
|
+
| 'idempotency_key_reused';
|
|
97
|
+
/** Seconds until a running request should be over: with `idempotency_in_progress`. */
|
|
98
|
+
readonly retryAfter?: number;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { client } from '@alxia/client';
|
|
104
|
+
import { alxia } from '@alxia/core';
|
|
105
|
+
import { idempotency } from '@alxia/redis';
|
|
106
|
+
import { connectRedis } from '@nxgt/redis';
|
|
107
|
+
import { z } from 'zod';
|
|
108
|
+
|
|
109
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
110
|
+
|
|
111
|
+
const app = alxia()
|
|
112
|
+
.use(idempotency(connection.client, { name: 'payments', required: true }))
|
|
113
|
+
.post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
|
|
114
|
+
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const api = client(app);
|
|
118
|
+
const result = await api.post('/payments', {
|
|
119
|
+
body: { amount: 10 },
|
|
120
|
+
init: { headers: { 'idempotency-key': crypto.randomUUID() } },
|
|
121
|
+
});
|
|
122
|
+
// result.status: 201 | 400 | 409 | 422 | 500
|
|
123
|
+
if (result.status === 409) {
|
|
124
|
+
await Bun.sleep(result.data.retryAfter! * 1000); // then send the same request again
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### What is replayed
|
|
129
|
+
|
|
130
|
+
The status, every header but `Set-Cookie`, `Date` and `Content-Length`,
|
|
131
|
+
and the body, byte for byte. A cookie belongs to the response that set it:
|
|
132
|
+
a repeat never receives a session.
|
|
133
|
+
|
|
134
|
+
### What is not kept
|
|
135
|
+
|
|
136
|
+
- **A `5xx`** — a thrown error included — is answered and not kept: the key
|
|
137
|
+
is free again, and the next repeat runs the route.
|
|
138
|
+
- **A `text/event-stream` response** is answered and not kept.
|
|
139
|
+
- **Everything else is kept**, `4xx` included: a `400`, a `401`, a `404`
|
|
140
|
+
or a `429` answered under a key is replayed to every repeat for `ttl`
|
|
141
|
+
seconds — see [Order](#order-what-runs-inside-the-guard) below.
|
|
142
|
+
|
|
143
|
+
### `409` and `Retry-After`
|
|
144
|
+
|
|
145
|
+
`retryAfter` is when the running request's lease lapses **unless it is
|
|
146
|
+
renewed**, rounded up to the second — 10 by default, however short the
|
|
147
|
+
route actually is. A client that waits that long and repeats gets the
|
|
148
|
+
replay. With `wait`, the repeat waits on the server instead, and usually
|
|
149
|
+
gets the replay rather than the `409`:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { alxia } from '@alxia/core';
|
|
153
|
+
import { idempotency } from '@alxia/redis';
|
|
154
|
+
import { connectRedis } from '@nxgt/redis';
|
|
155
|
+
|
|
156
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
157
|
+
|
|
158
|
+
const app = alxia()
|
|
159
|
+
.use(idempotency(connection.client, { name: 'orders', wait: 2_000 })) // keep it under your HTTP timeout
|
|
160
|
+
.post('/orders', async ({ reply }) => reply(201, { id: crypto.randomUUID() }));
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Scope: whose key it is
|
|
164
|
+
|
|
165
|
+
Clients choose their keys, so two clients can choose the same one. The
|
|
166
|
+
stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
|
|
167
|
+
client's address by default. When it is `undefined` — no address, as with
|
|
168
|
+
`app.request()` in a test or a server that cannot see one — the scope is
|
|
169
|
+
`anyone`, and every client shares the key space.
|
|
170
|
+
|
|
171
|
+
Behind a proxy, the address is the proxy's unless the app's `ip` option
|
|
172
|
+
reads the forwarded header. Where requests carry a user, scope by the user:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { alxia } from '@alxia/core';
|
|
176
|
+
import { idempotency } from '@alxia/redis';
|
|
177
|
+
import { connectRedis } from '@nxgt/redis';
|
|
178
|
+
|
|
179
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
180
|
+
|
|
181
|
+
const app = alxia({ ip: (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() })
|
|
182
|
+
.use(
|
|
183
|
+
idempotency(connection.client, {
|
|
184
|
+
name: 'payments',
|
|
185
|
+
scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
.post('/payments', ({ reply }) => reply(201, { ok: true }));
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`scope` receives `BaseContext` — `request`, `url`, `ip`, `route` and the
|
|
192
|
+
rest — not what an earlier `derive` added; read the user from the request.
|
|
193
|
+
Read the forwarded header only behind a proxy you trust.
|
|
194
|
+
|
|
195
|
+
A key is also scoped by route: the same key on `/payments` and `/refunds`
|
|
196
|
+
is two keys.
|
|
197
|
+
|
|
198
|
+
## Order: what runs inside the guard
|
|
199
|
+
|
|
200
|
+
The plugin wraps the routes declared after it, and every route hook
|
|
201
|
+
declared after it too. Whatever those answer is kept like the route's
|
|
202
|
+
answer. A rate limit or an authentication check declared **after**
|
|
203
|
+
`idempotency` has its `429` or `401` kept and replayed — even once the
|
|
204
|
+
client is allowed through. Declare them **before**:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { alxia } from '@alxia/core';
|
|
208
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
209
|
+
import { idempotency, redisStore } from '@alxia/redis';
|
|
210
|
+
import { connectRedis } from '@nxgt/redis';
|
|
211
|
+
|
|
212
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
213
|
+
|
|
214
|
+
const app = alxia()
|
|
215
|
+
.use(rateLimit({ limit: 10, windowMs: 60_000, store: redisStore(connection.client, { name: 'pay' }) })) // its 429 is never kept
|
|
216
|
+
.use(idempotency(connection.client, { name: 'payments' }))
|
|
217
|
+
.post('/payments', ({ reply }) => reply(201, { ok: true }));
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Measured: with the rate limit after `idempotency`, a key refused with a
|
|
221
|
+
`429` answered `429` again, `Idempotent-Replayed: true`, after the window
|
|
222
|
+
had passed; with it before, the same repeat ran the route and answered
|
|
223
|
+
`201`.
|
|
224
|
+
|
|
225
|
+
A route that refuses a request it might accept later — a `401` before the
|
|
226
|
+
client signs in again, a `409` on a state that changes — should answer it
|
|
227
|
+
before the guard, or the client should send a new key when it retries.
|
|
228
|
+
|
|
229
|
+
## The fingerprint
|
|
230
|
+
|
|
231
|
+
The first request's method, path, query and raw body are hashed and stored
|
|
232
|
+
with the key. A repeat whose hash differs is the `422`. Headers are not
|
|
233
|
+
part of it: a repeat with another `Authorization` and the same body is a
|
|
234
|
+
replay, within the same scope.
|
|
235
|
+
|
|
236
|
+
## When the route outlives its lease
|
|
237
|
+
|
|
238
|
+
The lease is renewed by a timer while the route runs. A route that blocks
|
|
239
|
+
the event loop — a synchronous loop, `Bun.sleepSync` — cannot renew it:
|
|
240
|
+
after a whole `lease`, a repeat can take the key and run the route again,
|
|
241
|
+
and the first request ends in a `500`, logging
|
|
242
|
+
|
|
243
|
+
```text
|
|
244
|
+
GuardError: run on "payments": the key was taken from this run before it finished (forgotten, or its lease of 10000ms went unrenewed), so a repeat may have run it too; its result was not stored
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Yield (`await Bun.sleep(0)`) inside long synchronous work, or move it to a
|
|
248
|
+
`Worker`.
|
|
249
|
+
|
|
250
|
+
## Next
|
|
251
|
+
|
|
252
|
+
- [Rate limits](rate-limits.md) — `redisStore`.
|
|
253
|
+
- [Testing](testing.md) — specs for a guarded route.
|
|
254
|
+
- [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard) —
|
|
255
|
+
the primitive underneath, for idempotency outside HTTP.
|