Caching
Gebweb's response cache is opt-in per route. Register a store on the
app, decorate handlers with @Cache(ttl), and the framework checks
the cache before binding / dispatching the handler. On routes that
carry @Auth (or role / permission requirements) authentication runs
BEFORE the cache lookup - a cache hit never bypasses auth - and the
cache key automatically varies on Authorization and Cookie, so
one user's cached response is never served to another. Anonymous
routes keep the cheap cache-first path.
Registering a store
import web.cache as cache;
let store = cache.fileCacheStore("/tmp/app-cache", 3600);
gebweb.useCacheStore(app, store);
store is any value with get(key): any and
set(key, value): bool methods. The stdlib provides three drop-in
stores:
| Factory | Backend |
|---|---|
gebweb.redisCacheStore(opts) |
Redis (shared) |
web.cache.memoryCacheStore(ttl) |
In-process (memory) |
web.cache.fileCacheStore(directory, ttl) |
JSON files on disk |
web.cache.databaseCacheStore(conn, table, ttl) |
SQL table |
The store's TTL governs eventual eviction; per-route @Cache(ttl)
wraps a tighter TTL envelope inside the stored value (see below).
Redis cache store
gebweb.redisCacheStore(opts) returns a cache store backed by Redis.
Because the store lives outside the process, multiple app instances
sharing the same address share the same cache - a cache hit written by
any instance is served by all others.
Requires geblang 1.28.0+ (uses redis.Client.setex/eval).
import gebweb;
let app = gebweb.app(controllers);
gebweb.useCacheStore(app,
gebweb.redisCacheStore({
"address": "localhost:6379",
"ttl": 300,
"poolSize": 8,
})
);
gebweb.serve(app);
Options:
| Option | Default | Meaning |
|---|---|---|
address |
required | "host:port" of the Redis server. |
ttl |
300 |
Store-level TTL in seconds. |
poolSize |
8 |
Max concurrent connections to Redis. |
password |
none | Password for Redis AUTH. |
db |
0 |
Redis logical database index (SELECT). |
pool |
none | Pre-built gebweb.redisPool(...) (see below). |
logger |
none | func(string): void for warn messages. |
Fail-open behavior: any Redis error (connection refused, timeout,
command error) is caught and warn-logged. get returns null (cache
miss); set and delete are no-ops. A Redis outage degrades to
serving every request from the origin - the app never surfaces the
Redis error to clients.
Sharing a Redis connection pool
Both redisCacheStore and redisRateLimit (see middleware chapter)
open their own connection pool by default. To share one pool between
them - avoiding double the connections to the same server - build a
pool explicitly and pass it as the pool option:
let pool = gebweb.redisPool({
"address": "localhost:6379",
"poolSize": 8,
});
gebweb.useCacheStore(app,
gebweb.redisCacheStore({"pool": pool, "ttl": 300})
);
gebweb.before(app,
gebweb.redisRateLimit({"pool": pool, "perSecond": 50, "burst": 100})
);
gebweb.redisPool(opts) accepts address, poolSize, password,
and db. The pool holds up to poolSize live connections; a
connection that errors on use is closed and replaced automatically.
Opting routes in
class FeedController {
@Get("/feed")
@Cache(ttl: 60)
func feed(): dict<string, any> {
return {"items": expensiveLookup()};
}
}
The cache key defaults to METHOD path (e.g. GET /feed). Cache
hits skip every later stage: handler dispatch, validation, even auth.
Vary on headers
For routes whose response depends on a header (auth identity, tenant, preferred language):
@Get("/me")
@Auth
@Cache(ttl: 30)
func me(CurrentUser user): dict<string, any> {
return {"id": user.id, "name": user.name};
}
The cache key becomes METHOD path key=value;key=value with vary
headers sorted alphabetically (case-insensitive header lookup).
@Auth routes vary on Authorization and Cookie automatically;
listing extra headers in vary adds them on top. Different
credential values get different cache entries.
Class-level @Cache
A class-level decorator applies to every method:
@Cache(ttl: 10)
class PublicController {
@Get("/public/banner") func banner(): dict<string, any> { /* ... */ }
@Get("/public/feed") func feed(): dict<string, any> { /* ... */ }
}
A method-level @Cache overrides the class-level one for that route.
What's NOT cached
- Non-2xx responses are never written to the cache. A 4xx / 5xx is surfaced normally; if the next call returns 200, that response takes the cache slot.
- Streaming responses (
@WebSocket,@Sse,gebweb.stream) are passed through. There's no useful "cached stream" semantics. @Cache(ttl: 0)disables the per-route envelope; entries live for the store's TTL only.
TTL envelope mechanics
A per-route @Cache(ttl: 60) writes a stored value shaped as:
{"response": <response>, "expiresAt": <now + ttl>}
The store wraps THAT in its own envelope and tracks the broader
store-TTL eviction. On read, the framework strips the store envelope,
then checks the route envelope's expiresAt and treats expired
entries as misses. This means a store-TTL of 1 hour can host a route
TTL of 30 seconds without storage drift.
Invalidating after a write
When you write to something that has a cached read, you usually
want to drop the cached read so the next request rebuilds it.
For example: GET /widgets is @Cache-d, and POST /widgets
should drop that cached list.
The cache key is built from the request. A fake GET request
shape gives you the same key the framework would have written:
import gebweb.cache as cache;
@Post("/widgets")
func create(WidgetDto body): dict<string, any> {
repo.insert(body);
let key = cache.cacheKey({"method": "GET", "path": "/widgets",
"headers": {}, "query": {}}, []);
this.store.delete(key);
return {"created": true};
}
If the cached route uses @Cache(vary: ["X-User"]), pass the
same list as the second argument to cacheKey so the key
matches.
Reference
gebweb.useCacheStore(app, store): GebwebApp- register a cache store.storeis any value withget(key)andset(key, value).@Cache(ttl: N)- opt a route in.ttlis the per-route TTL in seconds; 0 means defer to the store TTL.@Cache(ttl: N, vary: ["X-User", ...])- extend the cache key with header values. Vary keys are sorted; lookup is case-insensitive.- Facade stores:
gebweb.redisCacheStore(opts)(Redis, shared across instances; geblang 1.28.0+),web.cache.fileCacheStore(directory, ttl)(JSON files),web.cache.databaseCacheStore(conn, table, ttl)(SQL).
Helper primitives in gebweb.cache for custom store integrations:
findCacheDecorator, ttlFromDecorator, varyFromDecorator,
cacheKey, envelope, openEnvelope.
Cache tags and invalidation
Tag cached responses and drop them by tag after writes:
@Get("/users/{id}")
@Cache(ttl: 300, tags: ["user:{id}", "users"])
func show(@PathParam("id") string id): dict<string, any> { ... }
/* after updating user 42: */
gebweb.cacheInvalidate(app, "user:42"); /* one user's entries */
gebweb.cacheInvalidate(app, "users"); /* every tagged entry */
{name} placeholders resolve from the request's path parameters when
the response is cached.
Cross-process invalidation. When the cache store provides shared
tags (the Redis store does), each tag's generation counter and key
index live in Redis, and cacheInvalidate runs atomically (a Lua
script): it advances the shared generation and deletes every entry
recorded under the tag. So an invalidation on one instance is seen by
every instance sharing that Redis - the entry is dropped, and any
response computed against the old generation is detected as stale on
read across all instances. With the in-memory or file store (no shared
tags), invalidation is process-local: each instance only drops its
own entries.
Idempotency
The @Idempotent decorator makes state-changing endpoints safe to
retry. A duplicate request carrying the same client-supplied key
replays the original response instead of re-running the handler. A
concurrent duplicate is rejected while the first is still in flight.
This is the payment-API safety property (Stripe-style
Idempotency-Key).
Registering a store
import gebweb;
let app = gebweb.app(controllers);
/* Single-process (in-memory, no external dependency): */
gebweb.useIdempotencyStore(app, gebweb.memoryIdempotencyStore());
/* Multi-instance (Redis, shared across processes): */
gebweb.useIdempotencyStore(app,
gebweb.redisCacheStore({
"address": "localhost:6379",
"ttl": 86400,
"poolSize": 4,
})
);
| Factory | Backend | Atomicity guarantee |
|---|---|---|
gebweb.memoryIdempotencyStore() |
In-process store.Store | Strong (in-process atomic) |
gebweb.redisCacheStore(opts) |
Redis (shared) | Strong (Lua SET NX atomic) |
The file cache store (web.cache.fileCacheStore) can be passed but
its putIfAbsent is best-effort (get-then-set, no file lock). Use
the Redis or in-memory store for real concurrency.
Decorating a handler
class OrderController {
@Post("/orders")
@Idempotent
func create(OrderDto body): dict<string, any> {
/* Runs at most once per Idempotency-Key value. */
return {"id": repo.insert(body)};
}
@Post("/payments/{id}/capture")
@Idempotent(required: true, ttl: 3600)
func capture(@PathParam("id") string id): dict<string, any> {
return {"captured": payment.capture(id)};
}
}
Options:
| Option | Default | Meaning |
|---|---|---|
required |
false |
true -> missing key returns 400; false -> runs normally. |
ttl |
86400 (24 h) |
How long the record is retained, in seconds. |
header |
"Idempotency-Key" |
Request header carrying the client key. |
methods |
["POST", "PUT", "PATCH"] |
Methods the decorator applies to; others run normally. |
The Idempotency-Key header
The client sends a unique opaque value (UUID, random hex, etc.) with each request that should be idempotent:
POST /orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"item": "widget", "qty": 1}
The key is scoped per route and method internally, so the same key value on different endpoints does not collide.
Request flow
- If the request method is not in
methods(e.g. GET), the handler runs normally - no idempotency check. - If no key header is present and
required: false, the handler runs normally. Ifrequired: true, 400 is returned. - The framework builds a record key from the route, method, and client key, and a fingerprint from the method, path, and request body.
- Atomic claim via
putIfAbsent:- Claim succeeds (key absent): the handler runs.
- 2xx response: the response is stored and returned to the caller (NOT marked as a replay - it is the original).
- Non-2xx response or thrown exception: the marker is released so the next same-key request re-runs.
- Claim fails (key present):
in-progress: 409 ("a request with this idempotency key is already in progress").completed, same fingerprint: the stored response is returned withIdempotent-Replayed: true.completed, different fingerprint: 422 ("idempotency key reused for a different request").
- Claim succeeds (key absent): the handler runs.
Responses
| Condition | Status | Idempotent-Replayed header |
|---|---|---|
| First call (handler ran) | as-is | absent |
| Replay (same key + same body) | as-is | true |
| In-progress duplicate | 409 | absent |
| Key reused for different body | 422 | absent |
required: true + no key |
400 | absent |
Non-replay error responses use application/problem+json.
Release on failure
If the handler throws or returns a non-2xx status, the idempotency marker is deleted before the error propagates. This means a transient failure (network error, timeout, validation failure) does not lock the client out - a retry with the same key re-runs the handler.
Only successful (2xx) responses are stored for replay.
Example: verifying idempotency in tests
import test;
import gebweb;
class OrderTest extends test.Test {
gebweb.TestClient client;
int runs;
func setUp(): void {
this.runs = 0;
let app = gebweb.app([OrderController()]);
gebweb.useIdempotencyStore(app, gebweb.memoryIdempotencyStore());
this.client = gebweb.TestClient(app);
}
@test
func replayReturnsSameResponse(): void {
let hdrs = {"Idempotency-Key": "test-key-1"};
let a = this.client.request("POST", "/orders", {"item": "widget"}, hdrs);
a.assertStatus(201);
let b = this.client.request("POST", "/orders", {"item": "widget"}, hdrs);
b.assertStatus(201);
this.assertEquals("true", b.headers["Idempotent-Replayed"]);
/* Handler ran only once. */
}
}
Reference
gebweb.useIdempotencyStore(app, store): GebwebApp- register a store. Store must implementget(key),set(key, value, ttl),delete(key), andputIfAbsent(key, value, ttl): bool.@Idempotent- opt a handler in with defaults (POST/PUT/PATCH, TTL 24 h,Idempotency-Keyheader, key optional).@Idempotent(required, ttl, header, methods)- override any option.gebweb.memoryIdempotencyStore()- in-process store; strong atomicity viastore.Store; lost on process restart.gebweb.redisCacheStore(opts)- Redis-backed store;putIfAbsentuses an atomic LuaSET NX PXscript; survives restarts and works across multiple app instances.