Where a rule applies
Six HTTP components act on requests: authentication, the response cache, conditional requests, idempotent requests, rate limiting, and the access log. Each one answers the same two questions. This page answers them once, so you learn it here and every page after this only lists its own fields.
Two words, on all of them
from grelmicro import Grelmicro
from grelmicro.http import (
ConditionalRequests,
IdempotentRequests,
RateLimitedRequests,
)
from grelmicro.log import AccessLog
from grelmicro.resilience import RateLimiter
from grelmicro.security import TrustedProxies
burst = RateLimiter.sliding_window("burst", limit=100, window=60)
micro = Grelmicro(
uses=[
ConditionalRequests(include=("/carts/*",), exclude=("/carts/legacy",)),
IdempotentRequests(include=("/payments/*",)),
RateLimitedRequests(
burst,
trusted=TrustedProxies(["10.0.0.0/8"]),
exclude=("/livez", "/readyz"),
),
AccessLog(exclude=("/livez", "/readyz")),
]
)
include names the paths a component acts on. Empty means every path.
exclude names the paths it leaves alone, and it wins over include, so you
can name a whole router and carve one route out of it without the two rules
fighting.
Authentication takes exclude alone. A mistyped include
there would leave an endpoint public without a word, so every path is
authenticated unless it is excluded or its route declares Anonymous(). It refuses
a prefix that ends inside a segment, such as /public*, which would also
serve /publicity.
A pattern is an exact path, unless it ends with *, which matches as a
prefix. "/payments/*" covers everything under /payments, and /payments
itself, which is how a router mounted there is written.
The path is the one your routes are declared under, not the one on the wire. A prefix a mount or a proxy adds is taken off first, so the same pattern works whether the app is served at the root, mounted under another app, or sitting behind an ingress.
A string is not a set of paths
exclude="/internal/*" is a missing comma. A string is a sequence of
characters, so it would be read one character at a time, and the *
would match every path. It is refused where it is written, rather than
quietly turning the component off. Write exclude=("/internal/*",).
The response cache says more per path
The cache is the one of the six with something to say about each path, so
its include also takes a mapping from pattern to seconds:
CachedResponses(include=("/products/*",)) # each kept for ttl
CachedResponses(include={"/catalog": 300, "/products/*": 60})
The most specific pattern decides, so {"/products/*": 60, "/products/hot":
300} keeps the hot one for 300 seconds.
What wins
Four things can name one endpoint. In order, first one that answers:
exclude, always. Nothing overrides it.- The route's own declaration,
dependencies=[CachedResponse(ttl=60)]. - The nearest declaration above it: a route beats the router it sits in, and an inner router beats the one that includes it.
- The most specific pattern in
include.
A route that declared something is never overridden by a pattern naming it. The declaration is nearer to the endpoint, and it was written by the person who knows what the endpoint answers.
Reading it back
Nothing above is written twice, which means no single place restates it. So ask the app instead:
python -m grelmicro check app:micro --app app:app
Endpoints
GET /livez -
POST /orders idempotent 3600s rate-limit burst, daily access-log
GET /products cache 60s rate-limit burst, daily access-log
GET /products/hot cache 300s rate-limit burst, daily access-log
One line per endpoint, read from the routes the app declares and the components registered beside them. It is computed, so it cannot drift.
A route template stands for many URLs, and a pattern may name one of them.
exclude=("/users/me",) on an app declaring GET /users/{uid} reads as
access-log (some paths): it reaches that endpoint for one of the requests
it answers and not the others. A parameter takes one path segment of the
URL whatever its converter, and a path parameter every segment left.
A pattern gets no row of its own, because rows come from routes. An
include pattern that matches no route at all is reported beside the table
instead, as a check: a mistyped one turns a rule off without saying so, and
nothing matching is the only trace it leaves. An exclude is not checked,
because carving out a path this app does not serve is usually deliberate.
micro.describe(app).endpoints returns the same thing as data.
The route a request took
route_template(scope) returns the template of the route a request reached,
the same one the access log writes as http.route:
from fastapi import APIRouter, FastAPI
from starlette.types import ASGIApp, Receive, Scope, Send
from grelmicro import Grelmicro
from grelmicro.http import route_template
seen: set[str] = set()
class Coverage:
"""Record each route template a request reached."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(
self, scope: Scope, receive: Receive, send: Send
) -> None:
await self.app(scope, receive, send)
if scope["type"] == "http":
route = route_template(scope)
if route is not None:
seen.add(route)
recipes = APIRouter(prefix="/recipes")
@recipes.get("/{recipe_id}")
async def recipe(recipe_id: int) -> dict[str, int]:
return {"id": recipe_id}
app = FastAPI()
app.include_router(recipes, prefix="/api/meals/v1")
app.add_middleware(Coverage)
micro = Grelmicro()
micro.install(app)
GET /api/meals/v1/recipes/7 records /api/meals/v1/recipes/{recipe_id},
with every prefix the router was included under.
- On Starlette, nested mounts are joined, so
/mm/r/areads as/mm/r/a. - On FastAPI, a route of a FastAPI app mounted with
app.mountreads as its full template, such as/mm/r/{x}. A Starlette app orMountmounted in FastAPI reads as the mount's own template, such as/mm/{path}. - A URL no route matches returns
None. Under a mount, it returns the mount's own template, such as/admin/{path}, because the mount took it. - A request answered before its route ran, such as a refused token or a cached response, returns the route its path reaches.
- On Starlette, a slash redirect returns
None. The access log writes the route it redirects to. - On Litestar, the app's own middleware runs only for a request a route took. A request refused before routing is seen only from a wrapper outside the app.
- The template starts with the app's
root_path, so it names the route as the client sees it behind a proxy.
Call it from a middleware or a handler of an app micro.install(app) wired.
It raises AppNotInstalledError on any other app, and from a wrapper outside
the app before the app runs.
Changing it without a restart
A mounted ConfigMap tunes the response cache, the rate limiter and the access log while the service runs. What the other three do is settled at startup, and the next section says why:
grel:
cached_responses:
ttl: 60
include:
"/products/*": 60
"/products/hot": 300
rate_limited_requests:
exclude: ["/livez", "/readyz"]
access_log:
exclude: ["/livez", "/readyz"]
A flat GREL_... key says the same thing in JSON, which is what a variable
can carry: GREL_ACCESS_LOG_EXCLUDE='["/livez", "/readyz"]'.
Register ExternalConfig
and the next request is answered with the new values. Nothing is rebuilt: the
middleware reads a snapshot the component publishes, so a request already
running finishes on the configuration it started with.
What a file may not change
Live reload tunes what a request costs, never what it is protected by.
| Component | What it protects | Where it is changed |
|---|---|---|
CachedResponses |
latency | live |
RateLimitedRequests |
the service's capacity | live |
AccessLog |
nothing, it observes | live |
ConditionalRequests |
the client's data, from a lost update | a deploy |
IdempotentRequests |
the client's data, from a duplicate | a deploy |
AuthenticatedRequests |
who is let in | a deploy |
A cache miss runs the handler, so turning caching off costs time and nothing else. Take a path out of idempotency and the next retry runs the operation a second time, which for a payment is the outcome idempotency exists to prevent. Take one out of conditional requests and an unconditional write erases an update nobody is told about. Take one out of authentication and anyone can call it.
So those three are configured at startup, every field of them, and changed by
a deploy where they are reviewed. Not a chosen list of fields: every one of
theirs turns out to protect something. Switch fingerprint_body off and a key
reused with a different payload replays the first response. Lower
max_body_size and a large response stops being stored, or stops carrying
an ETag. A list would also have to be maintained, and the field somebody
forgets to add to it is the one a file can then reach.
This is an authorization boundary as much as a design one. Editing a ConfigMap is a much more widely granted permission than shipping an image, so what a file may change is what somebody with only that permission may change.
The same holds for what a client has to send. The OpenAPI schema is built
once, from the app as installed, because it is a published contract rather
than a tuning knob, and each replica polls its own source on its own clock,
so a live schema would have two pods publishing two different documents.
Nothing the schema states is live either: require_key, the header names,
and reused_status move only with a deploy.
RateLimitedRequests keeps its reach live, because its contract has a
superset form: the schema documents the 429 on every operation, which stays
true whichever paths are metered. A rule that says what a client must send
has no superset form, which is why the other three are fixed.
A key a file may not change is reported, naming the variable, and every other key in the same file still applies.
And a file never changes what the app is made of. It cannot register a
component, choose a cache store, add a rate limiter, or supply a key=
function. So a cache pattern arriving from a ConfigMap is checked against
the same rules micro.install(app) checks. One naming a read that runs
checks of its own, a security scheme or an ordinary FastAPI dependency, is
left uncached. One written for a path that answers no GET is refused as the
typo it is, and the running configuration is kept. A prefix is not,
because a router holds writes beside its reads and those are simply left to
their handlers.
The store refuses too, not only the check. A pattern names a URL and a route template stands for many, so no reading of the patterns alone can be trusted to have seen every way a gated read might be named. A path the app refuses to have cached is answered from its handler however it was named.
Where the budget lives
Two settings look like they belong here and do not.
A rate limit is held by the RateLimiter, not by the middleware, because the
buckets are the limiter's. It is tuned under its own name:
grel:
ratelimiter:
burst:
limit: 500
An idempotency window is held by the Idempotency that stores the response,
for the same reason. IdempotentRequests names its one after the namespace
it stores under, so the key is grel.idempotency.http.ttl.
The split is the point. The HTTP component says where a rule applies and what one request costs. The object underneath says what the budget is.