First-class policy#
HttpPolicy composes request-wide decisions at application construction. The owning modules expose explicit policy values and the stores needed by distributed variants.
wreath.policy#
First-class HTTP policy compiled by Wreath and its native server.
Policy is configuration, not middleware. None of the component classes expose the public middleware protocol, none can be registered on a middleware tape, and their ordering is fixed by HttpPolicy rather than by priorities.
HttpPolicy#
The complete, fixed-order HTTP policy for one application.
Standard policy components are immutable after construction. At startup, Wreath freezes every natively representable combination into a descriptor consumed by the native policy engine. The readable executor below is the independent reference used by pure builds, differential tests, and policy combinations awaiting a native opcode; it is not a middleware tape.
HttpPolicy(*, proxy: ProxyPolicy | None = None, trusted_host: TrustedHostPolicy | None = None, ai_scraping: AIScrapingPolicy | None = None, rate_limit: RateLimitPolicy | None = None, principal_rate_limit: RateLimitPolicy | TieredRateLimitPolicy | None = None, request_decompression: RequestDecompressionPolicy | None = None, request_id: RequestIdPolicy | None = None, server_timing: ServerTimingPolicy | None = None, signed_routes: SignedRoutePolicy | None = None, traffic: TrafficPolicy | None = None, cors: CorsPolicy | None = None, csrf: CsrfPolicy | None = None, security_headers: SecurityHeadersPolicy | None = None, websocket_origin: WebSocketOriginPolicy | None = None, session: SessionPolicy | None = None, idempotency: IdempotencyPolicy | None = None, cache_control: CachePolicy | None = None, compression: CompressionPolicy | None = None, concurrency: ConcurrencyPolicy | None = None, deadline: DeadlinePolicy | None = None, maintenance: MaintenancePolicy | None = None) -> Nonecomponents (property)#
components: tuple[Any, ...]Configured components in their canonical ingress order.
counter_sources (property)#
counter_sources: tuple[Any, ...]Policy components that may contribute to the canonical metrics walk.
merged#
merged(other: HttpPolicy) -> HttpPolicyReturn one policy containing two disjoint feature declarations.
schema_owners (property)#
schema_owners: tuple[Any, ...]Persistent stores delegated to by configured policy.
wreath.middleware.base#
Middleware contracts and the startup compilation that turns them into a tape.
Wreath accepts middleware in two forms. The hook form is an object carrying before or before_sync and/or after, after_sync, or after_inplace attributes; MiddlewareHooks is the canonical container, but any object with those attributes qualifies; standard HTTP policy is configured separately through HttpPolicy. The legacy form is the familiar async def (request, call_next) callable. An object carrying both is rejected with TypeError at compile time, because which form it is cannot be guessed.
Hook middleware is compiled once, at startup, into a MiddlewareTape: a flat tuple of instructions with precomputed jump targets, walked by one loop instead of a stack of nested closures. A contiguous run of before_sync hooks is fused into a single instruction that runs them in one synchronous pass; after_sync instructions likewise run without a coroutine or await, while after_inplace additionally promises not to replace the response. One legacy middleware anywhere in a route's chain opts that whole route out of the tape: the chain falls back to nested closures, with the hook middleware adapted into that shape.
Setting global_scope = True on a middleware object makes Wreath.add_middleware route it to Wreath.add_global_middleware instead of a route's chain. Global hooks run around routing itself, so they also cover route misses, static files, and authentication and authorization failures, and their after hooks are handed error responses.
DescribesItself#
A middleware that declares what it adds to the operations it covers.
DescribesItself(*args, **kwargs)describe#
describe() -> MiddlewareContractHeaderSpec#
One header a middleware reads or emits.
Args:
name— The header name in its canonical casing, for the document.description— What it is for, rendered into the operation.required— True when the middleware refuses a request without it.const— The exact value, when configuration fixes it.RateLimit-Policy- is
60;w=60for a middleware built withlimit=60, window=60.0, - and putting that in the document is what lets a test assert the
- document and the runtime agree rather than merely resemble.
HeaderSpec(name: str, description: str = '', required: bool = False, const: str | None = None) -> NoneLegacyMiddleware#
The nested (request, call_next) middleware form.
Await call_next(request) to continue down the chain and return what it gives you, or return a response without calling it to short-circuit. This form is the one to reach for when the endpoint call itself must be wrapped -- a try/finally, a context manager, a timeout. It costs the tape: a single legacy middleware makes the whole route compile to nested closures. Everything else is better expressed as MiddlewareHooks.
An object that defines __call__ and any of before, before_sync, after, after_sync, or after_inplace is rejected with TypeError when the route compiles.
LegacyMiddleware(*args, **kwargs)MiddlewareContract#
What a middleware adds to every operation it covers.
Returned by an optional describe(). The OpenAPI generator collects these by asking every middleware on a route's tape, the same way Wreath.schema_components asks for component() -- a hand-kept list would be one more place to forget a new middleware, and forgetting is the defect the mechanism exists to remove.
A contract describes what the middleware was configured to do, not what its class can do in general, so the document and the runtime cannot disagree about a limit or a policy string.
Args:
request_headers— Headers the middleware reads, as header parameters.response_headers—(status, header)pairs. A status ofNonemeans- the operation's own success status, so
ETagcan be declared - without knowing whether the route answers 200 or 201.
responses—(status, ResponseSpec | model)pairs for the statuses this- middleware can answer on its own, in
RouteDefinition.responses - shape so
ResponseSpecis reused rather than duplicated. methods— Restrict the contract to these uppercased methods;None- covers every method the route serves. An
Idempotency-Keybelongs - on the unsafe methods and nowhere else.
behaviours— Names fromBEHAVIOURSthat a generated client may act on.
MiddlewareContract(request_headers: tuple[HeaderSpec, ...] = (), response_headers: tuple[tuple[int | None, HeaderSpec], ...] = (), responses: tuple[tuple[int, Any], ...] = (), methods: frozenset[str] | None = None, behaviours: frozenset[str] = frozenset()) -> NoneMiddlewareHooks#
A middleware expressed as hooks, so it can be fused into a linear tape.
A before hook returns None to continue to the next hook, or a response value to short-circuit: the endpoint and every hook below are skipped. An after hook receives the response -- from the endpoint, from a short-circuit, or from the error handler -- and returns the response to carry on with, which is normally the one it was handed.
before_sync is the synchronous form of before. It is a plain function, not a coroutine function, and a contiguous run of before_sync hooks is compiled into one instruction that calls them in a single pass, with no per-hook coroutine and no await. Set before or before_sync, never both; when both are set before_sync wins and before is silently unreachable. after_sync is the equivalent synchronous form of after; set one or the other, and after_sync wins when both exist. after_inplace is for a hook that only mutates the response. It returns nothing and takes precedence over both transforming forms.
When after runs. An after hook runs only when its own before completed, or when the middleware has no before at all -- but it may run for a request that never reached the endpoint, and it may be handed an error response. Returning a response from before is a completed before, so that middleware keeps its after. Raising is not, so it does not. In a route-scoped tape a before that raises propagates out of the tape and no after hook runs at all; registered globally, the hooks whose before already completed still unwind and see the error response.
Args:
before— Async hook run on the way in; returns None or a response.after— Async hook run on the way out; returns the response to continue with.applies_to— Predicate overMiddlewareRoutedeciding route membership.before_sync— The synchronous, fusible form ofbefore.after_sync— The synchronous, non-awaiting form ofafter.after_inplace— Synchronous response mutation with no replacement value.contract— What this middleware adds to the OpenAPI operations it covers.Nonedeclares nothing, which is what an unannotated middleware- has always done.
MiddlewareHooks(before: BeforeHook | None = None, after: AfterHook | None = None, applies_to: RoutePredicate | None = None, before_sync: SyncBeforeHook | None = None, after_sync: SyncAfterHook | None = None, after_inplace: InPlaceAfterHook | None = None, contract: MiddlewareContract | None = None) -> Nonedescribe#
describe() -> MiddlewareContract | NoneThe declared contract, or None when this middleware declares nothing.
MiddlewareRoute#
The static route facts an applies_to predicate is given.
One instance is built per route and method during startup compilation and passed to every candidate middleware's applies_to. Nothing here varies per request, and that is the point: the decision is made once, and the middleware is either present in that route's compiled chain or absent from it, with no per-request predicate call.
Args:
path— The registered route template, parameter placeholders intact.method— The HTTP method, uppercased at registration.endpoint— The handler function as registered, before argument binding.authenticated— True when the route declares an authentication requirement.
MiddlewareRoute(path: str, method: str, endpoint: CallNext, authenticated: bool = False) -> NoneMiddlewareTape#
One immutable, linear middleware program compiled for a single route.
Built by compile_middleware when every middleware on a route is in the hook form; not constructed directly, and the instruction type it takes is private. It is the compiled artifact you inspect, not one you assemble.
Calling the tape runs one request through it. Instructions are laid out in execution order -- the before-region, the endpoint, then the after-region in reverse registration order -- and each before instruction carries the position to jump to when it short-circuits, so a response returned from a before lands directly on the first after that must still run. Every branch the loop takes was decided at compile time; the dispatch is on an integer opcode, not a chain of isinstance tests.
The tape guarantees the response it returns came from an endpoint or from a short-circuiting before, and that every after whose own before completed was applied to it in reverse order. It does not catch exceptions: a hook or endpoint that raises propagates out of the tape, and no after runs for it. That is deliberate -- the application's exception handlers turn it into a response, and global middleware still unwinds around the result.
operations holds the opcode name of each instruction in order -- one of fused_before, before, endpoint, after, after_sync, after_inplace -- which is how a test or a complexity probe asserts what the compiler produced.
MiddlewareTape(instructions: tuple[_Instruction, ...]) -> NonePipelineHooks#
Global hooks placed at the named boundaries of the request pipeline.
global_scope is always True, so Wreath.add_middleware registers these globally: before and the selected egress hook bracket every HTTP request, including route misses, static files, and error responses. The four stage hooks in between fire only when the request reaches that boundary, which is what makes them cheap -- a stage nobody registered is not dispatched at all.
Every hook returns None to continue or a response to terminate the request there. A stage hook that raises is converted to an error response rather than propagating.
after runs only when this object's before completed, or when it has no before; a before that returns a response counts as completed, a before that raises does not. A terminating stage hook does not change that: after still runs, and is handed the stage's response. So after sees requests that never reached a handler, and it sees error responses.
Args:
before— Ingress, before routing. Every HTTP request passes through it.miss— No route matched, before the static-file and preflight fallbacks.pre_auth— A protected route matched and the caller is not yet identified.identity— The backend has run andrequest.identityis set.action— Authorization passed; the handler is about to be invoked.after— Egress, wrapping every response this middleware was entered for.after_sync— Synchronous egress; takes precedence overafter.after_inplace— In-place egress; takes precedence over both other forms.
PipelineHooks(before: BeforeHook | None = None, miss: BeforeHook | None = None, pre_auth: BeforeHook | None = None, identity: BeforeHook | None = None, action: BeforeHook | None = None, after: AfterHook | None = None, after_sync: SyncAfterHook | None = None, after_inplace: InPlaceAfterHook | None = None) -> Noneglobal_scope (property)#
global_scope: boolAlways True. Wreath.add_middleware registers this as global middleware.
compile_middleware#
compile_middleware(endpoint: CallNext, middleware: Iterable[Middleware], *, route: MiddlewareRoute | None = None) -> CallNextCompile applicable middleware once, using a tape when every item is fusible.
Called at startup, once per route and method. Each middleware's applies_to predicate is evaluated here and never again -- a middleware that does not apply is simply not part of what this returns, so it costs the request nothing. A middleware with no applies_to, or a call with no route, always applies.
When every applicable middleware is in the hook form the result is a MiddlewareTape. One legacy (request, call_next) middleware among them drops the whole chain to nested closures, with the hook middleware wrapped into the legacy shape; the observable behaviour is the same, the flat dispatch is not. With nothing applicable, endpoint is returned unchanged, so an unused registration adds no frame at all.
Args:
endpoint— The route handler the compiled chain terminates in.middleware— Registered middleware, outermost first.route— Static route facts forapplies_to. None applies everything.
Returns:
- An awaitable taking the request and returning the response.
Raises:
TypeError— A middleware defines both__call__and hook attributes.
wreath.policy.admission#
First-class fail-fast concurrency admission policy.
AdmissionStats#
Current permits and cumulative refusals for one policy.
AdmissionStats(limit: int, active: int, refused: int) -> NoneConcurrencyPolicy#
Bound simultaneously executing HTTP handlers and fail fast at capacity.
This differs from rate limiting: a token bucket constrains arrivals over time, while this policy constrains work that is active now. The permit is held while the handler runs and released before response emission. A streaming response therefore protects handler construction, not the later lifetime of its producer; streams need their own domain-specific bound.
The gate is a request-owned native atomic counter. There is deliberately no hidden queue: an unbounded wait merely moves overload into suspended tasks, while a bounded queue needs a latency/deadline policy of its own.
Args:
limit— Maximum handlers executing concurrently in this process.detail— Problem detail returned with a 503 refusal.retry_after— Optional whole seconds advertised throughRetry-After.
ConcurrencyPolicy(limit: int, *, detail: str = 'Request concurrency limit reached', retry_after: int | None = None) -> Nonedescribe#
describe()The 503 emitted when every handler permit is in use.
refusal#
refusal() -> ProblemResponseBuild this policy's ordinary overload problem response.
release#
release() -> NoneRelease a permit acquired by try_acquire.
stats#
stats() -> AdmissionStatsReturn the current permit count and cumulative refusals.
try_acquire#
try_acquire() -> boolAcquire a permit for non-HTTP work without creating a wait queue.
wreath.policy.cache#
First-class response cache-control policy.
CachePolicy#
Set default browser and CDN cache policy on responses missing one.
Global policy, so it covers route misses, static files, and error responses as well as routed ones. It never overrides: a response that already has a Cache-Control header, from the handler or from a static mount, is returned untouched. This is the floor for everything that did not state a policy, not a way to impose one.
policy is consulted first and may return None to decline, in which case default applies. With no policy and no default, nothing is added.
A policy that resolves to a public directive on an authenticated response or one carrying Set-Cookie is downgraded to private, no-store. Either makes the representation caller-specific, so a shared cache must not hand it to the next caller through the same URL.
Args:
default— Applied whenpolicyis absent or declines. None adds nothing.policy— Called with the request and response to choose per response.cdn_default— Applied as RFC 9213CDN-Cache-Controlwhencdn_policy- is absent or declines.
cdn_policy— Called with the request and response to choose a targeted- CDN policy per response.
CachePolicy(default: CacheControl | None = None, policy: CacheSelector | None = None, *, cdn_default: CacheControl | None = None, cdn_policy: CacheSelector | None = None) -> Noneafter#
async after(request: Request, response)Append the selected Cache-Control header, or return the response as is.
describe#
describe()The Cache-Control floor, with its value when configuration fixes it.
A policy chooses per response, so the value is only a constant when default alone is in play. Documenting a const that a policy can override would be a claim this policy cannot keep.
wreath.policy.compression#
First-class response compression on Wreath gzip and CPython zstd.
CompressionPolicy#
Compress eligible responses with DCZ, fragment gzip, gzip, or zstd.
Global policy, so static files and error responses are compressed on the same terms as routed ones. A client that accepts neither coding — by naming both with q=0, or by offering neither zstd, gzip, nor a usable * — gets the response uncompressed.
The ordinary codings are not offered on equal terms, deliberately. zstd is served only to a client that named it; gzip is served to a client that named it or that sent a bare *. RFC 9110 would allow reading * as consent to zstd, but a request carrying * and no explicit list is far more likely to come from an old client than a new one, and a missing zstd decoder surfaces as a corrupt body rather than as an error anyone can attribute. When a client accepts both, the higher q wins and a tie goes to zstd, which decodes faster and smaller at every level offered here. A bare wildcard therefore selects gzip, never zstd. For an eligible HTTPS response whose exact registered dictionary appears in Available-Dictionary, DCZ is preferred when its client quality is at least as high as every ordinary coding. If that exact match is unavailable, an equal-quality client that advertised DCZ falls through to gzip: an exact prepared fragment is attempted first and the format-aware gzip encoder is the final gzip fallback. Explicitly higher client quality values are always honoured.
zstd is available when this Python 3.14 build includes the optional _zstd extension. Construction refuses immediately when it does not, naming the required interpreter capability; brotli is not installed as a silent third-party fallback.
A response is compressed only when all of these hold. The request method is not HEAD and the status is not 204, 304, or 206. The response carries neither Content-Encoding -- an already-encoded body is never re-encoded -- nor Content-Range. Its Cache-Control does not say no-transform. Its Content-Type is compressible, meaning text/*, application/json, application/problem+json, application/javascript, application/xml, image/svg+xml, or any application/*+json or application/*+xml; a response with no Content-Type at all is left alone. And any ETag it carries is a well-formed quoted tag, strong or weak, since the encoded body needs a distinct one.
An in-memory response is compressed only when its body is at least minimum_size bytes, and its Content-Length is rewritten. A streaming response loses its Content-Length; zstd emits incrementally, while gzip holds the member until finish so its format-aware parser sees the complete body. minimum_size cannot apply because the length is not known when the decision is made. A FileResponse is never compressed.
A compressed response gains Content-Encoding naming the coding used, gains Accept-Encoding in its Vary, and has its ETag suffixed with --gzip or --zstd inside the quotes. The suffix is per-coding for the same reason it exists at all: a shared cache keys on Vary: Accept-Encoding, and two differently-encoded bodies under one tag is a cache handing a zstd body to a gzip-only client.
Args:
minimum_size— Smallest in-memory body to compress, in bytes.gzip_level— Wreath gzip compression level from 0 to 9.zstd_level— zstd compression level, fromZSTD_MIN_LEVELtoZSTD_MAX_LEVEL; 3 by default, the level whose speed is comparable- to gzip's default while compressing appreciably better. There is no
- zstd store mode — levels below 1 are libzstd's fast modes.
compress_streaming— Compress streaming response iterables.compress_authenticated— Opt identified callers into compression despite- the response-length side channel. False by default.
Raises:
RuntimeError— this CPython build omitted the optional_zstdmodule.ValueError—minimum_sizeis negative,gzip_levelis outside 0-9, orzstd_levelis outside libzstd's range.
CompressionPolicy(*, minimum_size: int = 1024, gzip_level: int = 5, zstd_level: int = 3, compress_streaming: bool = True, compress_authenticated: bool = False) -> Noneafter#
async after(request: Request, response)Compress the response when every eligibility condition holds.
Returns the response object either way; an in-memory body is replaced in place, and a streaming body is wrapped in a compressing iterator that does its work as the response is sent.
describe#
describe()What negotiation this policy takes part in.
No const on Content-Encoding: which codec is chosen depends on the request's Accept-Encoding and on the body, so a fixed value would be a guess. The header's presence is the contract; its value is not.
wreath.policy.cors#
First-class cross-origin HTTP policy.
Preflight OPTIONS requests short-circuit from the _ingress stage; simple requests get their response headers appended by the _egress stage. All allow-list computation happens once at construction, so the per-request work is a header lookup and, for hits, a few list appends:
app.configure_http_policy(HttpPolicy(
cors=CorsPolicy(allow_origins=["https://app.example"]),
))CorsPolicy#
Answer CORS preflights and add CORS headers to cross-origin responses.
Global policy, so it covers routed responses, route misses, static files, and error responses alike. Every allow-list is computed once in the constructor; per request the work is a header lookup and, on a hit, a few list appends.
A preflight -- OPTIONS carrying both Origin and Access-Control-Request-Method -- is answered from _ingress and never reaches a route. An OPTIONS request missing either header is not a preflight and falls through to the route as normal. Because preflights target paths whose routes rarely declare an OPTIONS method, this class also exposes _preflight, which the application consults for unmatched OPTIONS requests. Only one global CORS preflight handler may be registered per application.
A simple cross-origin request from an origin that is not allowed is passed through untouched apart from Vary: Origin. Wreath does not refuse it; the absent Access-Control-Allow-Origin is what makes the browser withhold the response from the caller's JavaScript. Only preflights are refused outright.
Vary: Origin is added whenever the answer depends on the request's origin, including when the origin is rejected, so a shared cache cannot replay one origin's answer to another. It is omitted only when every origin is allowed without credentials, where the answer is the same for all of them.
Args:
allow_origins— Permitted origins, or*for any. Case-insensitive on scheme and host.allow_methods— Methods a preflight may ask for. Defaults to GET, HEAD, POST, OPTIONS.allow_headers— Request headers a preflight may ask for. Empty sends no header.allow_credentials— Permit credentialed requests. Refused together with*.expose_headers— Response headers the caller's JavaScript may read.max_age— Seconds a browser may cache a preflight result.
Raises:
ValueError—allow_originscontains*andallow_credentialsis True.
CorsPolicy(*, allow_origins: Iterable[str] = (), allow_methods: Iterable[str] = ('GET', 'HEAD', 'POST', 'OPTIONS'), allow_headers: Iterable[str] = (), allow_credentials: bool = False, expose_headers: Iterable[str] = (), max_age: int = 600) -> Nonedescribe#
describe()The cross-origin headers this policy negotiates.
Values depend on the request's Origin, so none is a const.
wreath.policy.csrf#
First-class signed double-submit CSRF policy.
Needed wherever the browser attaches credentials on its own -- a session cookie, HTTP basic auth -- because then a cross-site form post carries them too. An API authenticated by a bearer token the client has to attach deliberately is not exposed to this and does not need the policy:
app.configure_http_policy(HttpPolicy(
csrf=CsrfPolicy(secret=SECRET, trusted_hosts=["app.example"]),
))
@app.get("/whoami")
async def whoami(request):
return {"csrf": csrf_token(request)}The resubmitted token is read from x-csrf-token by default. Set form_field="csrf_token" when ordinary HTML forms also need to submit it in their urlencoded or multipart body; header-only deployments retain the native zero-body-read fast path.
Mount ProxyPolicy ahead of this one behind a TLS-terminating proxy; see CsrfPolicy for why the origin check depends on it.
CsrfPolicy#
Protect unsafe browser requests with a signed double-submit token.
Global policy. GET, HEAD, OPTIONS, and QUERY are treated as safe; every other method is checked, including ones a route does not implement.
Sec-Fetch-Site is consulted first, and settles the request when present. The browser sets it and the page making the request cannot forge it, so an unsafe request is refused unless the value is same-origin or none, and a safe one needs no token at all. same-site is refused: it means a different subdomain, which is a different security origin, and is what a sibling-subdomain takeover abuses. Go's net/http.CrossOriginProtection draws the line in the same place. Browsers have sent this header since 2023 and OWASP accepted Fetch Metadata as a complete alternative to tokens in December 2025.
The token below is the fallback, for a client that sent no Sec-Fetch-Site: a pre-2023 browser, a proxy that strips it, or a non-browser caller. It is unchanged, and it is why this is a reordering rather than a replacement — a deployment that loses the header falls back to exactly the check it had before, rather than to nothing.
Token validation is two independent checks, and both must pass. The double submit: the request carries the token in the configured header, or in the explicitly configured form field, and in the cookie, the two are byte-equal under a constant-time compare, and the cookie's own HMAC signature verifies and is within max_age. The origin check: the request's Origin header, or failing that the origin parsed out of Referer, matches the origin built from the request scheme and Host, or one of trusted_origins. A request with neither header is refused unless allow_missing_origin says otherwise.
A failure is a 403 application/problem+json document titled Forbidden with detail CSRF validation failed. Nothing distinguishes which of the two checks failed, deliberately.
The token reaches the application through csrf_token(request) and the browser through the cookie, which _egress writes whenever the token was minted or renewed. Renewal happens once a token is three quarters of the way through max_age, on unsafe methods as well as safe ones -- a client that only ever POSTs never takes the safe path, and would otherwise watch its token expire with nothing to refresh it.
On a request Fetch Metadata answered, no token is minted until a handler calls csrf_token, which mints one then and leaves _egress to write the cookie exactly as it would have. That is where the saving is: minting is paid by the request that wanted a token rather than by every request that did not. cross_site_refusals counts what the header check refused.
The cookie is written with Path=/, Max-Age, SameSite, and Secure when configured. It is deliberately not HttpOnly, because a script client has to read it to put it in the header; the signature, not inaccessibility, is what makes it unforgeable.
The expected origin is built from the client-supplied Host header, so something must constrain that. Naming trusted_hosts here does it and keeps this policy self-contained; leaving it empty means TrustedHostPolicy must be mounted instead. Behind a TLS-terminating proxy, ProxyPolicy must run first or the expected origin is built as http://host while the browser sends https://host, and every unsafe request is refused.
exempt_errors counts the times the exempt predicate raised. Each of those requests was refused -- failing closed is the only defensible direction -- but a broken predicate refuses everything forever and looks exactly like a site under attack, so the count is how the two are told apart.
Args:
secret— HMAC key, at least 32 bytes. A str is encoded as UTF-8.cookie_name— Cookie the token is written to and read from.header_name— Request header the resubmitted token is read from.form_field— Optional urlencoded/multipart text field carrying the token.- The header wins when both are present. Repeated fields are refused.
max_age— Token lifetime in seconds, and the cookieMax-Age.secure— Mark the cookieSecure. Leave True outside local plaintext development.same_site— CookieSameSite, one of strict, lax, or none.trusted_origins— Extra origins accepted besides the request's own.trusted_hosts— Host values accepted. Empty defers toTrustedHostPolicy.exempt— Predicate excusing an unsafe request. Raising refuses it.allow_missing_origin— Accept an unsafe request carrying no Origin and no Referer.
Raises:
ValueError— The secret is under 32 bytes.ValueError—cookie_nameorheader_nameis not a valid HTTP token.ValueError— A__Host-or__Secure-cookie name was given withoutsecure.ValueError—max_ageis not positive and finite.ValueError—same_siteis not strict, lax, or none.ValueError—same_siteis none withoutsecure.
CsrfPolicy(secret: str | bytes | bytearray | memoryview, *, cookie_name: str = 'wreath_csrf', header_name: str = 'x-csrf-token', form_field: str | None = None, max_age: int = 7200, secure: bool = True, same_site: str = 'lax', trusted_origins: Iterable[str] = (), trusted_hosts: Iterable[str] = (), exempt: Callable[[Request], bool] | None = None, allow_missing_origin: bool = False) -> Nonedescribe#
describe() -> AnyThe token header this policy requires, and the 403 without it.
Named from self._header_name, so a deployment that configured a different header documents the one it actually reads.
csrf_token#
csrf_token(request: Request) -> strReturn the CSRF token CsrfPolicy prepared for this request.
This is the value a client sends back in the configured request header, x-csrf-token by default. It is the same value that goes into the cookie, which is what makes the pair a double submit.
A token is prepared for every safe-method request from a client that did not send Sec-Fetch-Site, and for every unsafe one that passed the token check. It is not prepared for a request the exempt predicate excused, which never ran validation and has nothing to hand back.
When Fetch Metadata answered the request, no token was minted -- that is the whole saving -- so one is minted here, on demand, for the caller that asked. _egress still writes the cookie, because minting sets the same flag the eager path does. A handler that hands tokens to a client therefore behaves exactly as it did before; the cost simply moved to the request that wanted one instead of being paid by every request that did not.
Returns:
- The signed token string, shaped
v1.issued.nonce.mac.
Raises:
RuntimeError— No token was prepared for this request, and no policy- was recorded that could mint one -- the policy is not installed,
- or the
exemptpredicate excused this request.
wreath.policy.deadline#
First-class application handler deadline policy.
DeadlinePolicy#
Bound asynchronous handler execution independently of request ingestion.
Wreath's server request_timeout protects the socket-facing request phase. This policy starts after routing, authentication, and dependencies have activated and bounds the handler await itself. Synchronous Python cannot be pre-empted safely, so a synchronous handler is measured but can only observe the deadline after it returns; CPU work that needs hard pre-emption belongs in an isolated worker.
Args:
seconds— Positive handler deadline in seconds.detail— Problem detail returned with status 504.
DeadlinePolicy(seconds: float, *, detail: str = 'Request handler exceeded its deadline') -> Nonedescribe#
describe()The 504 emitted when asynchronous handler execution expires.
wreath.policy.idempotency#
Make unsafe requests replay-safe with an Idempotency-Key header.
A client that retries a POST (a dropped connection, an impatient user) must not create two records. When a request carries an Idempotency-Key, this policy remembers the first response and replays it for any repeat of the same key.
Opt-in per request (the header) and safe by construction:
- only unsafe methods are considered (
POST/PUT/PATCH/DELETE), - the key is scoped by method, path, and the authenticated principal, so two callers cannot collide on the same key value,
- an unauthenticated request is therefore not guarded at all: there is no principal to scope it by, and sharing one keyspace between anonymous callers would replay one caller's response to another,
- a concurrent duplicate (the first is still running) gets
409, - a
5xxis not cached — a transient failure stays retryable.
Two stores, and the difference matters. The default MemoryIdempotencyStore is bounded and in-process: it covers a retry that lands on the worker that served the original, and nothing else. Behind a load balancer that is most retries but not all, so a deployment that needs the guarantee should share PostgresIdempotencyStore — one table, no Redis, and the claim is a single statement so two workers cannot both believe they were first.
Even then, remember what this policy is: response replay. It saves the work and keeps the answer consistent. What makes the effect happen once is a unique index on something durable — the key you pass to wreath.jobs.JobRunner.enqueue(), or a unique column on the row you write. Compose the two and the guarantee holds end to end; see the exactly-once recipe.
IdempotencyPolicy#
Replay the stored response for a repeated Idempotency-Key.
An unauthenticated request is not guarded at all. The handler runs exactly as it would without this policy, and a retry re-runs it. That refusal is deliberate, not an oversight: the key is scoped by the authenticated principal, an anonymous caller has none, and one shared anonymous keyspace over a key value the client chooses would let one caller be handed another caller's stored response — on POST /signup or a password reset, a response carrying a new id, a token, an email. There is no address-scoped fallback either, because a proxy or a NAT puts unrelated callers back into one bucket. A key sent by an anonymous caller is counted in ignored and named on the way out with an Idempotency-Ignored: unauthenticated response header, so the client is told rather than left to assume.
Only POST, PUT, PATCH and DELETE are considered, and only when the request carries the header. The store key is a blake2s digest over length-framed method, path, principal type, principal id, and the header value. Length framing keeps spaces or other delimiters inside one component, and hashing keeps the stored key bounded whatever the path.
The first request holding a key claims it, runs the handler, and stores the response. A repeat arriving while the first is still running is refused with 409 and Retry-After: 1, as an RFC 9457 application/problem+json body — not a replay, and not a wait. A repeat arriving after the first finished is answered from the store with the original status, the stored headers, and Idempotency-Replayed: true. Set-Cookie is never stored or replayed, because a stored copy re-issues credentials the server has already rotated away from.
Nothing is stored for a 5xx, for a status whose cause is expected to change on a retry (401, 403, 408, 409, 423, 425, 429, 449), for a streaming or file response, or for a body over max_body_bytes. The key is released instead and the request stays retryable. A handler that raises has become a 500 by the time this policy sees it, so it releases the key too — a failed attempt never locks its key for the day.
It runs at the action stage, after the pipeline has identified the caller and before the handler, rather than at ingress where a global before hook would run upstream of authentication and so guard nothing.
Two counters are worth alerting on. ignored is requests that sent a key this policy did not act on; silence there is how "idempotency works in staging" happens. conflicts is requests refused with 409, and a climbing count with no matching traffic means keys are stuck — a process that died between reserving a key and storing its response leaves it claimed for the whole TTL, and release() is the lever.
Args:
ttl— Seconds a key is honoured, for the default store only. Default one day.max_entries— Replays the default in-process store keeps. Default 4096.methods— Methods eligible for guarding, upper-cased for you.header— Request header carrying the key. Defaultidempotency-key.store— Where replays live. Default a per-processMemoryIdempotencyStore.max_body_bytes— Largest response body stored; above it the key is released.
IdempotencyPolicy(*, ttl: float = 86400, max_entries: int = 4096, methods: Iterable[str] = ('POST', 'PUT', 'PATCH', 'DELETE'), header: str = 'idempotency-key', store: IdempotencyStore | None = None, max_body_bytes: int = 262144) -> Noneaction#
async action(request: Request)Claim the key, or answer the request from the store.
Returns None to let the handler run, a 409 problem response when the key is already in flight, a 503 when bounded storage cannot safely admit a new key, or the replayed response when one is stored.
Deliberately the action stage and not before: a global before hook runs at ingress, which is upstream of authentication, so request.identity is always None there -- and _key returns None for an anonymous request, so mounting this as an ingress hook made it silently guard nothing at all. RateLimitPolicy refuses principal_key at construction for exactly this reason; this policy has no such choice to refuse, so it moves to the stage where the principal exists. The after half still runs for every response, as it must.
after#
async after(request: Request, response)Store the response under the claimed key, or release the key.
Runs for every response, including one produced by a handler that raised, which is how a failure releases its key instead of caching itself for the day. A request that claimed no key passes straight through, except that an ignored key is named on the response here.
describe#
describe() -> AnyThe key this policy reads, and the 409 a concurrent repeat gets.
Scoped to the four methods action actually considers, so a generated client does not send a key on a GET that would ignore it.
release#
async release(key: str) -> NoneForget key, so a stuck claim can be cleared without waiting a day.
A process that dies between reserving a key and storing its response leaves the key claimed until it expires, and every retry gets 409 until then. There was no way to say "that one is not coming back". The argument is the store key, the hashed value request.state carries as idempotency_key, not the header the client sent.
schema_owners (property)#
schema_owners: tuple[Any, ...]The store this policy delegates its tables to.
It owns no tables itself, so it answers with the store it was given rather than forwarding a component(). Wreath.schema_components walks policy and asks each holder this question, so a PostgresIdempotencyStore contributes its wreath_idempotency table.
The default in-process store is returned too and contributes nothing -- it has no component(), and the walk asks rather than assumes.
IdempotencyStore#
Where first responses are kept so a retry can replay them.
Three calls, always in this order. reserve claims the key and says whether this request owns it; the owner then either stores the response it produced or releases the key so a later attempt can own it. A key that is claimed and neither stored nor released stays claimed until its TTL expires, and every repeat of it is refused with 409 until then.
IdempotencyStore(*args, **kwargs)release#
async release(key: str) -> NoneForget key, so the next request carrying it runs the handler again.
Called whenever the response must not be replayed — a 5xx, a status whose cause is expected to change, a body too large to snapshot. Not an error when the key is already gone.
reserve#
async reserve(key: str) -> tuple[str, Replay | None]Claim key for this request.
Returns ("fresh", None) when this caller owns the key and should run the handler, ("in_flight", None) when another request holds it, ("done", replay) when a response is already stored, or ("full", None) when bounded storage cannot admit a new key without displacing a live one.
Must be atomic: a read followed by a write lets two workers both conclude they were first, which is the one thing this policy exists to prevent.
store#
async store(key: str, replay: Replay) -> NoneRecord the response key produced, for the rest of the key's window.
The window opened when reserve claimed the key, and storing does not move it, so a slow handler cannot extend its own key past the TTL.
MemoryIdempotencyStore#
Bounded, in-process, no external state. One worker's memory.
A limitation, not a configuration choice: this store deduplicates only within the process that holds it. A retry routed to a second worker sees no claim and no stored response, so it runs the handler again. Enough on its own for a single-worker deployment or a sticky load balancer; behind anything else it is a fast path in front of the durable guarantee rather than the guarantee itself, and PostgresIdempotencyStore is the guarantee.
The window runs from the first attempt, not from whenever the handler finished -- the capability map keeps the deadline a key was claimed with, so a slow request cannot extend its own key and the two stores honour a key for the same length of time.
Args:
ttl— Seconds a key is honoured, timed from the claim. Default one day.max_entries— Live claims and replays retained. A new key is refused at- capacity rather than evicting one still inside its replay window.
MemoryIdempotencyStore(*, ttl: float = 86400, max_entries: int = 4096) -> Nonerelease#
async release(key: str) -> NoneDrop key from this worker's memory. Not an error when it is gone.
reserve#
async reserve(key: str) -> tuple[str, Replay | None]Claim key in this worker's memory.
Returns ("fresh", None) when this request owns the key and must run the handler, ("in_flight", None) while another request on this worker holds it, ("done", replay) when a response is already stored, or ("full", None) when admitting it would evict a live key.
store#
async store(key: str, replay: Replay) -> NoneKeep replay under key for the remainder of the claimed window.
PostgresIdempotencyStore#
Replays in a shared table, so every worker honours every key.
The claim is wreath.store.PostgresStore.claim() — one INSERT ... ON CONFLICT (key) DO UPDATE ... WHERE expired RETURNING, where a returned row is the claim. No owner column, no second round trip, no advisory lock, and no window in which two workers both believe they were first; the row lock the statement takes is the whole of the mutual exclusion. Everything else here is this policy's payload, a status, headers, and a body, in the storage discipline wreath.store holds for all three of its callers.
The table is not created for you; apply schema_sql() as a migration, and drop expired rows with purge_pass().
Args:
database— The pool to run against, such asapp.postgres("main").table— Table holding the replays. Defaultwreath_idempotency.ttl— Seconds a key is honoured, timed from the claim. Default one day.
PostgresIdempotencyStore(database: Any, *, table: str = 'wreath_idempotency', ttl: float = 86400) -> Nonecomponent#
component() -> AnyThis store's claim on the wreath schema.
The table is unqualified and stays that way: moving it into the wreath schema is not additive, so a worker on the previous version would look for a name that had gone. Registered where the rows are.
purge#
async purge() -> strDrop expired replays in one unbounded statement.
Kept for a small table and for tests. On a table big enough to matter this is the long transaction a chunked pass exists to prevent -- it holds a snapshot open for as long as the delete runs, so nothing can be vacuumed in the meantime and the application stays slower for as long as the bloat takes to work back out. Prefer purge_pass().
Safe either way: an expired key is already forgotten.
purge_pass#
purge_pass(*, chunk: int = 1000, **options: Any) -> AnyA recurring pass that drops expired replays, chunk by chunk.
This is the supported way to keep the table small. Hand it to the job runner and forget about it:
jobs.drive(store.purge_pass(), cron="*/5 * * * *")Every property a purge loop is usually missing comes with it: one transaction per chunk rather than one for the whole delete, a durable cursor so a redeploy resumes instead of restarting, and pacing so the purge cannot be the reason a request waited for a connection. See wreath.passes. Remaining keyword options are the pass's own — within, shift, pace, schema, tenant.
Args:
chunk— Rows deleted per transaction. Default 1000.
release#
async release(key: str) -> NoneDelete key's row. Not an error when it is already gone.
reserve#
async reserve(key: str) -> tuple[str, Replay | None]Claim key in the shared table, so every worker honours it.
The same three answers as MemoryIdempotencyStore.reserve(), decided by the single claiming statement — a claimed row with no status yet is a request still in flight on some worker, which is what makes a concurrent duplicate a 409 across the whole deployment rather than one process.
schema_database (property)#
schema_database: AnyThe database component()'s tables belong to.
The application never saw this store constructed -- a caller builds it and hands it to IdempotencyPolicy -- so it cannot know which app.postgres() the tables go to unless the store says. This is that contract, and it is one name rather than a list of plausible ones: Wreath._schema_database reads exactly schema_database.
schema_sql#
schema_sql() -> strDDL for the backing table, semicolon-joined. A derivation of component().
store#
async store(key: str, replay: Replay) -> NoneWrite the response key produced into the shared table.
Headers go in as latin-1 text pairs in jsonb and the body as bytea. The row's expiry is deliberately left alone, so the window still runs from the claim rather than from whenever the handler finished.
wreath.policy.maintenance#
First-class operator-controlled request admission policy.
MaintenancePolicy#
Refuse ordinary traffic while allowing explicit operational paths.
The active flag and refusal counter live in a native atomic switch, so a request pays no Python lock and the same instance remains correct under the free-threaded build. Exempt paths are exact, not prefixes: exempting /health must not silently exempt /health/delete-everything.
Args:
active— Begin in maintenance mode.exempt_paths— Exact paths that remain available, typically readiness.detail— Problem detail returned with status 503.retry_after— Optional whole seconds advertised throughRetry-After.
MaintenancePolicy(*, active: bool = False, exempt_paths: Iterable[str] = (), detail: str = 'Application is in maintenance mode', retry_after: int | None = None) -> Noneactive (property)#
active: boolWhether maintenance refusal is currently active.
describe#
describe()The 503 emitted while maintenance mode is active.
disable#
disable() -> NoneLeave maintenance mode for subsequent requests.
enable#
enable() -> NoneEnter maintenance mode for subsequent requests.
refused (property)#
refused: intRequests refused since construction.
wreath.policy.proxy#
First-class trusted forwarding policy for deployments behind a proxy.
Behind a TLS-terminating proxy the connection Wreath actually accepts is plaintext HTTP from the proxy, so the scope says scheme == "http" and the peer address is the proxy's. Middleware downstream believes it, and two things fail quietly: SecurityHeadersPolicy gates HSTS on an HTTPS scheme and emits nothing, and CsrfPolicy builds its expected origin as http://host while the browser sends https://host and rejects every unsafe request.
This policy restores the truth, but only from proxies that were explicitly configured:
app.configure_http_policy(HttpPolicy(
proxy=ProxyPolicy(trusted=["10.0.0.0/8"]),
))Forwarding headers are trivially forged by any client, so nothing is trusted by default and the allow-list has no wildcard: trusted is required and must name the proxy networks. Run this before anything that reads scheme, host, or client -- a negative priority puts it first.
ProxyPolicy#
Apply X-Forwarded-* headers, but only from configured proxy networks.
Mount this only when a proxy you control sits in front of the application and overwrites these headers on the way in. Every one of them is client-supplied and trivially forged; the peer address is the only thing that cannot be, which is why the allow-list is on the peer and not on the header. There is no wildcard: trusted is required and empty is refused.
Three corrections, all skipped entirely when the immediate peer is outside trusted. X-Forwarded-For sets Request.client to the rightmost hop that is not itself a trusted proxy, or to the leftmost when every hop is one, and always with a None port -- the port on the connection belongs to the proxy and no forwarding header carries the client's. A chain containing one hop that does not parse leaves Request.client alone entirely, because a malformed chain puts the boundary between forged and vouched-for hops out of reach. X-Forwarded-Proto sets Request.scheme, taking the first entry of a chain and accepting only http or https. X-Forwarded-Host overwrites the Host header rather than a scope entry, because TrustedHostPolicy and CsrfPolicy read the header.
Mount it before anything that reads scheme, host, or client -- a negative priority puts it first. Without it, SecurityHeadersPolicy sees an http scheme and emits no HSTS, and CsrfPolicy builds an expected origin of http://host while the browser sends https://host and refuses every unsafe request. Both failures are silent.
Args:
trusted— Proxy addresses or CIDR networks whose forwarding headers count.trust_proto— ApplyX-Forwarded-Prototo the request scheme.trust_host— ApplyX-Forwarded-Hostto the requestHostheader.
Raises:
ValueError—trustedis empty.ValueError— An entry is not a valid address or strict CIDR network.TypeError— An entry is not a string.
ProxyPolicy(*, trusted: Iterable[str], trust_proto: bool = True, trust_host: bool = True) -> Nonewreath.policy.ratelimit#
First-class token-bucket request policy with a pluggable store.
A token bucket rather than a fixed window: a window counter lets a client spend its whole allowance in the last instant of one window and again in the first instant of the next, delivering double the intended rate across the boundary. A bucket refills continuously, so the average rate holds over any interval while still admitting a configured burst.
The default store keeps buckets in this process:
app.configure_http_policy(HttpPolicy(
rate_limit=RateLimitPolicy(limit=60, window=60.0),
))That is per-worker: with four workers the effective limit is four times the configured one. When the limit must hold across workers, hand it a store backed by a table the workers share:
store = PostgresRateLimitStore(app.postgres("main"))
app.configure_http_policy(HttpPolicy(
rate_limit=RateLimitPolicy(limit=60, window=60.0, store=store),
))By default each client IP gets its own bucket, read from scope["client"]. Behind a proxy that address is the proxy's, and every client would share one bucket -- add ProxyPolicy ahead of this one so the scope carries the real client.
MemoryRateLimitStore#
In-process buckets. Fast and dependency-free, but per-worker.
A limitation worth stating plainly: a limit of N per window is N per worker, not per cluster, and nothing here observes the other workers. Four workers admit four times the configured rate. Use PostgresRateLimitStore when the number has to mean something across a deployment.
Not a wreath.store.MemoryStore: a bucket is not a stored payload but an arithmetic state that every read advances, and the whole refill-and-consume step lives in TokenBucket (native, with its own bounded table) so the hot path stays one call.
Args:
max_entries— Distinct keys tracked. The fullest bucket is evicted at the ceiling.
MemoryRateLimitStore(*, max_entries: int = 10000) -> Noneacquire#
async acquire(key: str, cost: float, now: float) -> floattry_acquire behind the awaiting RateLimitStore protocol.
clear#
clear() -> NoneForget every bucket, restoring a full allowance to every key.
Safe as a reset between tests. In production it is an amnesty, not a purge -- a throttled caller is let straight back through.
configure#
configure(capacity: float, rate: float) -> NoneBuild the bucket table for this policy. Once, and only once.
Raises ValueError on any second call, even one repeating the same numbers.
tracked (property)#
tracked: intKeys with a live bucket right now, and 0 before configure.
try_acquire#
try_acquire(key: str, cost: float, now: float) -> floatSpend cost tokens without awaiting anything.
Returns 0.0 when the request is allowed, else the seconds until the bucket holds enough. Nothing is spent on a refusal. now is a monotonic reading. This is the stage RateLimitPolicy binds to for a local store, so an allowed request costs no coroutine at all.
PostgresRateLimitStore#
Buckets in a shared table, so one limit covers every worker.
The whole refill-and-consume decision is one INSERT ... ON CONFLICT DO UPDATE. That statement takes a row lock, so concurrent workers serialize per key and the limit holds exactly; two statements or a read-then-write would race and overspend. The database clock drives the refill, so the workers cannot disagree about how much time has passed.
updated is a last-touched mark rather than a deadline, which is what makes this store's ageing different from the other two in wreath.store: a bucket never expires, it refills, and the elapsed time since updated is the arithmetic the statement above does. Only an idle purge can retire one.
The table is not created for you; apply schema_sql() as a migration. Rows are small and bounded by distinct keys -- run purge_pass() periodically to drop idle ones.
Args:
database— The pool to run against, such asapp.postgres("main").table— Table holding the buckets. Defaultwreath_rate_limit.
PostgresRateLimitStore(database: Any, *, table: str = 'wreath_rate_limit') -> Noneacquire#
async acquire(key: str, cost: float, now: float) -> floatSpend cost tokens from the shared bucket for key.
Returns 0.0 when the request is allowed, else the seconds until the bucket holds enough. One round trip, and now is ignored -- the database clock is the only one every worker agrees on. Requires configure to have run.
component#
component() -> AnyThis store's claim on the wreath schema.
The table is unqualified and stays that way: moving it into the wreath schema is not additive, so a worker on the previous version would look for a name that had gone. Registered where the rows are.
configure#
configure(capacity: float, rate: float) -> NoneRecord the policy bound into every acquire. Once, and only once.
The statement text carries no policy -- capacity and rate are bound per call -- so this only stores the numbers. Raises ValueError on any second call, even one repeating the same numbers, because a second caller means two policies sharing one keyspace.
purge#
async purge(idle_seconds: float) -> strDrop buckets untouched for idle_seconds, in one unbounded statement.
Safe -- an idle bucket has refilled to capacity, so forgetting it grants nothing it would not have granted anyway -- but on a table big enough to matter this is the long transaction a chunked pass exists to prevent. Prefer purge_pass().
purge_pass#
purge_pass(idle_seconds: float, *, chunk: int = 1000, **options: Any) -> AnyA recurring pass that retires buckets untouched for idle_seconds.
The supported way to keep the table small:
jobs.drive(store.purge_pass(3600), cron="17 * * * *")It walks in last-touched order behind a frontier the database clock re-derives each cycle, one transaction per chunk, paced. See wreath.passes. Remaining keyword options are the pass's own -- within, shift, pace, schema, tenant.
Args:
idle_seconds— Seconds a bucket must sit untouched before it is dropped.chunk— Rows deleted per transaction. Default 1000.
schema_database (property)#
schema_database: AnyThe database component()'s tables belong to.
The application never saw this store constructed -- a caller builds it and hands it to RateLimitPolicy -- so it cannot know which app.postgres() the tables go to unless the store says. This is that contract, and it is one name rather than a list of plausible ones: Wreath._schema_database reads exactly schema_database.
schema_sql#
schema_sql() -> strDDL for the backing table, semicolon-joined. A derivation of component().
RateLimitPolicy#
Reject requests that exceed a per-key token-bucket allowance.
Each key owns a bucket holding burst tokens (limit when no burst is given) that refills continuously at limit / window tokens per second. A request spends cost; when the bucket is short, the request is refused and nothing is spent. Continuous refill rather than a window counter is the point — a window lets a caller spend its whole allowance in the last instant of one window and again in the first instant of the next.
A refused request is 429 as an RFC 9457 application/problem+json body, carrying Retry-After in whole seconds (rounded up, and never 0), X-RateLimit-Limit, RateLimit-Policy, and X-RateLimit-Remaining: 0. Those headers ride on the refusal only. Advertising the policy on every response needs a global _egress stage, which wreath-request-trace priced at +18 boundary crossings per request, to carry a header that matters when a request is refused. The remaining allowance stays absent from an allowed response for a second reason: neither store reports it — acquire answers "wait this long", not "you have this many left" — and a number invented here would be worse than no number.
This is a global stage, so it runs at ingress, before authentication. Keying it on principal_key is refused at construction for that reason; TieredRateLimitPolicy is the per-principal limiter. The default key is the client address from scope["client"], which behind a proxy is the proxy's — put ProxyPolicy ahead of this one. A request whose key function returns None lands in one shared UNKEYED bucket rather than skipping the limit, because a limiter that skips what it cannot name is not a limiter; use exempt to let a request past deliberately.
throttled counts refusals. Without it, a limiter that keys everyone the same — the default key behind a proxy — looks exactly like one with nothing to do.
Args:
limit— Requests admitted perwindow. Must be positive.window— Seconds the limit is measured over. Default 60.burst— Bucket capacity in requests. Defaults tolimit; never less thancost.cost— Tokens one request spends. Default 1.0.store— Where buckets live. Default a per-processMemoryRateLimitStore.key— The bucket key for a request. Default the client address.exempt— A request it answers True for is not limited at all.
Raises:
ValueError— Non-positive or non-finite limit, window, cost, or burst;- burst below cost;
key=principal_key.
RateLimitPolicy(*, limit: int, window: float = 60.0, burst: int | None = None, cost: float = 1.0, store: RateLimitStore | None = None, key: Callable[[Request], str | None] | None = None, exempt: Callable[[Request], bool] | None = None, quota: Any = None, _route_scoped: bool = False) -> Noneadmit_key#
async admit_key(key: str) -> ProblemResponse | NoneCharge non-HTTP work against this policy's existing keyed bucket.
describe#
describe() -> AnyThe 429 this limiter can answer, and the headers it puts on it.
Derived from self._policy_headers -- the same tuple _limited appends to the refusal -- so the document cannot drift from the wire. A second format string here would be a second source of truth, and the two would agree only until someone changed one of them.
schema_owners (property)#
schema_owners: tuple[Any, ...]The store this limiter delegates its tables to.
A limiter owns no tables itself, so it answers with the store it was given rather than forwarding a component(). Answering at all is the point: Wreath.schema_components walks policy and asks each holder this question, so a PostgresRateLimitStore contributes its wreath_rate_limit table.
The default in-process store is returned too and contributes nothing -- it has no component(), and the walk asks rather than assumes, so filtering here would be a second copy of that test.
A metered quota's store is included for the same reason the limiter's is: a PostgresQuotaStore puts a wreath_quota table in a database, and a table emitted by wreath schema sql and created by nothing is exactly the defect this property was added to fix.
RateLimitStore#
Where buckets live.
configure is called once, when the policy is constructed, so the policy has a single owner and the store can prepare statements before startup. A store may additionally expose a synchronous try_acquire with the same contract as acquire; the policy binds to it and skips awaiting when it exists.
RateLimitStore(*args, **kwargs)acquire#
async acquire(key: str, cost: float, now: float) -> floatReturn 0.0 when allowed, else the seconds until enough tokens.
now is a monotonic reading from the caller. A store that owns a clock its workers agree on (Postgres does) should ignore it.
configure#
configure(capacity: float, rate: float) -> NoneFix the policy this store enforces, before any request arrives.
Called by RateLimitPolicy.__init__, not at startup, so a store can prepare statements and a misconfiguration is a construction-time error. capacity is the bucket size in tokens (the burst) and rate is tokens per second. Both shipped stores refuse a second call, because sharing one store between two policies means sharing one keyspace.
TieredRateLimitPolicy#
A different allowance per plan, keyed on the principal.
An authenticated API rarely wants one limit for everybody: the free tier and the enterprise tier are different products. The tier comes from the caller's roles -- the same roles the Cedar policies authorize with -- so there is one answer to "who is this" rather than two that can disagree:
app.configure_http_policy(HttpPolicy(principal_rate_limit=TieredRateLimitPolicy(
tiers={"pro": (600, 60.0), "enterprise": (10_000, 60.0)},
default=(60, 60.0),
)))Each entry is (limit, window_seconds). A caller holding more than one named role gets the most generous of them, ranked by limit / window, because holding two plans must not be worse than holding the better one. A caller whose roles name no tier -- and an anonymous caller, who has no roles at all -- is charged against default, so every request is limited by something. Each tier is a whole RateLimitPolicy with its own store, so the 429, the Retry-After, and the policy headers are the ones documented there.
This is the first-class post-authentication policy stage. Configure it as HttpPolicy(principal_rate_limit=...); it never enters a route middleware tape. For unauthenticated ingress protection -- a flood of 404s or a login flood -- also configure HttpPolicy(rate_limit=RateLimitPolicy(...)). A request that has to pass both spends a token in each.
Each tier keeps its own buckets, so a promotion arrives with a full allowance rather than whatever was left of the old plan's. That is the intended behaviour -- an upgrade should take effect at once -- and it has a consequence worth naming: moving between tiers hands the caller a fresh allowance every time, so where roles are self-service, toggling one twice is a way to bypass the limit on demand. Keep tier membership server-controlled, or key the limit on something the caller cannot change.
Args:
tiers— Role name to(limit, window_seconds). At least one is required.default— The(limit, window_seconds)for a caller matching no tier.tier— Names the tier for a request. Default the caller's most generous role.key— The bucket key within a tier. Defaultprincipal_key.cost— Tokens one request spends, in every tier. Default 1.0.exempt— A request it answers True for is not limited at all.store_factory— Builds one store per tier. DefaultMemoryRateLimitStore.quota— Awreath.quota.QuotaMetercharged in the same stage, after the- rate limit is satisfied. One meter across every tier, not one per
tier— the tier decides how fast a caller may go, the quota decides- how much they get, and giving each tier its own counter would hand a
- caller a fresh monthly allowance for changing plan.
Raises:
ValueError—tiersis empty.
TieredRateLimitPolicy(*, tiers: Mapping[str, tuple[int, float]], default: tuple[int, float], tier: Callable[[Request], str | None] | None = None, key: Callable[[Request], str | None] = <function principal_key>, cost: float = 1.0, exempt: Callable[[Request], bool] | None = None, store_factory: Callable[[], RateLimitStore] | None = None, quota: Any = None) -> Noneschema_owners (property)#
schema_owners: tuple[Any, ...]Every tier's store, so their tables are collected too.
A tier is a whole RateLimitPolicy, and the walk that asks this question goes one level down, not all the way -- so the tiers' answers are flattened here rather than returning the tiers themselves and trusting a recursion that does not happen. Every tier built by one store_factory claims the same table name and schema_components deduplicates by name, so N tiers still contribute one claim.
principal_key#
principal_key(request: Request) -> str | NoneKey a limit on who is asking rather than where they connected from.
Behind a proxy or a carrier NAT, the client address lumps unrelated callers into one bucket and lets one caller earn a fresh allowance per device. For an authenticated API the principal is the honest key.
An authenticated caller is keyed on a length-framed type, namespace, and id from request.identity — the same identity the Cedar policies authorize with, so delimiters inside one component cannot collide with another principal and there is one answer to "who is this" rather than two that can disagree.
Anonymous callers fall back to their address, because a single shared "anonymous" bucket is a denial of service you inflict on yourself. The ip: prefix keeps an address from ever colliding with a principal id. That fallback is only as trustworthy as scope["client"], which behind a proxy is the proxy's address and puts every anonymous caller in one bucket — put ProxyPolicy ahead of the limiter so the scope carries the real client. Returns None when there is no identity and no client address, which lands the request in the limiter's shared unkeyed bucket.
Must run after authentication. request.identity is set during route authorization, so a limiter using this key has to be route policy -- a global stage runs at ingress, before anyone has been identified. RateLimitPolicy refuses this key at construction for that reason; use TieredRateLimitPolicy.
wreath.policy.request_decompression#
Bounded transparent decoding for compressed HTTP request bodies.
RequestDecompressionPolicy#
Decode a gzip request representation before dependencies and handlers.
The public Request API does not change: body(), json() and form() see decoded bytes and share their existing one-read cache. Compressed input remains bounded by RequestLimits.max_body_bytes while max_output_bytes independently bounds expansion.
Unsupported, stacked, or duplicate content codings fail closed with 415. Format-aware decoding also refuses duplicate Content-Type fields rather than choosing one representation interpretation. The native decoder accepts exactly one complete gzip member and rejects truncation and trailing bytes.
Args:
max_output_bytes— Decoded byte ceiling.Nonereuses the request's- configured
RequestLimits.max_body_bytes. format_aware— Give Content-Type to the format-aware native inflate- kernel. Disable only for comparative measurement.
RequestDecompressionPolicy(*, max_output_bytes: int | None = None, format_aware: bool = True) -> Nonedescribe#
describe() -> AnyThe accepted request coding and bounded-decoding failures.
wreath.policy.request_id#
First-class request correlation policy.
Assigns every request a stable id, echoes it on the response by default, and records it on request state so handlers and later observability layers can read it:
app.configure_http_policy(HttpPolicy(request_id=RequestIdPolicy()))
@app.get("/")
async def index(request):
return {"trace": request_id(request)}An inbound id is only reused when it survives validation, because it is echoed into a response header and will later be written into access logs and trace attributes. A rejected id is replaced rather than sanitized -- minting a fresh one is cheaper than escaping, and a caller who sent junk has no expectation of seeing it back.
RequestIdPolicy#
Accept a validated inbound request id, or mint one.
Global policy, so every response is correlated, including route misses and errors. The id lands on request state for request_id(request) and, when echo is on, is appended to the response under the same header name it was read from.
An inbound id is reused only when trust_inbound is on and the value passes validation, meaning it is between 1 and max_length bytes and contains only ASCII letters, digits, -, _, and .. Anything else is replaced, not sanitized: the id is echoed into a response header and later written into access logs and trace attributes, minting a fresh one is cheaper than escaping, and a caller who sent junk has no claim on seeing it back. A minted id is 16 bytes from os.urandom hex-encoded -- collision-free in practice for correlation, and never usable as crypto material.
A trusted inbound id is caller-controlled, so the default mints every id. Enable trust_inbound only when a trusted proxy replaces the header from the public request before it reaches Wreath.
Args:
header— Header the id is read from and echoed to. Compared lowercased.trust_inbound— Reuse a valid inbound id instead of always minting one.echo— Append the id to the response.max_length— Longest inbound id accepted, in bytes.
Raises:
ValueError—headeris empty, ormax_lengthis below 1.
RequestIdPolicy(*, header: str = 'x-request-id', trust_inbound: bool = False, echo: bool = True, max_length: int = 128) -> Nonedescribe#
describe() -> AnyThe correlation header, in whichever direction this one is configured for.
A policy built with echo=False emits nothing, and saying so is the point: the document describes this instance, not the class.
request_id#
request_id(request: Request) -> strReturn the id RequestIdPolicy assigned to this request.
Reads request state, not a header, so it returns the same value whether the id was accepted from the caller or minted here, and whether or not echo is on.
Returns:
- The correlation id for this request.
Raises:
RuntimeError— No id was assigned, soRequestIdPolicyis not mounted.
wreath.policy.security#
First-class trusted-host and browser security policy.
Three independent global policies. TrustedHostPolicy refuses a request whose Host is not one this application serves, before routing. WebSocketOriginPolicy applies an exact browser-origin allowlist before a WebSocket handshake. SecurityHeadersPolicy adds hardening headers to every response that does not already declare them:
app.configure_http_policy(HttpPolicy(
trusted_host=TrustedHostPolicy(["app.example", "*.app.example"]),
security_headers=SecurityHeadersPolicy(
hsts_max_age=31_536_000,
hsts_include_subdomains=True,
),
))Both read the request scheme and Host, so behind a proxy both belong after ProxyPolicy.
SecurityHeadersPolicy#
Append a precompiled set of browser security headers to responses.
Global policy, so error responses and static files are covered too. The header list is built once in the constructor; the per-response work is one scheme comparison and an append of whatever is not already there.
A header is added only when the response does not already carry one by that name, matched case-insensitively. A handler that set its own Content-Security-Policy keeps it -- this sets defaults, it does not enforce them.
By default it emits Content-Security-Policy: default-src 'self', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy: strict-origin-when-cross-origin. Pass None for any of those to omit it. Permissions-Policy is off unless configured.
Strict-Transport-Security is emitted only on responses to requests whose scheme is https, and only when HSTS was configured at all. Behind a TLS-terminating proxy the scheme Wreath sees is http, so without ProxyPolicy in front the header is silently never sent. Configure HSTS either structurally through hsts_max_age and its companions or verbatim through strict_transport_security, never both.
Args:
content_security_policy— The CSP value, or None to emit no CSP header.csp_nonce_directives— CSP directive names that receive a fresh- per-request nonce, such as
script-srcandstyle-src. csp_report_only— EmitContent-Security-Policy-Report-Onlyinstead- of the enforcing header.
frame_options— TheX-Frame-Optionsvalue, or None to omit it.content_type_options— EmitX-Content-Type-Options: nosniff.referrer_policy— TheReferrer-Policyvalue, or None to omit it.permissions_policy— ThePermissions-Policyvalue. None omits it.hsts_max_age— HSTS lifetime in seconds. None emits no HSTS header.hsts_include_subdomains— AddincludeSubDomainsto the HSTS value.hsts_preload— Addpreload, which the preload list requires.strict_transport_security— A verbatim HSTS value instead of the structured ones.
Raises:
ValueError— Bothhsts_max_ageandstrict_transport_securitywere given.ValueError—hsts_max_ageis not a non-negative integer.ValueError—hsts_preloadwithouthsts_include_subdomainsand a year of max-age.
SecurityHeadersPolicy(*, content_security_policy: str | None = "default-src 'self'", csp_nonce_directives: Iterable[str] = (), csp_report_only: bool = False, frame_options: str | None = 'DENY', content_type_options: bool = True, referrer_policy: str | None = 'strict-origin-when-cross-origin', permissions_policy: str | None = None, hsts_max_age: int | None = None, hsts_include_subdomains: bool = False, hsts_preload: bool = False, strict_transport_security: str | None = None) -> Nonedescribe#
describe()Every configured header, with its value, read off the precompiled set.
Derived from self.https_headers -- the widest set this instance can emit -- so a deployment that turned CSP off documents no CSP. HSTS is included because a caller over https receives it; over http it is simply absent, which the description says.
TrustedHostPolicy#
Reject requests whose Host value does not match a compiled allowlist.
Global policy with a synchronous _ingress_sync stage, so it is fused into the pipeline with no coroutine and no await. It runs before routing, which is the point -- a request for a host this application does not serve never reaches a handler.
The check is on the host alone. A syntactically valid numeric port is stripped before comparison, an IPv6 literal keeps its brackets, and the comparison is case-insensitive. User information, junk after a bracketed literal, and malformed ports are rejected rather than truncated into an allowed host. A pattern is either an exact host, * for any host, or *.example.com, which matches any subdomain of example.com but not the bare example.com; to accept both, list both. A * anywhere else in a pattern is rejected at construction rather than being treated as a literal.
A request with no Host header, or one that matches nothing, is answered 400 application/problem+json with detail Invalid Host header.
Behind a proxy this must be configured after ProxyPolicy, which is what turns X-Forwarded-Host into the Host this reads.
Args:
allowed_hosts— Host patterns to accept. Must not be empty.
Raises:
ValueError—allowed_hostsis empty or contains an invalid pattern.
TrustedHostPolicy(allowed_hosts: Iterable[str]) -> Nonedescribe#
describe()The 400 an untrusted or duplicated Host gets.
WebSocketOriginPolicy#
Reject WebSocket handshakes outside an exact browser-origin allowlist.
WebSocketOriginPolicy(allowed_origins: Iterable[str]) -> Nonecsp_nonce#
csp_nonce(request: Request) -> strReturn the per-request CSP nonce prepared by SecurityHeadersPolicy.
The value is minted once on first use and is the exact value injected into the configured CSP directives during response egress.
wreath.policy.sessions#
First-class signed-cookie session policy.
The session is a plain dict on request.state.session. It is serialized to JSON, signed with HMAC-SHA256 together with the request's tenant and Host, and stored client-side in a cookie — nothing is kept on the server unless a store is given. Tampered, expired, or cross-tenant cookies yield a fresh empty session; the cookie is only (re)written when the session content changed:
app.configure_http_policy(HttpPolicy(session=SessionPolicy(secret="…")))
@app.get("/visit")
async def visit(request):
request.state.session["count"] = request.state.session.get("count", 0) + 1
return {"visits": request.state.session["count"]}Call rotate_session(request) whenever the caller's privileges change. Wreath activates sessions after native ingress and before authentication, and persists them in a fixed egress slot.
SessionPolicy#
Load and persist a per-caller session held in a signed cookie.
This is an HttpPolicy component, never middleware. Wreath loads it in the fixed activation slot after native ingress and before authentication, including WebSocket authentication. before publishes a plain dict on request.state.session; handlers mutate that dict and fixed egress calls after to decide what to persist.
Without a store the whole session lives in the cookie. It is serialized to JSON, base64url-encoded, and signed with HMAC-SHA256 over the payload and its issue time; nothing is kept server-side. A cookie whose signature does not verify under any known secret, or whose issue time is more than max_age seconds old, yields a fresh empty session rather than an error -- tampering is indistinguishable from a very old cookie, and both mean the same thing to the caller. Browsers cap a cookie at about 4 KiB, which is the real bound on how much a cookie session can hold; exceed it and the browser drops the cookie without telling anyone. Give a store to move past that.
With a store, the cookie carries only a signed session id and the contents live server-side, which makes a session revocable and unbounded by cookie size. A signed id whose row the store no longer has is treated as no session at all, because a deleted row is a revocation.
The cookie is written only when the serialized session differs from what arrived, so a request that reads without writing sends no Set-Cookie and does not defeat downstream caching. The comparison is byte-for-byte against the exact payload decoded from the cookie: a cookie minted by another encoder, or with its keys in another order, reads as changed and is reissued with the same content and a fresh signature. A session emptied to {} clears the cookie, and deletes the stored row when there is one. With a store, an unchanged live session has its expiry extended when the store offers a touch, so a session in constant use does not die at a fixed age.
previous_secrets are accepted for verification but never used for signing, so a secret can be rotated without invalidating every live session at once. A cookie accepted under a previous secret is always re-signed with the current one, which is what lets the old secret eventually be retired.
Signatures also bind the normalized Host and, when tenancy middleware has run, the resolved tenant key. A valid cookie planted on a sibling host or replayed under another tenant is therefore invalid. Pre-binding cookies are accepted only by applications with no resolved tenant and immediately re-signed; tenant applications fail those legacy cookies closed.
Args:
secret— HMAC key, at least 32 bytes as UTF-8.cookie— Cookie name carrying the session or the session id.max_age— Session lifetime in seconds, and the cookieMax-Age.same_site— CookieSameSite, one of strict, lax, or none.secure— Mark the cookieSecure. Pass False only for local plaintext development.http_only— Mark the cookieHttpOnly, keeping scripts out of it.store— ASessionStoremoving contents server-side. None keeps them in the cookie.previous_secrets— Retired HMAC keys, each at least 32 bytes.
Raises:
TypeError— An item inprevious_secretsis not text or bytes-like.ValueError—secretor an item inprevious_secretsis shorter than 32 bytes.
SessionPolicy(secret: str, *, cookie: str = 'wreath_session', max_age: int = 1209600, same_site: str = 'lax', secure: bool = True, http_only: bool = True, store: Any = None, previous_secrets: Any = ()) -> Noneafter#
async after(request: Request, response: Any) -> AnyWrite, clear, or leave the session cookie, according to what changed.
Returns the response untouched when the session is byte-identical to what arrived, when before did not complete, or when the response type cannot carry a cookie. An emptied session clears the cookie instead of rewriting it.
before#
async before(request: Request) -> NonePublish request.state.session and the baseline after diffs against.
Always returns None; this hook never short-circuits. A missing, tampered, or expired cookie produces an empty session, not an error.
describe#
describe()The session cookie, named as this instance configured it.
schema_owners (property)#
schema_owners: tuple[Any, ...]The store this policy delegates its tables to, if it has one.
It owns no tables itself, so it answers with the store it was given rather than forwarding a component(). Wreath.schema_components walks policy and asks each holder this question, so a PostgresSessionStore contributes its wreath_session table.
Empty without a store, which is the cookie-only session and owns no tables at all -- not a claim that could not be attributed.
rotate_session#
rotate_session(request: Request) -> NoneMint a fresh session id for this request, discarding the old one.
Call this the moment a caller's privileges change -- on login, and on any step-up such as entering an admin area. An attacker who fixed a victim's session id beforehand then holds an id that no longer exists.
A no-op for cookie-backed sessions, which carry no server-side id: there, the cookie is re-signed on every content change already.
wreath.policy.signed_routes#
Expiring, optionally single-use authorization for exact HTTP paths.
SignedRoutePolicy#
Require an ActionTokens capability on configured exact paths.
The signature covers the normalized method and exact raw request target. Re-encoding the path, or reordering or changing any ordinary query parameter, therefore invalidates it. The generated token is appended last, and duplicate token parameters are refused.
ActionTokens supplies expiry, key rotation, and optional single-use replay refusal. This policy supplies the HTTP binding and fail-closed 403. By default, a bound tenant is part of that binding. scope can replace the request-side scope for another authority boundary; pass the matching value to sign when minting outside a tenant scope.
Args:
tokens— Application-owned action-token issuer/verifier.purpose— A purpose declared ontokens.paths— Exact protected absolute paths.methods— Protected methods. HEAD is normalized to GET so a signed GET- URL retains ordinary HTTP HEAD semantics.
scope— Request-side authority scope. Defaults to the bound tenant key.parameter— Query parameter carrying the capability token.detail— Generic 403 detail for every verification failure.
SignedRoutePolicy(tokens: ActionTokens, purpose: str, paths: Iterable[str], *, methods: Iterable[str] = ('GET', 'HEAD'), scope: Callable[[Request], str] = <function _tenant_scope>, parameter: str = 'signature', detail: str = 'Signed URL is invalid or expired') -> Nonedescribe#
describe() -> AnyThe generic 403 emitted for an invalid signed capability.
sign#
sign(path: str, *, method: str = 'GET', scope: str | None = None, now: float | None = None) -> strReturn path with an expiring capability appended to its query.
wreath.policy.timing#
First-class server timing policy.
Nothing is timed unless this is mounted, because the duration it reports is visible to anyone who can read the response:
app.configure_http_policy(HttpPolicy(server_timing=ServerTimingPolicy()))The elapsed time is recorded on request state whether or not the header is emitted, so elapsed(request) is the single measurement an access log or a tracing exporter reads later rather than each timing its own span.
The clock is time.perf_counter, which is monotonic: a wall-clock step mid-request cannot produce a negative duration.
ServerTimingPolicy#
Record request duration and optionally report it as Server-Timing.
Global policy. The duration is always recorded on request state for elapsed(request); emit_header controls only whether it also goes on the wire. Emitting is opt-out rather than opt-in here, but the header is visible to anyone who can read the response, so turn it off where a timing signal is a side channel worth closing.
The emitted header is one metric in the RFC-registered Server-Timing format -- name;dur=milliseconds, with the duration to three decimal places. Milliseconds is what the format specifies; elapsed returns seconds.
The clock is time.perf_counter, which is monotonic, so a wall-clock step mid-request cannot produce a negative duration. Where this policy sits in the chain decides what the number covers -- a stage mounted outside it is not in the span.
A request whose timer never started, because a _ingress stage ahead of this one short-circuited it, is left untimed rather than reported as zero.
Args:
metric— Metric name, 1-64 characters of A-Za-z0-9_- and validated once here.emit_header— Send theServer-Timingheader as well as recording the duration.
Raises:
TypeError—metricis not a string oremit_headeris not a bool.ValueError—metricis empty, over 64 characters, or has other characters.
ServerTimingPolicy(*, metric: str = 'total', emit_header: bool = True) -> Nonedescribe#
describe() -> AnyServer-Timing, and only when this instance was told to emit it.
Built with emit_header=False the policy still times the request for request.state, but the client never sees anything -- so the contract is empty, not a header nobody sends.
elapsed#
elapsed(request: Request) -> floatSeconds spent on this request, as measured by ServerTimingPolicy.
The span runs from that policy's _ingress stage to its _egress stage, so it covers routing, authentication, every stage mounted inside it, the handler, and response construction -- but not the write to the socket, which has not happened yet. It is recorded whether or not the header is emitted, so an access log or a tracing exporter reads this one measurement instead of timing a span of its own.
Returns:
- Elapsed seconds as a float, from a monotonic clock.
Raises:
RuntimeError— The request was not timed.
wreath.policy.traffic#
Declarative traffic classes derived from Wreath client facts.
Classification runs after trusted proxy handling and before rate limiting. It establishes a bounded, application-declared name that existing rate-limit and Cedar policy can consume; it is not a second authorization engine. User-Agent, Client Hints, and GeoIP remain evidence rather than identity. Only a verified agent identity is suitable for a trust-sensitive class.
TrafficClass#
One conjunctive client-fact match and its ingress disposition.
Omitted fields are unconstrained; supplied fields are ANDed. Multiple values inside one field are alternatives. The first declaration matching a request wins, making precedence visible in the tuple handed to TrafficPolicy.
countries, browsers, claimed_agent and mobile are asserted or coarse lookup facts. They are useful for shaping traffic, rendering, and analytics, but must not be treated as authentication. verified_agent and agent_identities come from accepted Web Bot Auth signatures.
TrafficClass(name: str, countries: tuple[str, ...] = (), browsers: tuple[str, ...] = (), ip_versions: tuple[int, ...] = (), address_sources: tuple[str, ...] = (), agent_identities: tuple[str, ...] = (), claimed_agent: bool | None = None, verified_agent: bool | None = None, mobile: bool | None = None, deny: bool = False) -> Nonematches#
matches(facts: ClientFacts) -> boolWhether every declared condition holds for facts.
TrafficPolicy#
Classify each request once, optionally refusing selected classes.
The selected name is written to request state before rate limiting and authorization. traffic_class(request) reads it for a custom limiter tier/key, and Wreath's default Cedar context publishes it as context.client_class. A denied class returns one uniform 403 response; the response does not reveal which fact matched.
This component deliberately keeps no callback predicate. The complete matching vocabulary is validated at construction, and every class name is bounded by application configuration. A custom decision belongs in Cedar, which receives the selected class rather than a parallel policy language.
TrafficPolicy(provider: ClientFactsProvider, classes: tuple[TrafficClass, ...], *, default: str = 'unclassified') -> Nonecounters#
counters() -> AnyBounded aggregate readings for the existing metrics bridges.
traffic_class#
traffic_class(request: Any) -> strThe class selected for this request, or "unclassified".
This is the tier callback to hand an existing TieredRateLimitPolicy. It never resolves facts itself: TrafficPolicy owns that work and runs before rate limiting.
wreath.cache_control#
Typed HTTP cache-control policies.
CacheControl#
CacheControl(public: 'bool' = False, private: 'bool' = False, no_store: 'bool' = False, no_cache: 'bool' = False, no_transform: 'bool' = False, must_revalidate: 'bool' = False, proxy_revalidate: 'bool' = False, immutable: 'bool' = False, max_age: 'int | None' = None, shared_max_age: 'int | None' = None, stale_while_revalidate: 'int | None' = None, stale_if_error: 'int | None' = None)
CacheControl(public: bool = False, private: bool = False, no_store: bool = False, no_cache: bool = False, no_transform: bool = False, must_revalidate: bool = False, proxy_revalidate: bool = False, immutable: bool = False, max_age: int | None = None, shared_max_age: int | None = None, stale_while_revalidate: int | None = None, stale_if_error: int | None = None) -> Noneto_header#
to_header() -> bytesto_targeted_header#
to_targeted_header() -> bytesReturn this policy as an RFC 9213 Structured Dictionary.
PRIVATE_NO_STORE (value)#
PRIVATE_NO_STORE: CacheControl = CacheControl(public=False, private=True, no_store=True, no_cache=False, no_transform=False, must_revalidate=False, proxy_revalidate=False, immutable=False, max_age=None, shared_max_age=None, stale_while_revalidate=None, stale_if_error=None)