HTTP
- Start here: HTTP
- The errors: Errors
- The own-port server: Ops Server
Register ErrorResponses() and micro.install(app) wires the handler, so a
rejection raised in a route handler answers the client as an
application/problem+json body. Import ProblemDetail to return one of your
own, and send_error to write one from a pure-ASGI middleware.
Register AuthenticatedRequests(verifier) and every request is authenticated
with a bearer token, the verified caller in request.user. Read
Authentication.
Register IdempotentRequests() and install adds IdempotencyMiddleware,
which replays a stored response when a request repeats its Idempotency-Key.
Register ConditionalRequests() and install adds the entity tags, so
check_precondition(etag_of(version)) refuses a write whose If-Match moved
on. Read Conditional Requests.
Register CachedResponses(), declare CachedResponse(ttl=...) on a route, and
a repeated read is answered from the cache instead of the handler. Read
Response Cache.
Register RateLimitedRequests(limiter, trusted=...) and a caller over its
budget is answered 429 at the edge, with the quota it has left. Read Rate
Limit.
Call route_template(request.scope) for the route a request reached, the one
the access log names. Read The route a request took.
Register OpsServer() on a process that serves no HTTP, and the health probes
and the Prometheus endpoint answer on a port of their own.
grelmicro.http
HTTP.
Puts grelmicro's rejections on the wire. AdmissionError already covers
every "turned away" case, and ProblemDetail renders it as the
application/problem+json body of RFC 9457, with the field the client
needs next: how long to wait, or that there is nothing to wait for.
Register ErrorResponses() to opt in, and micro.install(app) wires the
handler, so a rate limiter, a bulkhead, an open circuit breaker, or an
elapsed deadline answers the client without a single except in a route
handler. Without it grelmicro changes nothing about how your framework
answers an error.
The format comes from the factory you call, never from a variable. RFC 9457
problem details are the default, and ErrorResponses.tmf() renders the TM
Forum format for a service answering to a TM Forum Open API platform.
IdempotencyMiddleware replays a stored response when a request repeats
its idempotency key, and IdempotentRequests() registers it so
micro.install(app) adds it.
ConditionalRequests() answers conditional requests: it puts an ETag on
responses, answers 304 to a read that already holds one, and lets
check_precondition(etag_of(version)) refuse a write whose If-Match no
longer matches.
Read more in the Error Responses and Idempotency Middleware docs.
Each name loads its submodule on first access.
PROBLEM_MEDIA_TYPE
module-attribute
PROBLEM_MEDIA_TYPE = 'application/problem+json'
Media type every problem detail is served with, from RFC 9457.
ERROR_DOCS_BASE
module-attribute
ERROR_DOCS_BASE = DOCS_BASE
Where a type URI points, one anchor per rejection.
OpsServer
OpsServer(
*,
enabled: bool | None = None,
port: int | None = None,
host: str | None = None,
show_details: bool | None = None,
request_timeout: float | None = None,
shutdown_timeout: float | None = None,
max_connections: int | None = None,
name: str = "default",
env_prefix: str | None = None,
env_load: bool | None = None,
)
Serve the health and metrics endpoints on a port of their own.
For a process that runs no web framework: a FastStream consumer, a scheduler, a worker. Kubernetes still needs somewhere to send its probes, and Prometheus still needs somewhere to scrape.
from grelmicro import Grelmicro
from grelmicro.health import HealthChecks
from grelmicro.http import OpsServer
from grelmicro.metrics import Metrics
micro = Grelmicro(
uses=[
HealthChecks(provider_checks="require"),
Metrics(exporter="prometheus"),
OpsServer(port=8080),
]
)
It serves what the app registers: the three health endpoints when a
HealthChecks is registered, and /metrics when a Metrics is. An
app that registers neither has nothing to serve, so the server says so
instead of listening.
It speaks HTTP/1.1 over the standard library, answers one request per connection, and serves nothing but these endpoints. It speaks no TLS and reads no request body, so give it the pod network rather than an ingress.
Register it first in uses=[...] and it closes last, so the probes keep
answering while the rest of the app drains.
Read more in the Ops Server docs.
Initialize the ops server.
| PARAMETER | DESCRIPTION |
|---|---|
enabled
|
Whether the server starts. Default: True. Resolves from
TYPE:
|
port
|
Port to listen on. Default: 8080. When unset and env reads are enabled (see
TYPE:
|
host
|
Address to bind. Empty binds every interface, IPv4 and IPv6. Default: empty. Resolves from
TYPE:
|
show_details
|
Whether Default: False. Resolves from
TYPE:
|
request_timeout
|
Seconds one request may take, from the first byte read to the last byte written. Default: 10.0. Resolves from
TYPE:
|
shutdown_timeout
|
Seconds in-flight requests get to finish on shutdown. Default: 5.0. Resolves from
TYPE:
|
max_connections
|
Connections served at once. Default: 32. Resolves from
TYPE:
|
name
|
Registration name. Two
TYPE:
|
env_prefix
|
Override the auto-derived environment variable prefix. Default:
TYPE:
|
env_load
|
Whether to read environment variables. When None (the default), follow the process-wide
TYPE:
|
kind
class-attribute
kind: str = 'ops'
name
property
name: str
Return the registration name.
paths
property
paths: tuple[str, ...]
Return the paths served.
Empty until the app it belongs to has finished opening, because
until then it answers /livez alone and what it will serve is not
settled: a Metrics still opening has no exporter to read. Always
empty when the server is switched off.
from_config
classmethod
from_config(
config: OpsServerConfig, *, name: str = "default"
) -> Self
Construct an OpsServer from a pre-built OpsServerConfig.
| PARAMETER | DESCRIPTION |
|---|---|
config
|
The pre-built ops server configuration. Use this path when the configuration is assembled at
startup from a settings tree (for example YAML, Vault, or a
TYPE:
|
name
|
Registration name. Defaults to
TYPE:
|
OpsServerConfig
Bases: BaseModel
Ops Server Config.
enabled
class-attribute
instance-attribute
enabled: bool = True
Whether the server starts. False binds no port and serves nothing, and the app opens as it would without one.
host
class-attribute
instance-attribute
host: str = ''
Address to bind. Empty binds every interface, IPv4 and IPv6, which is what the kubelet needs to reach the pod. Set 127.0.0.1 to keep the port on loopback.
port
class-attribute
instance-attribute
port: int = 8080
Port to listen on.
show_details
class-attribute
instance-attribute
show_details: bool = False
Whether /healthz includes each check's verbose details field. False strips them.
request_timeout
class-attribute
instance-attribute
request_timeout: PositiveFloat = 10.0
Seconds one request may take, from the first byte read to the last byte written. A connection that goes quiet is dropped.
shutdown_timeout
class-attribute
instance-attribute
shutdown_timeout: NonNegativeFloat = 5.0
Seconds in-flight requests get to finish on shutdown before they are cancelled.
max_connections
class-attribute
instance-attribute
max_connections: PositiveInt = 32
Connections served at once. Beyond it a connection is answered 503 and closed.
OpsServerError
Bases: GrelmicroError
Raised when the ops server cannot serve.
The app registers nothing the server can answer, no Grelmicro app is
active, or the port is taken.
ErrorResponses
ErrorResponses(*, name: str = 'default')
Answer every grelmicro rejection in a standard error format.
Register it to opt in, and micro.install(app) wires the handler into
FastAPI, Starlette, or Litestar. Without it grelmicro registers nothing,
and a rejection reaches the framework's own error handling exactly as any
other exception does.
A rate limiter over budget, a full bulkhead, an open circuit breaker, a
lock held elsewhere, or an elapsed deadline then answers the client with
a body carrying what it can act on, instead of becoming a 500.
The bare constructor renders RFC 9457
problem details, which is the format an HTTP API is expected to speak.
ErrorResponses.tmf() renders the TM Forum format instead:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses
micro = Grelmicro(uses=[ErrorResponses()])
app = FastAPI()
micro.install(app)
A format is chosen by the factory you call, never by a variable, because it is the shape of the response and not a value to tune. One rendering answers for the whole app, so registering two is refused.
A framework that serves no HTTP, such as FastStream, ignores it.
Read more in the Error Responses docs.
Render rejections as RFC 9457 problem details.
| PARAMETER | DESCRIPTION |
|---|---|
name
|
Registration name. Only one may be registered.
TYPE:
|
kind
class-attribute
kind: str = 'error_responses'
singleton
class-attribute
singleton: bool = True
singleton_reason
class-attribute
singleton_reason: str = "One rendering answers for the whole app, so two formats cannot both be registered"
name
property
name: str
Return the registration name.
media_type
property
media_type: str
Return the content type every rendered error is served with.
model
property
model: type[BaseModel]
Return the body model, for publishing the shape in OpenAPI.
Each registered component reads it when the OpenAPI schema is built, so the schema describes the format the app answers in.
tmf
classmethod
tmf(
*,
code_prefix: str = DEFAULT_CODE_PREFIX,
reference_error: str | None = DOCS_BASE,
name: str = "default",
) -> Self
Render rejections in the TM Forum error format of TMF630.
For a service that answers to a telco or OSS platform built on TM
Forum Open APIs, where the client expects code and reason
rather than type and title.
The status codes are the same ones RFC 9457 mode returns. TMF630
mandates the IANA registry and names 422, 429 and 503 itself,
so nothing is remapped and Retry-After keeps its meaning.
micro = Grelmicro(uses=[ErrorResponses.tmf(code_prefix="SBB")])
Two members do not survive the format. There is no equivalent of
instance, and TMF630 defines no extension mechanism, so a
retry_after reaches the client only as the Retry-After header.
reference_error=None leaves the documentation URI out, for a
service whose responses must name no address outside it.
| PARAMETER | DESCRIPTION |
|---|---|
code_prefix
|
Namespace for the
TYPE:
|
reference_error
|
Base the
TYPE:
|
name
|
Registration name. Only one may be registered.
TYPE:
|
problem_details
classmethod
problem_details(*, name: str = 'default') -> Self
Render rejections as RFC 9457 problem details.
The same as the bare constructor, for a wiring that says which format it speaks rather than leaving it to the default.
| PARAMETER | DESCRIPTION |
|---|---|
name
|
Registration name. Only one may be registered.
TYPE:
|
render_status
render_status(
status: int,
*,
detail: str | None = None,
instance: str | None = None,
extensions: dict[str, Any] | None = None,
) -> RenderedError
Return the response for an error the framework raised.
The rejections grelmicro raises go through render, which knows
what each one is. This renders one it does not know, so an app's own
HTTPException and its request validation failures answer in the
same format as everything else.
The status and the message stay as the framework set them. Only the shape changes.
| PARAMETER | DESCRIPTION |
|---|---|
status
|
HTTP status code of the response.
TYPE:
|
detail
|
Explanation of this occurrence, safe to show a client.
TYPE:
|
instance
|
Request path recorded as the occurrence.
TYPE:
|
extensions
|
Extra members, such as the field errors of a validation failure.
TYPE:
|
render_validation
render_validation(
field_errors: list[dict[str, Any]],
*,
status: int,
detail: str | None = None,
instance: str | None = None,
) -> RenderedError
Return the response for a request that failed validation.
A known kind, so a client branches on the identifier rather than on the status, and reads the same identifier whichever framework validated the request.
The status stays the framework's. FastAPI answers 422 and Litestar
400, and which is right is a question those projects have already
answered for their users. 422 is the more precise code for a
request that is well formed but semantically wrong, and RFC 9110
section 15.5.21 now defines it, but grelmicro reshapes an answer
rather than overruling it.
| PARAMETER | DESCRIPTION |
|---|---|
field_errors
|
One entry per part of the request that did not match.
TYPE:
|
status
|
Status the framework chose. Kept, not second-guessed.
TYPE:
|
detail
|
What the framework said, when it said anything useful.
TYPE:
|
instance
|
Request path recorded as the occurrence.
TYPE:
|
render
render(
exc: BaseException, *, instance: str | None = None
) -> RenderedError | None
Return the response for exc, or None when grelmicro has none.
An error grelmicro did not raise to turn a caller away returns
None, so the framework answers it as it always did.
| PARAMETER | DESCRIPTION |
|---|---|
exc
|
The rejection to render.
TYPE:
|
instance
|
Request path recorded as the occurrence.
TYPE:
|
AuthenticatedRequests
AuthenticatedRequests(
verifier: _Verifier,
*,
exclude: tuple[str, ...] = (),
bans: ClientBans | None = None,
trusted: TrustedProxies | None = None,
check: _CallerCheck | None = None,
resource: str | None = None,
authorization_servers: tuple[str, ...] | list[str] = (),
scopes: tuple[str, ...] | list[str] = (),
enduser: bool = False,
name: str = "default",
openapi: bool = True,
)
Authenticate every request, wired by micro.install(app).
Register it and install adds AuthenticatedRequestsMiddleware, ahead
of every other middleware of ours that can answer a request:
from grelmicro import Grelmicro
from grelmicro.http import AuthenticatedRequests, ErrorResponses
from grelmicro.security import JWTVerifier
micro = Grelmicro(
uses=[
ErrorResponses(),
AuthenticatedRequests(
JWTVerifier.discover(
"https://auth.example.com/", audience="orders-api"
),
exclude=("/livez", "/readyz"),
),
]
)
Every request is authenticated except the paths in exclude. A route
declaring Anonymous() authenticates a request only when it sends a
token. The verifier is opened with the app,
so its keys load before the first request and stay fresh while it
serves.
Nothing is read from the environment and nothing is live: every setting decides what the service is protected by, so each one changes with a deploy.
Read more in the Authentication docs.
Authenticate every request through the registered middleware.
| PARAMETER | DESCRIPTION |
|---|---|
verifier
|
Verifies each bearer token, such as a
TYPE:
|
exclude
|
Paths served without a credential, such as health probes. Exact match unless the pattern ends with
TYPE:
|
bans
|
Refuses a caller that keeps presenting forged tokens.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed, for resolving the caller a ban is counted against.
TYPE:
|
check
|
Checks every verified caller before the app sees it, such as against the tokens the service revoked. Called with the caller and the request's ASGI scope, it returns the caller routes receive, or
TYPE:
|
resource
|
The URL clients call the service at, as they see it behind any proxy, such as
TYPE:
|
authorization_servers
|
Issuers a client gets a token from, listed in the metadata. Left empty, the verifier's issuers are listed.
TYPE:
|
scopes
|
Scopes a client may ask for, listed in the metadata. Left empty, none are listed, because the document is public.
TYPE:
|
enduser
|
Name the caller as
TYPE:
|
name
|
Registration name. Only one may be registered.
TYPE:
|
openapi
|
Describe the security scheme, and the
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If |
ValueError
|
If a pattern in |
SettingsValidationError
|
If |
kind
class-attribute
kind: str = 'authenticated_requests'
singleton
class-attribute
singleton: bool = True
singleton_reason
class-attribute
singleton_reason: str = "One authentication answers for the whole app, because a second would require every token to pass both. To accept tokens from several issuers, pass one verifier that accepts them"
asgi_authenticates
class-attribute
asgi_authenticates: bool = True
Placed ahead of every other answering middleware of ours at install.
name
property
name: str
Return the registration name.
verifier
property
verifier: _Verifier
Return the verifier every token is checked against.
from_config
classmethod
from_config(
verifier: _Verifier,
config: AuthenticatedRequestsConfig,
*,
bans: ClientBans | None = None,
trusted: TrustedProxies | None = None,
check: _CallerCheck | None = None,
name: str = "default",
openapi: bool = True,
) -> Self
Build the component from a configuration that is already whole.
The one declarative door. The verifier and the check stay beside the config, because they are objects rather than settings.
| PARAMETER | DESCRIPTION |
|---|---|
verifier
|
Verifies each bearer token, such as a
TYPE:
|
config
|
The pre-built authenticated requests configuration. |
bans
|
Refuses a caller that keeps presenting forged tokens.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed.
TYPE:
|
check
|
Checks every verified caller before the app sees it. It returns the caller routes receive, or
TYPE:
|
name
|
Registration name. Only one may be registered.
TYPE:
|
openapi
|
Describe the security scheme, and the
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If a config is passed where the verifier goes. |
asgi_middleware
asgi_middleware() -> tuple[type[Any], dict[str, Any]]
Return the middleware class and the arguments to build it with.
read_routes
read_routes(app: Any) -> None
Check the routes the app declares, now and when it starts.
Called by the integration after the middleware is added. The app is checked again when it starts, so a route added between the two counts as well.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The application to read the routes off.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If a route sits where |
route_gate
route_gate(
app: Any,
errors: ErrorResponses | None = None,
answering: Answering = (),
routed: Iterable[tuple[Any, Mapping[str, Any]]] = (),
) -> RouteGate | None
Return the gate the app's routes are wrapped with.
Called by micro.install(app) once the middleware is added, for an
integration that declares its routes. None when the middleware in
front of the app is not this component's, such as one the app added
by hand, which serves public paths through exclude alone. When
the app runs this component's middleware behind its router, an
answering middleware the app runs there too is left to it.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The application whose routes to gate.
TYPE:
|
errors
|
The registered format refusals are answered in, if any.
TYPE:
|
answering
|
Each registered middleware that may answer a request itself, as its class and arguments, in the order the integration placed them.
TYPE:
|
routed
|
The middleware the app runs once its router matched a route, as class and arguments, when its integration says.
TYPE:
|
handled_exceptions
handled_exceptions() -> tuple[type[Exception], ...]
Return what this component answers rather than letting through.
The middleware answers what it refuses itself. These are what a route raises, such as a missing scope, and registering the component is the opt-in for answering those the same way.
AuthenticatedRequestsConfig
Bases: BaseModel
Authenticated Requests Config.
Where authentication does not apply. Every other path is authenticated,
and there is no include: a mistyped one would leave an endpoint public
without a word. Nothing here is read from the environment, because every
field decides what the service is protected by.
exclude
class-attribute
instance-attribute
exclude: PathPatterns = ()
Paths served without a credential, such as health probes. Exact match unless the pattern ends with /*, which matches as a prefix.
resource
class-attribute
instance-attribute
resource: str | None = None
The URL clients call the service at, as they see it behind any proxy. Publishes the service's protected resource metadata and adds resource_metadata to every bearer challenge. An https URL with no fragment.
authorization_servers
class-attribute
instance-attribute
authorization_servers: tuple[str, ...] = ()
Issuers a client gets a token from, listed in the metadata. Left empty, the verifier's issuers are listed.
scopes
class-attribute
instance-attribute
scopes: tuple[str, ...] = ()
Scopes a client may ask for, listed in the metadata. Left empty, none are listed.
enduser
class-attribute
instance-attribute
enduser: bool = False
Whether the server span and the security events name the caller as enduser.id, the subject of a token whose signature verified.
AuthenticatedRequestsMiddleware
AuthenticatedRequestsMiddleware(
app: ASGIApp,
*,
verifier: _Verifier,
exclude: tuple[str, ...] = (),
bans: ClientBans | None = None,
trusted: TrustedProxies | None = None,
check: _CallerCheck | None = None,
resource: str | None = None,
authorization_servers: tuple[str, ...] | list[str] = (),
scopes: tuple[str, ...] | list[str] = (),
enduser: bool = False,
policies: _GatePolicies | None = None,
)
Authenticate every request before the app sees it.
Reads the bearer token from Authorization, verifies it, and puts the
verified caller in scope["user"] and scope["auth"], which is where
request.user, request.auth and Starlette's @requires look.
from grelmicro.http import AuthenticatedRequestsMiddleware
from grelmicro.security import JWTVerifier
app.add_middleware(
AuthenticatedRequestsMiddleware,
verifier=JWTVerifier.discover(
"https://auth.example.com/", audience="orders-api"
),
exclude=("/livez", "/readyz"),
)
Register AuthenticatedRequests(...) instead to have
micro.install(app) add it, place it and open its verifier for you.
What a refused caller is told:
- No credential, or one in another scheme such as
Basic:401. - A token that does not verify:
401, with the reason. - A caller
checkrefuses:401, with the reasonrevoked. - More than one credential:
400. - A caller
bansrefuses:429, withRetry-After. - Keys that have not loaded:
503.
Each is rendered by the app's ErrorResponses, with the
WWW-Authenticate challenge RFC 6750 gives it, and written as a
security event on grelmicro.security.events.
With resource=, it serves the service's protected resource metadata,
RFC 9728, at /.well-known/oauth-protected-resource followed by the
path of resource, to any caller. Every bearer challenge the app
answers with then carries resource_metadata, pointing at it.
A token naming a key the verifier does not hold waits for one refresh
and is verified again, so the first request after a rotation is served.
The verifier fetches at most once per retry_interval, so a caller
inventing key ids costs one fetch, not one per request.
The middleware is pure ASGI. It acts on http and websocket scopes and
passes every other scope through untouched. A refused websocket is
answered with the same 401 on a server that supports the denial
response extension, and closed before the handshake completes on one
that does not.
Initialize the middleware with the verifier it trusts.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the middleware chain.
TYPE:
|
verifier
|
Verifies each bearer token, such as a
TYPE:
|
exclude
|
Paths served without a credential. Exact match unless the pattern ends with
TYPE:
|
bans
|
Refuses a caller that keeps presenting forged tokens, before its next token is verified. Counts only the reasons it is configured with.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed, for resolving the caller a ban is counted against. Required with
TYPE:
|
check
|
Checks each verified caller before the app sees it, such as against the tokens the service revoked. Called with the caller and the request's ASGI scope, it returns the caller the app receives, or
TYPE:
|
resource
|
The URL clients call the service at, as they see it behind any proxy. Publishes its protected resource metadata.
TYPE:
|
authorization_servers
|
Issuers listed in the metadata. Left empty, the verifier's.
TYPE:
|
scopes
|
Scopes listed in the metadata. Left empty, none are.
TYPE:
|
enduser
|
Name the caller as
TYPE:
|
policies
|
The policy the gates of each app read, filled by
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If |
ValueError
|
If a pattern in |
SettingsValidationError
|
If |
app
instance-attribute
app = app
IdempotentRequests
IdempotentRequests(
*,
ttl: int | timedelta | None = None,
namespace: str = "http",
cache: TTLCache[Any] | None = None,
requires: BackendScope | None = None,
key_header: str | None = None,
replay_header: str | None = None,
methods: Collection[str] | None = None,
key: RequestKeyFunction | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
require_key: bool | None = None,
fingerprint_body: bool | None = None,
max_body_size: int | None = None,
max_wait: float | None = None,
include: tuple[str, ...] | None = None,
exclude: tuple[str, ...] | None = None,
reused_status: int | None = None,
openapi: bool = True,
name: str = "default",
env_prefix: str | None = None,
env_load: bool | None = None,
)
Bases: AnswersAtRoute, Reconfigurable[IdempotentRequestsConfig]
Replay repeated requests, wired by micro.install(app).
Register it and install adds IdempotencyMiddleware to the app and
describes it in the OpenAPI schema, so the container holds the whole
wiring:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.http import ErrorResponses, IdempotentRequests
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[Cache(redis), ErrorResponses(), IdempotentRequests()])
app = FastAPI()
micro.install(app)
The bare form stores responses under Idempotency("http"), which keeps
them for a day and rides the registered Cache. Pass an Idempotency
of your own to set the lifetime, the namespace, or the cache it uses.
Every option of IdempotencyMiddleware is taken here and forwarded,
so a registered component and a hand-added middleware answer the same.
A framework that serves no HTTP, such as FastStream, ignores it.
Read more in the Idempotency Middleware docs.
Replay repeated requests through the registered cache.
| PARAMETER | DESCRIPTION |
|---|---|
ttl
|
How long a stored response replays for, in whole seconds or as a
TYPE:
|
namespace
|
Namespace the stored keys sit under, so two sets of rules on one app never read each other's responses. Part of every stored key, so it is not live.
TYPE:
|
cache
|
The
TYPE:
|
requires
|
The smallest scope the cache backend must reach:
TYPE:
|
key_header
|
Request header carrying the idempotency key.
TYPE:
|
replay_header
|
Response header marking a replayed response. No standard names one, so pick what the clients already read.
TYPE:
|
methods
|
Methods that take an idempotency key. Every other method passes through.
TYPE:
|
key
|
Build the stored key from the ASGI scope and the client key. Set this in any multi-tenant app, folding in the caller identity.
TYPE:
|
skip
|
Predicate receiving the response. Return
TYPE:
|
require_key
|
Answer
TYPE:
|
fingerprint_body
|
Hash the request body and store the hash with the response, so a key reused with a different body gets
TYPE:
|
max_body_size
|
Largest body held in memory, in bytes.
TYPE:
|
max_wait
|
Seconds a duplicate waits for an execution already in flight, before it is answered with
TYPE:
|
include
|
Paths this middleware acts on. Empty means every path. Name the prefix of a router to select it, as
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever
TYPE:
|
reused_status
|
Status answering a key reused with a different payload.
TYPE:
|
openapi
|
Describe both headers and the responses the middleware returns in the OpenAPI schema. Read once when the schema is built, so it is not live.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
env_prefix
|
Override the derived prefix,
TYPE:
|
env_load
|
Whether to read environment variables.
TYPE:
|
kind
class-attribute
kind: str = 'idempotent_requests'
default_requires
class-attribute
default_requires: BackendScope = 'cluster'
Default requires=: a retry replays on whichever replica it lands.
name
property
name: str
Return the registration name.
idempotency
property
idempotency: Idempotency[Any]
Return the Idempotency the middleware stores through.
For code that has to reach the store itself, such as clearing a key an operator asked about. Handlers need none of it: the middleware does the storing.
from_config
classmethod
from_config(
config: IdempotentRequestsConfig,
*,
name: str = "default",
namespace: str = "http",
ttl: int | timedelta | None = None,
cache: TTLCache[Any] | None = None,
requires: BackendScope | None = None,
key: RequestKeyFunction | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
openapi: bool = True,
) -> IdempotentRequests
Build the component from a configuration that is already whole.
The one declarative door. What you pass is what runs: no environment variable is read, and the instance is not registered for live reload. The store, the key function and the skip predicate are passed here, next to the config.
| PARAMETER | DESCRIPTION |
|---|---|
config
|
The pre-built idempotent requests configuration.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
namespace
|
Namespace the stored keys sit under.
TYPE:
|
ttl
|
How long a stored response replays for, in whole seconds or as a
TYPE:
|
cache
|
The
TYPE:
|
requires
|
The smallest scope the cache backend must reach.
TYPE:
|
key
|
Build the stored key from the scope and the client key.
TYPE:
|
skip
|
Return
TYPE:
|
openapi
|
Describe the headers in the OpenAPI schema.
TYPE:
|
asgi_middleware
asgi_middleware() -> tuple[type[Any], dict[str, Any]]
Return the middleware class and the arguments to build it with.
micro.install(app) reads this from every registered component
that carries it and hands the pair to the integration, which adds
the middleware the way its framework takes one. A component
without it wires no middleware.
handled_exceptions
handled_exceptions() -> tuple[type[Exception], ...]
Return what this component answers rather than letting through.
The middleware answers what it refuses itself. These are what the block form raises inside a handler, and registering the component is the opt-in for answering those the same way.
IdempotencyMiddleware
IdempotencyMiddleware(
app: ASGIApp,
*,
idempotency: Idempotency[Any],
key_header: str = "Idempotency-Key",
replay_header: str = _DEFAULT_REPLAY_HEADER,
methods: Collection[str] = ("POST",),
key: RequestKeyFunction | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
require_key: bool = False,
fingerprint_body: bool = False,
max_body_size: int = 1024 * 1024,
max_wait: float = 10.0,
include: tuple[str, ...] = (),
exclude: tuple[str, ...] = (),
reused_status: int = status,
live: Live[_State] | None = None,
)
Replay a stored HTTP response when a request repeats its idempotency key.
A request whose method is listed in methods and which carries the
key_header runs once. A retry with the same key replays the stored
status, headers, and body without reaching the handler, and carries
the replay_header marker, Idempotent-Replayed: true by default. A
request without the key_header passes straight through, so adding
the middleware changes nothing until a client opts in.
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import IdempotencyMiddleware
from grelmicro.cache import TTLCache
from grelmicro.idempotency import Idempotency
micro = Grelmicro(uses=[...])
app = FastAPI()
micro.install(app)
app.add_middleware(
IdempotencyMiddleware, idempotency=Idempotency("http", ttl=3600)
)
Register IdempotentRequests() instead to have micro.install(app)
add it for you, along with the OpenAPI documentation.
Added by hand, it goes before or after micro.install(app). It
resolves its Cache through the grelmicro request scope, which
install keeps outside every other middleware.
A duplicate that arrives while the first execution is in flight waits
for it and replays its response. The wait folds across replicas when
a Coordination lock backend is configured, and in-process
otherwise. It is bounded by max_wait.
Every response the app returns is stored, errors included. A handler
that raises an unhandled exception stores nothing, so the framework's
500 never replays.
Without a custom key= function, a request carrying Authorization or
Cookie bypasses idempotency and runs the app. Its response cannot be
stored under a key shared across callers, and a replay cannot skip
authentication inside the app. Configure an identity-aware key= function
to make authenticated requests idempotent.
Four kinds of response are not stored, and each one lets a retry re-run
the handler: one carrying Set-Cookie, one carrying Content-Encoding,
one declaring trailers, and one whose body is over max_body_size. All
four are logged. Pass skip to add a rule of your own.
Background tasks run after the response is sent, so a replay can be served while the original request's background work is still in flight.
Registered through IdempotentRequests, it runs at the route on every
app whose integration gates its routes, with authentication or
without, so a URL no route answers is never stored or replayed. There
it reads whether the route runs checks of its own from the route's
declaration. Added by hand, it decides from include, exclude and
the request headers alone, and does not see a route's own checks.
The middleware is pure ASGI and works with any ASGI framework
(Starlette, Litestar, ...). It acts on http scopes and passes every
other scope through untouched.
Initialize the middleware with the idempotency store and policy.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the middleware chain.
TYPE:
|
idempotency
|
The
TYPE:
|
key_header
|
Request header carrying the idempotency key.
TYPE:
|
replay_header
|
Response header marking a replayed response. No standard names one, so pick what the clients already
read.
TYPE:
|
methods
|
Methods that take an idempotency key. Every other method passes through.
TYPE:
|
key
|
Build the stored key from the ASGI scope and the client key. Defaults to the authority, scheme, root path, method, route
path, query string, and client key, so two public resources
never replay each other. Requests carrying
TYPE:
|
skip
|
Predicate receiving the response. Return Mirrors
TYPE:
|
require_key
|
Answer
TYPE:
|
fingerprint_body
|
Hash the request body and store the hash with the response. A key reused with a different body then gets
TYPE:
|
max_body_size
|
Largest body held in memory, in bytes. A larger response is sent to the client and not stored. With
TYPE:
|
max_wait
|
Seconds a duplicate waits for an execution already in flight. Past it the duplicate is answered with
TYPE:
|
include
|
Paths this middleware acts on. Empty means every path. Exact match unless the pattern ends with
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever
TYPE:
|
reused_status
|
Status answering a key reused with a different payload.
TYPE:
|
live
|
The cell a registered
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If |
app
instance-attribute
app = app
StoredResponse
Bases: TypedDict
The response IdempotencyMiddleware is about to store.
Handed to skip so a handler's own rule decides whether a response
replays. headers maps lowercased names to their value, keeping the
last of a repeated name.
status
instance-attribute
status: int
headers
instance-attribute
headers: dict[str, str]
body
instance-attribute
body: bytes
ConditionalRequests
ConditionalRequests(
*,
etag_responses: bool | None = None,
require_precondition: Sequence[str] | None = None,
include: tuple[str, ...] | None = None,
exclude: tuple[str, ...] | None = None,
max_body_size: int | None = None,
openapi: bool = True,
name: str = "default",
env_prefix: str | None = None,
env_load: bool | None = None,
)
Bases: AnswersAtRoute, Reconfigurable[ConditionalRequestsConfig]
Answer conditional requests, wired by micro.install(app).
Register it and install adds ConditionalRequestsMiddleware, so
every request's preconditions are bound for check_precondition(...)
and every response carries an ETag:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.http import ConditionalRequests, ErrorResponses
micro = Grelmicro(uses=[ErrorResponses(), ConditionalRequests()])
app = FastAPI()
micro.install(app)
Every option of ConditionalRequestsMiddleware is taken here and
forwarded, so a registered component and a hand-added middleware
answer the same.
A framework that serves no HTTP, such as FastStream, ignores it.
Read more in the Conditional Requests docs.
Answer conditional requests through the registered middleware.
| PARAMETER | DESCRIPTION |
|---|---|
etag_responses
|
Add an
TYPE:
|
require_precondition
|
Methods answered
Empty by default, which leaves the decision to
TYPE:
|
include
|
Paths this middleware acts on. Empty means every path. Exact match unless the pattern ends with
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever
TYPE:
|
max_body_size
|
Largest response body held in memory to hash, in bytes. A larger one is forwarded untouched.
TYPE:
|
openapi
|
Describe
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
env_prefix
|
Override the derived prefix,
TYPE:
|
env_load
|
Whether to read environment variables.
TYPE:
|
kind
class-attribute
kind: str = 'conditional_requests'
name
property
name: str
Return the registration name.
from_config
classmethod
from_config(
config: ConditionalRequestsConfig,
*,
name: str = "default",
openapi: bool = True,
) -> ConditionalRequests
Build the component from a configuration that is already whole.
The one declarative door. What you pass is what runs: no environment variable is read, and the instance is not registered for live reload.
| PARAMETER | DESCRIPTION |
|---|---|
config
|
The pre-built conditional requests configuration.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
openapi
|
Describe the headers in the OpenAPI schema.
TYPE:
|
asgi_middleware
asgi_middleware() -> tuple[type[Any], dict[str, Any]]
Return the middleware class and the arguments to build it with.
The middleware is handed the cell rather than the values, so a live reconfigure reaches it without the stack being rebuilt, which a framework will not do once it is serving.
handled_exceptions
handled_exceptions() -> tuple[type[Exception], ...]
Return what this component answers rather than letting through.
Registering it is the opt-in: a service that asked for conditional
requests gets 412 and 428 on the wire, not a 500, whether or
not an ErrorResponses is registered. That component chooses the
format, this one decides these two are answered at all.
ConditionalRequestsMiddleware
ConditionalRequestsMiddleware(
app: ASGIApp,
*,
etag_responses: bool = True,
require_precondition: Sequence[str] = (),
include: tuple[str, ...] = (),
exclude: tuple[str, ...] = (),
max_body_size: int = 1024 * 1024,
live: Live[_State] | None = None,
)
Bind each request's preconditions and put an ETag on the response.
Four things, none of which a handler should have to write:
- Every
If-MatchandIf-None-Matchheader is parsed and bound for the request, socheck_precondition(...)reads them with no request object in the handler signature. - A
2xxresponse that carries a complete body and noETagof its own gets one, hashed from the body it just produced. - A
GETorHEADwhoseIf-None-Matchmatches that tag is answered304 Not Modifiedwith no body. - A method named in
require_preconditionthat carries neither precondition header is answered428, before it reaches a handler.
from grelmicro import Grelmicro
from grelmicro.http import ConditionalRequestsMiddleware
app.add_middleware(ConditionalRequestsMiddleware)
Register ConditionalRequests() instead to have micro.install(app)
add it for you.
Added by hand, it decides from include and exclude alone, and does
not see a route's own checks.
The 304 saves the response, not the work: the handler has already
run by the time the body is there to hash. A handler that knows its
resource's version cheaply should compare it itself and skip the load.
A response over max_body_size, or one streamed in chunks past it, is
forwarded as it comes and gets no entity tag, so a large download is
never held in memory.
The middleware is pure ASGI and works with any ASGI framework
(Starlette, Litestar, ...). It acts on http scopes and passes every
other scope through untouched.
Initialize the middleware with its entity tag policy.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the middleware chain.
TYPE:
|
etag_responses
|
Add an
TYPE:
|
require_precondition
|
Methods answered
Either header counts, so
TYPE:
|
include
|
Paths this middleware acts on. Empty means every path. Exact match unless the pattern ends with
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever
TYPE:
|
max_body_size
|
Largest response body held in memory to hash, in bytes. A larger one is forwarded untouched.
TYPE:
|
live
|
The cell a registered
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If |
app
instance-attribute
app = app
CachedResponses
CachedResponses(
*,
ttl: int | timedelta | None = None,
include: Mapping[str, int | timedelta]
| tuple[str, ...]
| None = None,
exclude: tuple[str, ...] | None = None,
vary_by_headers: tuple[str, ...] | None = None,
vary_by_query: tuple[str, ...] | _Unset | None = UNSET,
key: Callable[[Scope], str | None] | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
max_body_size: int | None = None,
cache: TTLCache[Any] | None = None,
namespace: str = "http",
lock: BackendScope | None = "process",
name: str = "default",
env_prefix: str | None = None,
env_load: bool | None = None,
)
Bases: AnswersAtRoute, Reconfigurable[CachedResponsesConfig]
Serve repeated reads from the cache, wired by micro.install(app).
Register it, mark the routes it answers for, and install adds
CachedResponsesMiddleware to the app:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.http import CachedResponses
from grelmicro.integrations.fastapi import CachedResponse
from grelmicro.providers.redis import RedisProvider
redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[Cache(redis), CachedResponses()])
app = FastAPI()
micro.install(app)
@app.get("/products", dependencies=[CachedResponse(ttl=60)])
async def list_products() -> list[Product]: ...
The bare form caches nothing until a route declares it. include= names
URLs instead, for a router whose routes you cannot touch and for a
framework that declares no cache on its routes.
It rides the registered Cache, so a response one replica computed
answers the callers of every other one. Pass a TTLCache of your own
to store them somewhere else.
Register it before ConditionalRequests(), so a hit is answered
without entering it.
Every option of CachedResponsesMiddleware is taken here and
forwarded, so a registered component and a hand-added middleware
answer the same.
A framework that serves no HTTP, such as FastStream, ignores it.
Read more in the Response Cache docs.
Answer repeated reads through the registered middleware.
| PARAMETER | DESCRIPTION |
|---|---|
ttl
|
How long a response is kept when its route names none, in whole seconds or as a
TYPE:
|
include
|
The paths cached, for a route that declares none. A tuple keeps each for
TYPE:
|
exclude
|
Paths never cached, whatever a route or
TYPE:
|
vary_by_headers
|
Request headers whose value is part of the key. A response whose
TYPE:
|
vary_by_query
|
Query parameters that are part of the key.
TYPE:
|
key
|
Builds the key from the ASGI scope, replacing the path and the vary rules. Return
TYPE:
|
skip
|
Returns whether one response is left unstored.
TYPE:
|
max_body_size
|
Largest response body stored, in bytes.
TYPE:
|
cache
|
The
TYPE:
|
namespace
|
Namespace the stored keys sit under, so two sets of rules on one app never read each other's responses. Part of every stored key, so it is not live.
TYPE:
|
lock
|
How far concurrent misses on one response share a single call of the handler.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
env_prefix
|
Override the derived prefix,
TYPE:
|
env_load
|
Whether to read environment variables.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
SettingsValidationError
|
If |
kind
class-attribute
kind: str = 'cached_responses'
name
property
name: str
Return the registration name.
from_config
classmethod
from_config(
config: CachedResponsesConfig,
*,
name: str = "default",
namespace: str = "http",
cache: TTLCache[Any] | None = None,
key: Callable[[Scope], str | None] | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
lock: BackendScope | None = "process",
) -> CachedResponses
Build the component from a configuration that is already whole.
The one declarative door. What you pass is what runs: no environment variable is read, and the instance is not registered for live reload. The store and the two callables stay here rather than in the config, because they are objects rather than values.
| PARAMETER | DESCRIPTION |
|---|---|
config
|
The pre-built cached responses configuration.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
namespace
|
Namespace the stored keys sit under.
TYPE:
|
cache
|
The
TYPE:
|
key
|
Builds the key from the ASGI scope.
TYPE:
|
skip
|
Returns whether one response is left unstored.
TYPE:
|
lock
|
How far concurrent misses on one response fold.
TYPE:
|
purge
async
purge() -> None
Delete every response this component stored.
What a write invalidates, so a handler that changed the resource drops what the cache would go on answering with until the TTL elapsed.
asgi_middleware
asgi_middleware() -> tuple[type[Any], dict[str, Any]]
Return the middleware class and the arguments to build it with.
The middleware is handed the cell rather than the values, so a live reconfigure reaches it without the stack being rebuilt, which a framework will not do once it is serving.
read_routes
read_routes(app: Any) -> None
Read what every route of the app declares, as its integration lists it.
Called by the integration after the middleware is added. The app is read again when it starts, so a route added between the two counts as well.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The application to read the rules off.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If a route declaring a cache answers a method other
than |
CachedResponsesMiddleware
CachedResponsesMiddleware(
app: ASGIApp,
*,
cache: TTLCache[Any],
ttl: int | timedelta = _DEFAULT_TTL,
include: Mapping[str, int | timedelta]
| tuple[str, ...]
| None = None,
exclude: tuple[str, ...] = (),
vary_by_headers: tuple[str, ...] = (),
vary_by_query: tuple[str, ...] | None = None,
key: Callable[[Scope], str | None] | None = None,
skip: Callable[[StoredResponse], bool] | None = None,
max_body_size: int = _DEFAULT_MAX_BODY_SIZE,
tag: str = "grelmicro:http:default",
lock: BackendScope | None = "process",
live: Live[_State] | None = None,
)
Answer a repeated read from the cache instead of the handler.
A GET or HEAD whose path a rule names is looked up first. A hit is
answered without reaching the app, carrying the Age it has spent in
the cache, and a client that already holds the entity tag is answered
304 Not Modified with no body at all.
from grelmicro.cache import TTLCache
from grelmicro.http import CachedResponsesMiddleware
app.add_middleware(
CachedResponsesMiddleware,
cache=TTLCache(),
include={"/products/*": 60},
)
Register CachedResponses() instead to have micro.install(app) add
it for you, and to declare CachedResponse(ttl=...) on a route.
A miss runs the handler once. Every other request for the same key
waits for that one and is answered from what it stored, so a cold key
never fans one computation out to every caller at once. The wait is in
the process by default. lock="host" or lock="cluster" also folds
it through the lock backend of the app's Coordination.
A request asking for a range is passed through, because what is stored is the whole resource.
A response is stored only when it is safe to hand to somebody else:
status 200, no Set-Cookie, no Content-Encoding, no
Cache-Control refusing it, and a Vary naming nothing outside
vary_by_headers. A request carrying Authorization or Cookie
never reads the cache and never fills it. Neither does one an outer
ASGI authentication middleware has already marked as authenticated.
Registered through CachedResponses, it runs at the route on every
app whose integration gates its routes, with authentication or
without, so a URL no route answers is never cached. At a route whose
gate admitted the request, the route's declaration decides. A
protected route declaring CachedResponse(shared=True) shares its
response among every caller it admits, a credential included, keyed by
the protection it declares. A request carrying a credential to an
anonymous route is answered by its handler. A route running checks of
its own is never cached by a pattern. A Cookie still keeps a request
out.
Added by hand, it decides from include, exclude and the header
rules above alone, and does not see a route's own checks.
A request's own Cache-Control is not read. This answers for the
resource rather than for one caller, so a caller that could ask for
the handler could spend it at will.
The middleware is pure ASGI and works with any ASGI framework
(Starlette, Litestar, ...). It acts on http scopes and passes every
other scope through untouched.
Initialize the middleware with the paths it answers for.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the middleware chain.
TYPE:
|
cache
|
The
TYPE:
|
ttl
|
How long a response is kept when its route names none, in whole seconds or as a
TYPE:
|
include
|
The paths cached. A tuple keeps each for
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever else says. Same matching.
TYPE:
|
vary_by_headers
|
Request headers whose value is part of the key, so one value never answers another. A response whose
TYPE:
|
vary_by_query
|
Query parameters that are part of the key.
TYPE:
|
key
|
Builds the key from the ASGI scope, replacing the path and the vary rules. Return
TYPE:
|
skip
|
Returns whether one response is left unstored.
TYPE:
|
max_body_size
|
Largest response body stored, in bytes. A larger one is streamed to the client and not kept.
TYPE:
|
tag
|
Tag every entry carries, so
TYPE:
|
lock
|
How far concurrent misses on one response share a single call of the handler.
TYPE:
|
live
|
The cell a registered
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If a set of path patterns was given as a string. |
app
instance-attribute
app = app
RateLimitedRequests
RateLimitedRequests(
*limiters: RateLimiter,
flood: RateLimiter | None = None,
trusted: TrustedProxies | None = None,
key: Callable[[Scope], str | None] | None = None,
cost: int | None = None,
max_wait: float | None = None,
include: tuple[str, ...] | None = None,
exclude: tuple[str, ...] | None = None,
legacy_headers: bool | None = None,
openapi: bool = True,
name: str = "default",
env_prefix: str | None = None,
env_load: bool | None = None,
)
Bases: Reconfigurable[RateLimitedRequestsConfig]
Turn a caller away at the edge, wired by micro.install(app).
Register it and install adds RateLimitMiddleware to the app:
from grelmicro import Grelmicro
from grelmicro.http import ErrorResponses, RateLimitedRequests
from grelmicro.resilience import RateLimiter
from grelmicro.security import TrustedProxies
micro = Grelmicro(
uses=[
ErrorResponses(),
RateLimitedRequests(
RateLimiter.sliding_window("burst", limit=100, window=60),
RateLimiter.sliding_window("daily", limit=10000, window=86400),
trusted=TrustedProxies(["10.0.0.0/8"]),
),
]
)
A request spends one token of every limiter listed, so a burst limit stands beside a daily one and the response states both.
Every option of RateLimitMiddleware is taken here and forwarded, so
a registered component and a hand-added middleware answer the same.
A framework that serves no HTTP, such as FastStream, ignores it.
Read more in the Rate Limit docs.
Meter every request through the registered middleware.
| PARAMETER | DESCRIPTION |
|---|---|
*limiters
|
The limiters every request spends, in the order given.
TYPE:
|
flood
|
A limiter every request spends one token of before routing, a URL no route answers included. Only
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed, for resolving the caller.
TYPE:
|
key
|
Builds the bucket key from the ASGI scope, replacing the resolved caller. Return
TYPE:
|
cost
|
Tokens one request spends of every limiter.
TYPE:
|
max_wait
|
Seconds a throttled request waits before it is refused.
TYPE:
|
include
|
Paths metered. Empty (the default) means every path. Same matching as every other middleware.
TYPE:
|
exclude
|
Paths never metered, whatever
TYPE:
|
legacy_headers
|
Also send the superseded
TYPE:
|
openapi
|
Describe the
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
env_prefix
|
Override the derived prefix,
TYPE:
|
env_load
|
Whether to read environment variables.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If no limiter is given, or neither |
ValueError
|
If a limiter is named something a |
kind
class-attribute
kind: str = 'rate_limited_requests'
name
property
name: str
Return the registration name.
flood
property
flood: RateLimiter | None
Return the limiter every request spends before routing, if any.
from_config
classmethod
from_config(
config: RateLimitedRequestsConfig,
*limiters: RateLimiter,
name: str = "default",
flood: RateLimiter | None = None,
trusted: TrustedProxies | None = None,
key: Callable[[Scope], str | None] | None = None,
openapi: bool = True,
) -> RateLimitedRequests
Build the component from a configuration that is already whole.
The one declarative door. What you pass is what runs: no
environment variable is read, and the instance is not registered
for live reload. The limiters stay here rather than in the
config, because they are objects with buckets of their own, each
tuned under its own GREL_RATELIMITER_ address.
| PARAMETER | DESCRIPTION |
|---|---|
config
|
The pre-built rate limited requests configuration.
TYPE:
|
*limiters
|
The limiters every request spends, in the order given.
TYPE:
|
name
|
Registration name, for a second set of rules on one app.
TYPE:
|
flood
|
A limiter every request spends one token of before routing.
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed.
TYPE:
|
key
|
Builds the bucket key from the ASGI scope.
TYPE:
|
openapi
|
Describe the
TYPE:
|
asgi_middleware
asgi_middleware() -> tuple[type[Any], dict[str, Any]]
Return the middleware class and the arguments to build it with.
handled_exceptions
handled_exceptions() -> tuple[type[Exception], ...]
Return what this component answers rather than letting through.
The middleware answers what it refuses itself. This is what the limiter raises inside a handler, and registering the component is the opt-in for answering those the same way.
RateLimitMiddleware
RateLimitMiddleware(
app: ASGIApp,
*,
limiters: Sequence[RateLimiter],
flood: RateLimiter | None = None,
trusted: TrustedProxies | None = None,
key: Callable[[Scope], str | None] | None = None,
cost: int = 1,
max_wait: float = 0.0,
include: tuple[str, ...] = (),
exclude: tuple[str, ...] = (),
legacy_headers: bool = False,
live: Live[_State] | None = None,
)
Turn a caller away at the edge, and say what it has left.
Every request spends one token of every limiter it is given, keyed by
the caller. A request the first of them refuses is answered 429
without reaching the app, through the same AdmissionError path a
handler's own refusal takes, so one exception handler still covers
every way a caller is turned away.
from grelmicro.http import RateLimitMiddleware
from grelmicro.resilience import RateLimiter
from grelmicro.security import TrustedProxies
app.add_middleware(
RateLimitMiddleware,
limiters=[RateLimiter.sliding_window("api", limit=100, window=60)],
trusted=TrustedProxies(["10.0.0.0/8"]),
)
Register RateLimitedRequests(...) instead to have
micro.install(app) add it for you.
Allowed or refused, the response carries what the caller has left, in
the two fields of the RateLimit header specification: RateLimit with
r= remaining and t= seconds to reset, and RateLimit-Policy with
q= quota and w= window for every limiter that has a window. A
refusal adds Retry-After.
The caller is the resolved client address, which is the socket peer
unless a trusted proxy vouched for another one. A limiter keyed on the
peer would meter the ingress rather than the caller, and one keyed on
a raw X-Forwarded-For would meter whatever the caller wrote there.
The middleware is pure ASGI and works with any ASGI framework
(Starlette, Litestar, ...). It acts on http scopes and passes every
other scope through untouched.
Initialize the middleware with the limiters it spends.
| PARAMETER | DESCRIPTION |
|---|---|
app
|
The next ASGI application in the middleware chain.
TYPE:
|
limiters
|
The limiters every request spends. A request passes all of them or is refused by the first that says no.
TYPE:
|
flood
|
A limiter every request spends one token of before routing, a URL no route answers included. Behind
TYPE:
|
trusted
|
The proxies whose forwarded entries may be believed, for resolving the caller. Give this or
TYPE:
|
key
|
Builds the bucket key from the ASGI scope, replacing the resolved caller. Return
TYPE:
|
cost
|
Tokens one request spends of every limiter.
TYPE:
|
max_wait
|
Seconds a throttled request waits for tokens before it is refused.
TYPE:
|
include
|
Paths this middleware meters. Empty (the default) means every path. Exact match unless the pattern ends with
TYPE:
|
exclude
|
Paths this middleware leaves alone, whatever
TYPE:
|
legacy_headers
|
Also send
TYPE:
|
live
|
The cell a registered
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If no limiter is given, or the caller cannot be
resolved because neither |
ValueError
|
If a limiter is named something a |
app
instance-attribute
app = app
before_routing
before_routing(app: ASGIApp) -> ASGIApp
Return app behind the flood limit, for where authentication hands a request on.
The limiters run at the route reached, and the flood limit runs
here, for every request that crossed authentication. Without a
flood limit, app is returned as it is.
PreconditionError
Bases: GrelmicroError
Base error for a conditional request that could not proceed.
Catch it to handle both halves of optimistic concurrency with one
except: a precondition that failed, and one the service required and
the client did not send.
PreconditionFailedError
PreconditionFailedError(message: str | None = None)
Bases: PreconditionError
Raised when the client's precondition does not match the resource.
The If-Match header carried an entity tag that is not the one the
resource has now, so another writer landed in between and the write
would have overwritten their change. Or If-Match named a resource
that does not exist, or If-None-Match still matches the resource.
Answers 412.
Raise it yourself when the conditional write itself comes back empty, which is the case a check before the write cannot catch:
result = await session.execute(
update(Cart)
.where(Cart.id == cart_id, Cart.version == expected)
.values(items=items, version=expected + 1)
)
if result.rowcount == 0:
raise PreconditionFailedError
Initialize the error.
Without message, it says the entity tag the client holds is
stale, which is the case a conditional write that came back empty
reports.
PreconditionRequiredError
PreconditionRequiredError()
Bases: PreconditionError
Raised when a write that must be conditional carried no precondition.
The service requires If-Match on this write, so a client that sends
none is told to fetch the resource and come back with its entity tag,
rather than being allowed to overwrite whatever is there. Answers
428.
Initialize the error.
RenderedError
dataclass
RenderedError(
status: int,
media_type: str,
headers: dict[str, str],
body: bytes,
)
One rejection, ready to send, in whichever format was chosen.
What every integration needs and nothing more, so a framework wires the same three values whatever standard the body follows.
status
instance-attribute
status: int
HTTP status code of the response.
media_type
instance-attribute
media_type: str
Content type the body is written with.
headers
instance-attribute
headers: dict[str, str]
Headers beside the content type, Retry-After included.
body
instance-attribute
body: bytes
Serialized body.
ProblemDetail
Bases: BaseModel
An error response body, as defined by RFC 9457.
The five standard members are declared, and anything else passed is kept as an extension member, which is where the useful part of a problem detail lives:
from grelmicro.http import ProblemDetail
problem = ProblemDetail(
type="https://example.com/problems/insufficient-funds",
title="Insufficient funds",
status=409,
balance=30,
)
Declare it as a response model to publish the shape in OpenAPI:
@app.post("/charge", responses={429: {"model": ProblemDetail}})
async def charge() -> Charge: ...
Read more in the Error Responses docs.
type
class-attribute
instance-attribute
type: str = 'about:blank'
URI identifying the problem kind. Stable, so a client can branch on it without reading the prose.
title
instance-attribute
title: str
Short summary of the problem kind, the same for every occurrence.
status
instance-attribute
status: int
HTTP status code of the response.
detail
class-attribute
instance-attribute
detail: str | None = None
Explanation of this occurrence, safe to show a client.
instance
class-attribute
instance-attribute
instance: str | None = None
URI reference identifying the occurrence, the request path here.
TMFError
Bases: BaseModel
An error response body, as defined by TMF630.
Declare it as a response model to publish the shape in OpenAPI:
@app.post("/charge", responses={429: {"model": TMFError}})
async def charge() -> Charge: ...
Read more in the Error Responses docs.
model_config
class-attribute
instance-attribute
model_config = ConfigDict(
extra="allow", populate_by_name=True
)
code
instance-attribute
code: str
Application code for the error, namespaced by the prefix the component was built with.
reason
instance-attribute
reason: str
Short summary of the error kind, safe to show a client user.
message
class-attribute
instance-attribute
message: str | None = None
Explanation of this occurrence and what to do about it.
reference_error
class-attribute
instance-attribute
reference_error: str | None = Field(
default=None, alias="referenceError"
)
URI of the documentation describing this error kind.
type_
class-attribute
instance-attribute
type_: str = Field(default='Error', alias='@type')
Class type of the representation, Error for this one.
check_precondition
check_precondition(
version: object = _UNSET,
*,
etag: str | None = None,
require: bool = True,
) -> None
Refuse a write whose precondition no longer holds.
Load the resource, hand over what identifies its version, and write only if this returns:
@app.put("/carts/{cart_id}")
async def replace(cart_id: str, body: CartIn) -> Cart:
cart = await repo.load(cart_id)
check_precondition(cart.version)
return await repo.save(cart.apply(body))
The entity tag is built for you, the same way etag_of builds one, so
a version column is all a handler has to hand over. Pass the whole
resource where there is no version column, and etag= where the tag
is already a tag.
This is a check, not a lock. Between it and the write, another request can land, so the write itself has to be conditional too. Read Conditional Requests for the three ways to do that, and which one to reach for.
The request's headers come from ConditionalRequests(), so nothing is
threaded through the handler signature and the same line works on
every framework.
| PARAMETER | DESCRIPTION |
|---|---|
version
|
What identifies the version the resource carries now. Whatever
TYPE:
|
etag
|
An entity tag that is already one, quotes included. For a tag read from a store or an upstream service rather than built here. Pass this or the version, never both.
TYPE:
|
require
|
Answer
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
PreconditionFailedError
|
If the resource is not in the state the
request's |
PreconditionRequiredError
|
If |
OutOfContextError
|
If |
TypeError
|
If neither the version nor |
etag_of
etag_of(value: object, *, weak: bool = False) -> str
Build an entity tag for a resource.
check_freshness and check_precondition build one for you, so a
handler reaches for this only when it needs the tag itself: to set one
on a response the middleware will not tag, to hand one to
check_precondition(etag=...), to store one beside a cached object, or
to compare tags in a client of another service.
Two ways in, because a service has one of two things at hand:
etag_of(cart.version) # a version column: "7"
etag_of(cart) # a pydantic model: "b1946ac9..."
A version token is used as it stands, since it already identifies the version and hashing it would only make it longer. A representation is serialized and hashed with SHA-256.
| Value | Tag |
|---|---|
int, str, UUID |
the value, quoted |
datetime |
its ISO 8601 form, quoted |
bytes |
SHA-256 of the bytes |
| a pydantic model, dict, list | SHA-256 of the canonical JSON |
weak=True marks the tag weak, which says equivalent rather than byte
for byte. A weak tag still answers 304 on a read and never satisfies
an If-Match, which takes strong comparison.
Prefer a version token. A hash of the representation changes whenever
the serialization does, so adding a field to the model changes every
entity tag your service has ever issued, and every client holding one
gets 412 until it fetches again.
The serialization is canonical: sorted keys, no spaces, and never the
JSON library that happens to be installed, so every replica produces
the same tag for the same value. A pydantic model goes through
model_dump(mode="json") first.
Read more in the Conditional Requests docs.
| PARAMETER | DESCRIPTION |
|---|---|
value
|
What identifies this version of the resource. A version token (
TYPE:
|
weak
|
Mark the tag weak. A weak tag answers
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
TypeError
|
If the value is a |
ValueError
|
If a version token holds a quote or a control
character, which an entity tag cannot carry. An entity tag that
is already one goes to |
route_template
route_template(
scope: MutableMapping[str, Any],
) -> str | None
Return the template of the route the request reached.
It is the route the access log writes as http.route, such as
/api/meals/v1/recipes/{recipe_id} for /api/meals/v1/recipes/7. It
carries every prefix the route was included under, and it starts with
the request's root_path. On Starlette it joins nested mounts. On
FastAPI, a route of a FastAPI app mounted with app.mount reads as
its full template, such as /mm/r/{x}. A Starlette app or Mount
mounted 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
template of the mount that took it, such as /admin/{path}. A request
answered before routing, such as a refused token or a cached response,
returns the route its path reaches. On Starlette, a slash redirect
returns None, where the access log writes the route it redirects to.
Before routing it matches the path again, so it costs a route lookup.
| PARAMETER | DESCRIPTION |
|---|---|
scope
|
The ASGI scope of the request.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
AppNotInstalledError
|
If |
send_error
async
send_error(send: Send, rendered: RenderedError) -> None
Write a rendered error as a complete ASGI response.
For a middleware that refuses a request itself, before the app runs and
before any exception handler can see it. It takes what the app's
ErrorResponses produced rather than a body of its own, so a middleware
cannot answer in a format the rest of the app does not speak.
class Gate:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
try:
await self.app(scope, receive, send)
except Exception as exc:
errors = Grelmicro.current().error_responses
rendered = errors.render(exc, instance=scope["path"])
if rendered is None:
# Not a rejection grelmicro renders, so it stays the
# framework's to answer.
raise
await send_error(send, rendered)
| PARAMETER | DESCRIPTION |
|---|---|
send
|
The ASGI
TYPE:
|
rendered
|
What to write, from
TYPE:
|
merge_headers
merge_headers(
rendered: RenderedError,
theirs: Mapping[str, str] | None,
) -> dict[str, str]
Merge the app's headers over ours, keeping the safety ones ours.
A header the app set on an exception is part of the answer,
WWW-Authenticate on a 401 above all, so it outranks what the
component produced. Retry-After is data too, and an app that knows
better than the limiter may say so.
The two safety headers are not data. grelmicro adds them because of the
body it renders: no-store because a refusal is about one client at one
moment, and nosniff because that body reflects the request path back.
Letting them be overridden would take away a guarantee the docs make.
Names are lowercased first. They are case insensitive, and ours are
lowercase, so a canonical-cased Cache-Control would otherwise survive
beside our cache-control and emit two contradictory directives.
| PARAMETER | DESCRIPTION |
|---|---|
rendered
|
What the component produced, safety headers included.
TYPE:
|
theirs
|
Headers the exception carried, keyed however the app wrote them.
TYPE:
|