Changelog¶
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.7.0] - 2026-11-02¶
Added¶
- Passwords:
FastAPIPasswordshashes them with scrypt (OWASP's cost, a random salt, PHC's format, in a thread) and an optional pepper;verifylocks an account out aftermax_attemptswrong ones, in every process, and takes as long for an account that doesn't exist;needs_rehashfor stronger parameters later;checkrefuses short passwords, those with the user's e-mail or name, and, withPwnedPasswords(thepasswordsextra), those leaked, asking Have I Been Pwned without sending them. Reset tokens work once, expire, and a newer one replaces the last. Audited and counted (fastapi_alcazaba.passwords). - E-mail:
FastAPIMailbuilds messages that no field can add a header or a recipient to, withMessage-ID,DateandAuto-Submitted, andSMTPSendersends them over STARTTLS or implicit TLS, certificates checked (plain SMTP only to localhost).MailNotSentwhen one isn't sent, never with its body;MemoryMailSenderfor tests. Counted (fastapi_alcazaba.mail). - Object storage:
S3Presignersigns download URLs, upload URLs and upload forms bounded in size and type (Signature Version 4, query or POST policy) for S3, R2, MinIO and the like, without an SDK. Downloads can be attachments, so uploaded HTML never runs on the bucket's origin; object keys can't escape their prefix. - Broadcast:
FastAPIBroadcastsends messages to every process's subscribers of a channel, over Redis or Valkey's pub/sub, signed, bounded, with agapfor what a subscriber missed; in the process without a client. - The transactional outbox:
SQLAlchemyOutboxwrites a message in the transaction of the change it announces, andOutboxRelaydelivers it at least once, with retries and a backoff, giving up aftermax_attempts(infastapi_alcazaba.background.outbox, with thesqlalchemyextra). - Structured logs:
JSONFormatterandlogging_configwrite one line of JSON per record, with the request's ID, its trace and span, the exception, andextra=redacted as the audit trail redacts details; Uvicorn's access lines without the query. fastapi_alcazaba.audit.redacted, the audit trail's redaction for other uses.- These are experimental, as the scope and stability page says.
Fixed¶
- A test of the circuit breaker moves its clock by hand instead of sleeping, and no longer fails on a slow runner.
- The scheduled mutation testing copies
fuzz/,scripts/anddocs/, which the tests import.
[0.6.0] - 2026-11-02¶
Added¶
- Fuzz targets for WebAuthn's parsers (CBOR, authenticator data, base64url) and for the sessions as the backend holds them (a session's record, a user's index).
- Scope and stability: a page of what Alcazaba is for and leaves to others, and which tools are stable and which experimental (those new in 0.6), whose guides say so at the top.
- OIDCStrategy is tested against a real provider (Keycloak) when
OIDC_LIVE_*are set: the login through its form, a code used twice, a callback without the state cookie. - Passkeys:
FastAPIWebAuthn(with thecryptoextra) registers passkeys and security keys and logs in with them, as WebAuthn Level 2 says: challenges random, bound to the ceremony and the user, used once; the origin, the RP ID's hash, the user present and verified, the signature (ES256, EdDSA, RS256) and the counter (a cloned authenticator is refused). Logins without a username, by the user handle, or a second factor for a known user. CBOR and COSE are read by the library, strictly; attestation isn't checked (attestation: "none").Passkeyis what to store, a public key. Withaudit=, each ceremony is an event. An example app: a site without passwords, and each user's devices. - Each user's sessions:
UserSessions, given toServerSessionMiddleware(users=...), lists a user's sessions (when they started and were last seen, the browser, the address, which is the request's) and ends one, or all of them:end_allalso refuses every session of the user that started before, those its index missed too. At mostmax_sessionsper user. - Step-up is
fastapi_alcazaba.auth.stepup's (mark,verified_at,require_recent), whichFastAPITOTPandFastAPIWebAuthnshare: a TOTP code or a passkey. - A second factor:
FastAPITOTP(with thecryptoextra) checks the codes of authenticator apps (RFC 6238; SHA1, SHA256 or SHA512, 6 or 8 digits), each code once in every process, with a lockout aftermax_attemptswrong ones.enroll()gives theotpauth://URI and the secret sealed with AES-256-GCM for its user, to store instead of the secret.recovery_codes()anduse_recovery_code()handle codes for a lost phone, kept as HMACs and used once.mark()andrequire_recent()are step-up: a route that needs a second factor of the last minutes in this session, the same user's. Withaudit=, each code checked is an event. An example app: the password, then the app's code or a recovery code, and a recent one to delete the account. - Scopes:
FastAPIAuth.authenticate()checks the scopes ofSecurity(dependency, scopes=[...]), FastAPI's, which the OpenAPI schema shows, and ofscopes=. The strategy that found the user grants those of its credentials:JWTStrategythe token'sscopeandscp(scope_claims=for others),HTTPBearerStrategythose ofscopes_of(user), a strategy of your own withself.grant(connection, scopes); one missing is a 403 withWWW-Authenticate: Bearer error="insufficient_scope".granted_scopes(request)tells a route what was granted. - The audit trail of authentication and authorization:
FastAPIAuth(audit=)records failed authentications (auth.failure), OAuth2, OpenID Connect and SAML logins (auth.login) and missing scopes (auth.scope);FastAPIAuthz(audit=)andAuthzMiddleware(audit=)the requests their policy refuses (authz.denied) and those the engine couldn't decide (authz.unavailable).actor=tells who a user or subject is;subject_idby default. - OpenID Connect:
OIDCStrategy, with the newauth-strategy-oidcextra (httpx2 and PyJWT), logs people in with an OpenID Connect provider and verifies its ID token as OpenID Connect Core says: the signature with the provider's JWKS (asymmetric algorithms only, keys fetched again on rotation, at most every 30 seconds for unknownkids),iss,aud,azp,exp,iat, the login'snonce(sent and kept in the state cookie),at_hashand, withmax_age,auth_time.await discover()reads the issuer's discovery document, whose issuer must be the one configured;userinfo=Truereads the userinfo endpoint and refuses another subject's. An example app: a login with any OpenID Connect provider, then a session. - Unkey:
UnkeyStrategy, with the newauth-strategy-unkeyextra (httpx2), authenticates by API keys that Unkey keeps, verified on every request through its API (v2), with their permissions (Unkey's query syntax), credits (cost) and rate limits. A key that doesn't exist, is disabled or expired fails (401); one without the permissions gets a 403, one over its rate limit a 429 withRetry-After, one out of credits a 429 or a 402; Unkey down is a 503, never let through.UnkeyClientcalls it with a root key, a deadline and a circuit breaker;UnkeyKeysissues keys (credits, refills, rate limits, permissions, an external ID) and deletes them. An example app: keys made at sign-up. - Accounting:
fastapi_alcazaba.accounting.FastAPIAccountingmeters what each subject (a user, an API key, a tenant) uses of each meter per calendar hour, day or month (UTC), and holds it to itsQuota, a number or a function of the subject (its plan).meter(name)charges a request before the endpoint, checked and counted in one atomic operation on every backend; over the quota, a 429 withRetry-Afteror a 402, and theRateLimitheaders on every response.meter(name, amount=None)gives the endpoint aMeteringto add what it uses (tokens, bytes), charged when it ends. Server errors are refunded, client errors stay charged. Each charge is aUsageRecord, sent to sinks (thefastapi_alcazaba.accounting.usagelogger by default) for billing, also when the backend fails;usage()reads a subject's counter,record()charges what a job used. An example app: tenants with plans, metered searches and tokens. -
Cedar policies:
fastapi_alcazaba.authz.cedar.CedarEnforcer, with the newcedarextra (cedarpy), forFastAPIAuthzandAuthzMiddleware. Requests become Cedar's (principal, action, resource), each resource named by the route gets anidattribute forlike, andenforcetakes whole entities with their attributes (the user the authentication loaded, the row the route read) and Cedar'scontext. Policies are validated against a schema when one is given, and a policy that can't be evaluated refuses the request: Cedar alone skips it, so a failingforbidwould let the request through.enforceis a coroutine, andentities_ofmay be one too, for entities read from the database; an entity given twice with different contents raises ValueError instead of picking either. An example app: a team's documents, read and edited by their attributes. -
Open Policy Agent:
fastapi_alcazaba.authz.opa.OPAEnforcer, with the newopaextra (httpx2), asks an OPA that runs as a service of its own through its REST API, with the input{"subject", "resource", "action", "context"}. Only a result oftrueallows; an undefined rule refuses with a warning, and a result that isn't a boolean or a query OPA refuses (a 4xx) raise RuntimeError. Its own client gives each query a deadline (timeout, 1 second) and OPA a circuit breaker; a token (SecretStr) for--authentication=token. An example app: a shop whose orders and refunds a Rego policy decides, tested against a real OPA in CI. PolicyUnavailable: an enforcer raises it when its engine doesn't answer, and the request gets a 503 (Authorization unavailable), a WebSocket 1013 (try again later), counted asunavailablein thefastapi_alcazaba.authzmetric;authz.enforce()in a route raises an HTTPException 503. Any other exception of an enforcer is still a 500.
Changed¶
- A request is authenticated once: the dependencies that ask
FastAPIAuthfor the same strategies get the user the first one found, instead of running them again (an API key verified twice, Unkey's credits spent twice). OAuth2Strategyis built onAuthorizationCodeFlow, which it shares withOIDCStrategy; its docs say that it doesn't verify anid_token. A state cookie that isn't one the strategy set is refused instead of raising.FastAPICasbinisFastAPIAuthzandCasbinMiddlewareisAuthzMiddleware: they take any engine's enforcer, Casbin's or Cedar's. The authorization guide and examples are split into "In process" (Casbin, Cedar) and "As a separate service" (Open Policy Agent).FastAPIAuthzandAuthzMiddlewareawait anenforcethat is a coroutine function, for engines that answer through the network, andFastAPIAuthz.enforce,refusalandpath_refusalare coroutines:await authz.enforce(user, "drafts", "read")in a route.
[0.5.0] - 2026-10-31¶
Changed¶
- Pagination has seven strategies, one per way of paging, instead of twelve:
PaginationLimitOffsetis part ofPaginationOffset, whose pages have the total (and a link to the last page) whenpaginategetstotal=, andtotal: nullwithqueried=True.PaginationCursortakesPaginationKeyset's two directions andPaginationToken's binding to the query: onecursorparameter for the next page (next_cursor) and the one before (prev_cursor), signed (secretis required), bound to the request's other query parameters, and expiring withttl=;encode()makes the cursor of any position.PaginationKeysetis now the key in plain sight, which takesPaginationIdBased,PaginationTimeBasedandPaginationSearchAfter's place:?after=42for a key of one value (sort=int), a JSON array for several (sort=(datetime, int)), withafterandhas_moreon every page, the last one too, for clients that sync. The moduleslimit_offset,id_based,time_based,search_afterandpage_tokenare gone. ServerSessionMiddleware's cookie is__Host-sessionby default, which a sibling subdomain can't set; plain HTTP in development needssecure=Falseand anothercookie_name.CasbinMiddlewarerefuses requests without a user by default (anonymous=None); the subject ofstarlette_useris the user'sidentity, not itsdisplay_name, which users may choose; HEAD is checked as GET, the route it runs. The examples call visitors*anonymous, a name no user can have.JWTStrategyrefuses ablocklistwhoseleewayis shorter than its own: the revocation would expire while the token is still accepted.InMemoryBackendnever evicts live state (sessions, revocations, one-time codes, idempotency records...): each kind has its own store ofmax_sizelive entries, and a full one refuses new entries withBackendUnavailable, as Redis withnoeviction. A flood of idempotency keys used to evict revocations, and accept revoked tokens again.- The rate and concurrency limiters keep a budget per route template and endpoint, not per
root_path: underMount("/tenants/{tenant}"), each made-up tenant got a budget of its own. - The task queue gives each delivery a run id that only one entry may claim, and a deadline
(
replay_window, 7 days after it was due): a copy of a sealed task written back to the queue is refused asreplayed, and an older one asstale. Tasks queued by 0.4.0 aren't read. - Idempotency records are kept without
Set-Cookie: a replay never hands out a session. - A feature flag with a deny list is off for requests without a subject.
- The README presents the package and links to the guide, where the content of its sections moved; the security model says, tool by tool, what each one protects against and what it needs from the app.
Added¶
fastapi_alcazaba.pagination.sqlalchemy, with the newsqlalchemyextra:page_query()limits aselect()to the page of any strategy's parameters (the sort key compared as a row value, its order, backwards for a previous page, and the extra row),count_query()counts it, andsort_key()givespaginateitskey.- A Performance page with the benchmarks' numbers.
Fixed¶
- A session ended by a logout or a rotation came back when a request still running with its cookie saved it afterwards; the old id is now marked for the rest of its life.
FastAPIUploadscounted only the logical screen of a GIF: a 1x1 GIF with a 12000x12000 frame passedmax_pixels, and decoders allocated the whole frame. The frames' extents count now.- The README's login limiter keyed attempts on the username as typed: each spelling (
Alice@,alice@) got five more. It normalises the account and adds a limit per IP; the feature flags example took its subject from a header the client writes.
[0.4.0] - 2026-10-30¶
Added¶
fastapi_alcazaba.authz: authorization with Casbin, with the newcasbinextra (pip install "fastapi-alcazaba[casbin]", which installs PyCasbin).FastAPICasbinis a dependency that lets a request through only when the policy allows it:Depends(authz)asks about the route's path and method,Depends(authz.require("articles", "publish"))about your own objects and actions, with{name}taking a path parameter. Who makes the request comes from a dependency of your own (subject=), or from Starlette'sAuthenticationMiddleware. A request without a subject gets a 401 (or is checked asanonymous=), one that the policy refuses a 403; WebSockets are closed with 1008. Paths with control characters (%0A...) get a 400 without asking the policy, as Starlette routes/users/%0Ato/users/while the policy would read the newline. Decisions are counted in thefastapi_alcazaba.authzmetric.- Authentication by strategies:
FastAPIAuthinfastapi_alcazaba.auth.auth.use(strategy)adds one, andDepends(auth.authenticate("session", "jwt"))tries those named in order, returning the first user found: otherwise a 401 with their challenges (None withoptional=True). AStrategyreturns the user, None without its credentials, or raisesAuthenticationFailed; writing one takes a method. Strategies:SessionStrategy, the user id kept bylogin()in the session (rotated against session fixation);JWTStrategy, with the newauth-strategy-jwtextra (PyJWT): bearer or cookie tokens, HMAC secrets that rotate, public keys or an identity provider's JWKS (with theaudienceandissuerit requires), aTokenBlocklist, andissue(); tokens with anaudare refused when noaudienceis set;OAuth2Strategy, with the newauth-strategy-oauth2extra (httpx2): the authorization code flow with PKCE and a state cookie, for GitHub, Google, Keycloak or any OAuth2 provider;HTTPBearerStrategy, opaque bearer tokens that your function looks up (theauth-strategy-http-bearerextra installs nothing more);SAMLStrategy, with the newauth-strategy-samlextra (signxml): SAML 2.0 single sign-on started by the app, with the XML signature verified against the IdP's certificate and the issuer, audience, recipient, request and validity checked on what it covers, each assertion accepted once, and the app's metadata. Outcomes are counted in thefastapi_alcazaba.authmetric. CasbinMiddleware, which checks every request with the user of Starlette'sAuthenticationMiddleware:{"detail": ...}errors (problem details withFastAPIProblemDetails), WebSockets checked,OPTIONSrequests checked like the others, and the path the app routes, without itsroot_path.- Nine pagination strategies, each in a module of
fastapi_alcazaba.paginationwith its parameters and its page, linked to the pages around it and cached page by page: PaginationOffset(offset):?offset=40&limit=20without the total;has_morecomes from one row more than the page (params.fetch), so the list is never counted.PaginationRange(ranges):?start=0&end=24, both ends included, withContent-Range: items 0-24/319, also on pages served from the cache. A backwards or oversized range is a 400, a start past the end a 416 withitems */<total>.PaginationKeyset(keyset): keyset pagination in both directions,?after=and?before=, with keys that can be signed; both at once is a 400.PaginationIdBased(id_based):?last_id=42&limit=20, the items after the last id seen, oldest or newest first; ids areint,strorUUID, sent as they are.PaginationTimeBased(time_based): items older or newer than an ISO 8601 timestamp with its time zone, for feeds and syncing; a page never splits the items that share a time.PaginationSearchAfter(search_after): the sort values of the last item, a JSON array that clients can read, converted to the types declared insort=(...); wrong values are a 400.PaginationToken(page_token):?page_size=20&page_token=..., opaque tokens signed with HMAC-SHA256, bound to the request's other query parameters and expiring afterttl.PaginationScroll(scroll): a snapshot of the results kept in the cache's backend and read in batches withscroll_id, whatever is written meanwhile; retries get the same batch, each batch renews it,clear()drops it, and anownerkeeps one client's scroll from another.PaginationStreaming(streaming): every item as NDJSON in chunks ofbatch_size, from sync or async sources, up tomax_items;batches(fetch, key=...)reads a big table by key a batch at a time.
Changed¶
- Pagination: each strategy is in a module of its own, with its parameters and page, and
fastapi_alcazaba.paginationkeeps what they share (PaginationParams,LinkedPage,PageLinks,Position);fastapi_alcazabano longer exports them. The strategies are named after what they read, and so are their query parameters:FastAPIPaginationispagination.page_number.PaginationPageNumber(page,page_size, which wassize;PageandPageParamsarePageNumberPageandPageNumberParams, withpage_size),FastAPIPaginationLimitOffsetispagination.limit_offset.PaginationLimitOffset, andFastAPIPaginationCursorispagination.cursor.PaginationCursor(cursorandlimit, which wassize;CursorPage.sizeislimit, and the rows to query areCursorParams.fetch, which waslimit).fastapi_alcazaba.pagination.paginatorsis gone. FastAPIAuth.use()returns the strategy with its own type, so thatgithub.redirect()orsessions.login()type-check.- The documentation is organised like FastAPI's, in its tabs: Features, Learn, Reference, Resources,
About and Release Notes. Learn holds the Tutorial - User Guide, which starts at
/guide/with a quick start (an app built in three steps, a cache, a rate limit and authorization, each run and checked) and goes on with a page per tool and the production setup, and the examples. The home page follows FastAPI's: the key features, an example to create, run and check, and the optional dependencies. Features tells what sets Alcazaba apart and lists the tools, and About its design, license and credits. Authentication strategies open in the menu on a page per strategy, in the guide and in the examples, where each strategy has an app of its own (JWTs, GitHub logins, sessions, bearer tokens, SAML with Mock SAML, API keys), tested like the others. Every example starts with an animation of what its tool is for, a sequence diagram whose steps appear one by one, in the theme's colours;scripts/docs_animations.pydraws them.
Fixed¶
@cacheon an endpoint that returns aStreamingResponselogged a warning with a traceback on every request, trying to encode the stream: streams go through uncached.
[0.3.1] - 2026-10-27¶
0.3.0's release was cancelled before it reached PyPI: 0.3.1 is the first release with the changes of 0.3.0, below.
Changed¶
- Copying a terminal session of the documentation copies its commands alone, ready to paste into a shell: no prompts, no output, no blank lines. The commands of several lines are written so that they run as they're copied.
[0.3.0] - 2026-10-27¶
Added¶
-
Fuzz targets (
fuzz/) for what reads data from outside: the type and size sniffed from uploads and the safe file names, compressed entries, pagination cursors, signed webhooks and audit details, each checking the properties it must keep. ClusterFuzzLite runs them with atheris on pull requests and every week; the unit tests run them on random inputs. -
fastapi_alcazaba.core.canonical_json: the JSON Canonicalization Scheme (RFC 8785), the one serialization of a JSON value that every language implementing it writes the same. - Paginated responses link to the pages around them in the
Linkheader (RFC 8288): first, prev, next and last by page number or by limit and offset, first and next by cursor. The links keep the request's other query parameters and are relative to the host. Pages served by@cacheget them too, made for the request they answer and never stored with the entry. The page parameters carry them aslinks(aPageLinks), left out of their repr and so of cache keys.
Changed¶
-
A folder per tool, each exporting its public names (
from fastapi_alcazaba.pagination import Page); the main class of each tool is still importable fromfastapi_alcazaba. The modules moved, with nothing left at their old paths:response_cache:decorator(cache),caches(wascache:Cache,FastAPICache,get_cache),coder,key_builder(andKeyBuilder, fromtypes),compression;backends:Backend, fromtypes;rate_limit:limiter,concurrency;auth:apikeys,tokens;security:sessions,csrf, andmiddleware'sSecurityHeadersMiddlewareandBodySizeLimitMiddleware;observability:problems,request_id,timeouts,health;background:scheduler,tasks,leader,locks;operations:breaker,flags,maintenance;outbound: the transports ofoutbound;core:errors,integrity,encryption,canonical,resilience;telemetry:metrics,tracing;pagination,idempotency,audit,uploadsandwebhookskeep their names, now folders.
Loggers are named after the modules where they are (
fastapi_alcazaba.observability.timeouts); metrics, spans and thefastapi_alcazaba.audit.eventslogger keep their names. - Cached responses report the result in theCache-Statusheader of RFC 9211 instead ofX-FastAPI-Cache:Alcazaba; hit; ttl=42with the seconds the entry has left, or why the request reached the endpoint (fwd=miss,stalefor an early refresh,requestfor a client'sno-cache,method,bypasswith the cache off),storedwhen its response was kept andcollapsedwhen it waited for another request's. Responses that weren't cached get the header too.FastAPICache.init(cache_status_header=...)is nowcache_status=, the cache's name (Noneleaves the header out), andget_cache_status_header()isget_cache_status(). - The audit trail's signatures cover the event's RFC 8785 canonical JSON, after the contextfastapi_alcazaba/audit2and a NUL byte, so that any language can check them; events signed by 0.2.0 no longer verify. Details keep to I-JSON for it: integers beyond 2⁵³ are recorded as text, and lone surrogates in any text of an event as U+FFFD. - The API reference has a page per tool (cache, backends, limits, audit...) with an index of them, instead of one long page; each object is set apart, with its kind and its signature. - So does the guide: a Guide tab starts with getting started and has a page per tool, and the documentation's home page introduces the tools instead of holding the whole README. - The documentation has a site per version, with a selector in the header:devfor what's on main andX.Yfor each release from 0.3, the newest beinglatest, where the site's address leads. The other versions say they're not the latest.
Fixed¶
FastAPIPaginationCursor.decode()raised a TypeError, instead of the 400 of an invalid cursor, for a signed cursor with non-ASCII characters (the route's own parameter never lets one in).verify_audit_trail()raised a TypeError for an event whose signature had non-ASCII characters, instead of reporting it as altered.safe_filename()with amax_lengthunder 64 bytes could give a longer name when the extension was short in characters but long in bytes (emoji): such an extension is now dropped.
[0.2.0] - 2026-10-26¶
Added¶
- Rate limiting:
FastAPIRateLimiter(fastapi_alcazaba.limiter), a FastAPI dependency that lets each client maketimesrequests per sliding window to a route, with a 429 andRetry-Afterbeyond. The counters live in the cache's backend, shared by every process, behind its timeout and circuit breaker (fail_open). Clients are told apart by the address the ASGI server reports, not byX-Forwarded-For(which any client can set), and IPv6 addresses by their /64 network (client_networkfor other sizes); the budget is per route, not per URL (/items/1and/items/2share one); requests over the limit don't count, checked and counted atomically.nameshares a budget across routes,costmakes a route take more of it,identifiercounts per user, andhit()limits anything else (WebSocket messages, login attempts). Responses carry theRateLimitandRateLimit-Policyheaders of the IETF draft. Each process refuses a client with nothing left without asking the backend until something fits again. - Counters in every backend, for the rate limiter:
Backend.incr,Backend.get_counter,Backend.incr_window(a Lua script on Redis, Valkey and GLIDE, a conditional write on DynamoDB) andBackend.supports_counters. - The
fastapi_alcazaba.rate_limitmetric. get_cacheworks in WebSocket routes.- Pagination without another library (
fastapi_alcazaba.pagination):FastAPIPagination(page and size),FastAPIPaginationLimitOffsetandFastAPIPaginationCursor, each configured once with its limits and used as a dependency that checks the query parameters, and with apaginatefor a whole list or a page already queried. Cursors are keyset pagination on a unique sort key that keeps its types (datetimes, UUIDs, decimals…), optionally signed (HMAC-SHA256, rotation), with a 400 for invalid ones. The page parameters are arguments of the endpoint, so@cachekeeps each page under its own key. An example (examples/pagination): a catalogue cached page by page, whole, or by cursor. - Locks shared by every process:
Cache.lock()/FastAPICache.lock()(fastapi_alcazaba.locks), with an expiry, renewal while held, a random token per holder, waiting (wait) andLockNotAcquired.InMemoryBackend(within the process) andDynamoBackend(conditional writes) can now hold locks, and backends gainextend_lock. - Idempotency keys:
IdempotencyMiddlewareandrequire_idempotency_key(fastapi_alcazaba.idempotency, draft-ietf-httpapi-idempotency-key-header). A retried POST or PATCH gets the first response (Idempotent-Replayed: true) and runs once; a retry while it runs gets a 409, the same key for another request a 422. Keys are per client (Authorizationby default), responses are stored signed or encrypted, and a failing backend answers 503 unlessfail_open=True. - Revoked tokens and one-time codes (
fastapi_alcazaba.tokens):TokenBlocklistkeeps revoked JWTs (revoke, byjti) and subjects logged out everywhere (revoke_subject) in the cache's backend until the tokens would have expired, and refuses them when the backend fails unlessfail_open=True.OneTimeCodesissues e-mail or SMS codes, stored as HMACs with a secret, with an expiry, a limit of guesses and single use that holds under concurrent requests. - Outbound calls:
ResilientTransport(fastapi_alcazaba.outbound), an httpx2/httpx transport that gives each call a deadline for the whole response (httpx's timeouts are per phase, so a server that trickles bytes kept a request open forever) and a circuit breaker per origin (CircuitOpenError, anhttpx.TransportError). 5xx responses count as failures (failure_statuses), the caller's own errors (an illegal header, an unsupported scheme) don't.max_response_sizecaps the bytes read (ResponseTooLargeError). SecurityHeadersMiddlewareandBodySizeLimitMiddleware(fastapi_alcazaba.middleware): the OWASP REST security headers on every response (HSTS only over HTTPS, a strict CSP for everything but HTML), and a 413 for request bodies over a size limit, checked fromContent-Lengthand counted for chunked bodies.- Health checks:
health_router()(fastapi_alcazaba.health) adds/health/live(the process answers) and/health/ready(the app's checks run concurrently with timeouts: 200okordegraded, 503fail), with a non-critical check of the cache's backend. Errors are logged, never returned, and concurrent probes share one run, reused forreuse_forseconds. - Scheduled jobs:
FastAPIScheduler(fastapi_alcazaba.scheduler) runs async or sync jobs every N seconds (every) or by a cron expression in a time zone (cron, with its own parser: ranges, steps, names,@daily…; clock changes handled as cronie does), in every process and each tick once across them: ticks are claimed with an atomic counter in the backend, and a lock held while a job runs keeps long runs from overlapping elsewhere (exclusive=Falseruns a job everywhere). Errors are logged and counted without stopping the schedule, a failing backend skips the tick unlessfail_open=True, andlifespan/async withstart and stop it with agraceperiod for running jobs. - Circuit breakers:
FastAPIBreaker(fastapi_alcazaba.breaker) for any dependency, as a decorator of async or sync functions or around a block (attempt()), with atimeoutfor async calls,failuresandexcludeto say what counts, andBreakerOpen, anHTTPExceptionthat FastAPI answers with a 503 andRetry-After. - Problem details (
fastapi_alcazaba.problems):FastAPIProblemDetails().install(app)answers HTTPExceptions, validation errors and unhandled exceptions with RFC 9457application/problem+json(type,title,status,detail,instanceand therequest_idofRequestIDMiddleware), keeping the exceptions' headers (Retry-After,WWW-Authenticate…). 5xx details are never sent (a hidden HTTPException detail is logged); 4xx ones are unlessexpose_detail=False; validation errors listloc,msgandtype, never FastAPI'sinput(which echoes passwords) norctx, at mostmax_errors. Unhandled exceptions get a bare 500 and are still raised again for the server to log.Problemis an HTTPException with a type URI, a title and checked extension members;problems.response()answers one from your own exception handlers. - Request IDs (
fastapi_alcazaba.request_id):RequestIDMiddleware, a pure ASGI middleware that gives every HTTP request and WebSocket an ID, reusing the incomingX-Request-IDonly when it's a single value safe to log ([A-Za-z0-9._:-]{1,128},trust_incoming=Falseto never reuse it) and making a random 128-bit one otherwise, and sends it back in the response.request_id()returns it while the request is handled (sync endpoints' threads included), and it stays set after an unhandled exception so that the 500 handler and the server's error log still know the request.RequestIDLogFilterputs it in log records as%(request_id)s. - Request timeouts (
fastapi_alcazaba.timeouts):RequestTimeoutMiddleware, a pure ASGI middleware that gives every HTTP request a deadline (timeout=30seconds, other ones by path prefix withpaths, None for none) from its arrival until its response has been sent, reading the body included. At the deadline the app is cancelled (itsfinallyblocks andyielddependencies' exits run) and the client gets a 504; a response already streaming is cut by raising the TimeoutError again, so that the server closes the connection instead of ending a short body as if it were complete. Background tasks after the response have no deadline; WebSockets and lifespan events go through untouched. - Concurrency limits:
FastAPIConcurrencyLimiter(fastapi_alcazaba.concurrency), a FastAPI dependency (withyield) that lets at mostlimitrequests run at the same time across every process, per route, pernameshared by routes, or per client (identifier, answered with a 429 instead of a 503), withRetry-After. The semaphore islimit(up to 100) renewed locks in the cache's backend, tried in random order and expiring if their holder dies; requests wait up towaitseconds with jittered backoff. The slot is held until the response has been sent (the whole body of aStreamingResponsesince FastAPI 0.118;scope="function"releases it earlier), and while a WebSocket is open.slot()takes one outside requests. When the backend fails, each process enforces the limit on its own (fail_open=Falserefuses with a 503). - Leader election:
FastAPILeader(fastapi_alcazaba.leader) elects one process among those sharing the cache's backend, on a renewed lock, and runs the async functions given torun(a decorator) while it leads: they're cancelled when the leadership is lost or the leader stops, and the process campaigns again.start()/stop(),lifespanandasync with;is_leaderworks from sync code too. A stopping leader releases the lock for a quick hand-over; a dead one is replaced withinttland a third. Nobody is elected while the backend fails. - Maintenance mode:
FastAPIMaintenance(fastapi_alcazaba.maintenance) withenable(message, retry_after),disable()andstatus(), shared by every process through the cache's backend, andMaintenanceMiddleware, a pure ASGI middleware that answers 503 withRetry-After(and closes WebSockets with 1013) while it's on, except forallow_pathsand the requestsallowlets through. Each process keeps a copy of the state refreshed at most everyrefreshseconds, so requests don't reach the backend; when the backend fails, the last state read stays. The state is sealed with the cache's signing or encryption key when it has one. - Feature flags:
FastAPIFeatureFlags(fastapi_alcazaba.flags) stores flags in the cache's backend:set(name, enabled=..., percentage=..., allow=..., deny=...),delete,get,enabled(name, subject, default)andrequire(name, subject=...), a FastAPI dependency that answers 404 while the flag is off, without revealing it. Percentage rollouts are sticky (a hash of the flag and the subject) and only grow with the percentage. Without a percentage a flag is for everybody, or only for its allow list when it has one. Each process keeps definitions forrefreshseconds and falls back on the last one read, or the default, when the backend fails. Subjects are stored as salted SHA-256 digests, never in clear, and definitions are sealed with the cache's signing or encryption key when it has one. - API keys (
fastapi_alcazaba.apikeys):FastAPIAPIKeys(lookup), a dependency that authenticates machine clients by keys likeak_<id>_<secret>(80-bit id, 256-bit secret), read fromX-API-Keyor, withbearer=True,Authorization: Bearer, both declared in the OpenAPI schema.generate()returns the key, to show once, and anAPIKeyRecordto store: a SHA-256 digest of the secret bound to its id, an HMAC with an optionalpepper(rotation), scopes and an expiry. The app's ownlookup(key_id)(async or sync, in a thread) returns its row, which the dependency returns after a constant-time comparison and the expiry check; every failure gets the same 401 withWWW-Authenticate, andrequire(*scopes)a 403. Keys are never logged. An optional per-process cache of records (cache_ttl,cache_size,forget()) trades revocation latency for fewer lookups. - Signed webhooks (
fastapi_alcazaba.webhooks), compatible with Standard Webhooks (Svix, Supabase, Clerk, Resend...):WebhookSignersigns outgoing ones (whsec_secrets, a randomwebhook-id, every secret of a rotation), andFastAPIWebhooks, a dependency, verifies incoming ones over the raw body, which the endpoint can still read (capped bymax_body_size, 413 beyond), with a timestamptoleranceboth ways, constant-time comparison against every secret, and replay protection: each accepted delivery is counted in the cache's backend (hashed, understate_key) until it can't be accepted anymore, while the sender's retries (new timestamp and signature) still go through. Wrong signatures, stale timestamps and replays get the same 401; a failing backend a 503 (fail_opento accept instead).verify()checks a message without the replay check. - SSRF protection for outbound calls:
SSRFGuardTransport(fastapi_alcazaba.outbound), an httpx transport that resolves the host itself and refuses the request (BlockedDestinationError, anhttpx.RequestErrorthatResilientTransportdoesn't count against the origin) when any of its addresses isn't public: private, loopback, link-local and cloud metadata, carrier-grade NAT, multicast, reserved and documentation ranges, unique-local IPv6, and IPv4-mapped, NAT64, 6to4 and Teredo forms. Literal addresses and numeric spellings are caught too. The connection is pinned to the checked address (URL rewritten to it,Hostkept, SNI and certificate checked against the name), so DNS rebinding can't swap it; every redirect hop is checked; one address of the other family is tried if the first can't connect.allowlets internal names (*.for subdomains) and networks through,portsrestricts ports, each host name gets its own connection pool (max_hosts), and proxied inner transports are refused. Composes asResilientTransport(SSRFGuardTransport()). - CSRF protection:
CSRFMiddleware(fastapi_alcazaba.csrf), a plain ASGI middleware that refuses with a 403 the unsafe requests a page on another site made the browser send with the user's cookies or HTTP authentication. It trustsSec-Fetch-Site(same-origin passes, same-site and cross-site only fromtrusted_origins), and else requires anOriginorRefererof the app's own origin (scheme andHostas the server sees them) or a trusted one;Origin: nulland requests with neither are refused. Requests without cookies go through (token APIs aren't exposed), except form submissions from other sites (login CSRF);check_allchecks everything.exempt_pathsskips prefixes.require_tokenadds a double-submit token (a__Host-cookie compared in constant time with a header, also inrequest.state.csrf_token). WebSocket handshakes are checked too, against cross-site WebSocket hijacking. - Server-side sessions:
ServerSessionMiddleware(fastapi_alcazaba.sessions), a plain ASGI middleware that keeps each browser's session in the cache's backend and only a random 256-bit id in the cookie (HttpOnly,Secure,SameSite=Lax), so that, unlike Starlette's signed-cookie sessions, the client can't read it and logging out revokes it.request.sessionworks as usual. The data is stored under a digest of the id, sealed with the cache's signing or encryption key, saved only when it changed (nested changes too), JSON-only with a size limit, and expires aftermax_ageandidle_timeout. Ids are always generated by the server;rotate_session(at login, against session fixation) andclear_session(at logout) are included. A backend that can't be read gives an empty session; a change that can't be saved gives a 503. - Upload checks with nothing but the standard library (
fastapi_alcazaba.uploads):FastAPIUploads(types, max_size=..., max_pixels=...), a dependency (orcheck(file)) that reads the upload in chunks, counting its size whateverUploadFile.sizesays (413), detects its real type from its bytes (415 when it isn't allowed), refuses images with more thanmax_pixelspixels (decompression bombs, 413) and returns aCheckedFileread back to its start, with the detected type, size, dimensions, extension and a safe name.sniff(data)tells PNG, JPEG, GIF, WebP, PDF, ZIP, GZIP, MP4, QuickTime, UTF-8 text, and Office documents (from the content types the archive declares, macro-enabled ones apart) from each other, and keeps HTML, SVG and XML apart from text; SVG is never an image.image_size(data)reads image dimensions from their headers, andsafe_filename(name)makes a client's file name safe to display and to use on any file system. - Task queue:
FastAPITaskQueue(fastapi_alcazaba.tasks), on Redis 6.2+ or Valkey streams (clusters too).@queue.task()registers async or sync functions;await queue.enqueue(task, *args, delay=...)queues a run with JSON arguments checked against the function; the processes thatstart()it (or uselifespan) run them,concurrencyat a time. Delivery is at least once: tasks are acknowledged when they finish, those of a worker that stopped answering are taken over aftervisibility_timeout(heartbeats keep long ones), and one handed out more thanmax_deliveriestimes is dead-lettered. Failures are retried with exponential backoff and jitter, then dead-lettered with the type of their error only (dead_letters(),requeue_dead_letters(),discard_dead_letters()). Only registered tasks run, never pickle; with the cache's signing or encryption key every task is sealed and bound to its queue, so planted or altered ones are refused and dead-lettered, never run. Delayed tasks move to the stream atomically, the queue is bounded (QueueFull) and payloads are size-capped.stop()gives running tasksgraceseconds and puts back those it cancels. - Telemetry for the tools: each run of a scheduled job, and of a task (linked to the span that
enqueued it), is the root span of a trace of its own; rate-limit, breaker, outbound-breaker and
CSRF refusals and breaker transitions are events on the request's span, and idempotency
outcomes an attribute of it. New metrics:
fastapi_alcazaba.breaker.calls,.breaker.transitions,.scheduler.runs,.idempotency,.tokens.checks,.otp.verifications,.lock.wait,.lock.lost,.health.checks,.health.check.duration,.concurrency,.leader.transitions,.maintenance.refused,.flags.evaluations,.problems,.request_timeouts,.request_ids,.outbound.breaker,.outbound.ssrf.blocked,.webhooks,.api_keys,.csrf.refused,.sessions,.uploads,.uploads.size,.tasks.enqueued,.tasks,.tasks.durationand.tasks.reclaimed. The README shows the recommended setup (OpenTelemetry's zero-code instrumentation and its standard variables): Alcazaba only uses the API. - Locks, rate-limit counters, revoked tokens, one-time codes and idempotency records live under
{prefix}!(Cache.state_key), apart from the cached responses:clear()never frees a held lock, resets a limit or takes a revocation back, andInMemoryBackendgives them a store of their own that caching responses never evicts. Backend.invalidate_tags_with_keys: an invalidation that also tells which keys it deleted.- Examples for the tools (
examples/): small, complete apps for the response cache, pagination, rate and concurrency limits, idempotency keys, API keys and one-time codes, a browser app with sessions and CSRF protection, uploads, webhooks, errors and observability, background work and operations. Each is tested with the library and has a page in the documentation's new Examples section, with how to run it and what it answers. - A logo and a social card, and the documentation's theme in the project's colours.
RedisBackend.clientandValkeyBackend.client: the client the backend talks through.- pylint, configured in
pyproject.toml, runs with ruff and mypy inmake lint, pre-commit and CI. FastAPIBreaker.reset()closes a breaker by hand (the dependency is back, or between tests);failure_thresholdandreset_timeoutcan be read back.forget()onFastAPIMaintenance,FastAPIFeatureFlagsandFastAPIRateLimiterdrops what each process keeps (its copies of the state, the clients it refuses without asking the backend). A new cache drops it by itself, as each test's lifespan sets one up.SSRFGuardTransport.check(url): checks a URL as a request to it would be, without sending anything, to refuse a webhook's endpoint when it's registered.- With
FastAPIProblemDetailsinstalled, Alcazaba's middlewares answer with problems too (the 504 of a timeout, the 413 of a body too large, the 503 of maintenance, the 403 of CSRF...), and the error responses keep the rate limiters'RateLimitheaders. scripts/check_signatures.py, inmake lint,make format, pre-commit and CI: a signature that doesn't fit on one line has one parameter per line, the last one with a comma.- An audit trail (
FastAPIAudit,fastapi_alcazaba.audit): who did what to which resource, whether it worked, and the request it came from.audit.action()is a dependency that writes the event when the endpoint ends (success,deniedfor a 401 or a 403,failurewith the exception's type and status, never its message), the refusals of the authentication included;audit.log()andaudit.record()work outside endpoints. Events carry the request ID, the trace, the route template (never the path) and the client's address; their details keep no secrets (values under keys such aspasswordortoken, and credentials, are redacted) and are bounded. They go to thefastapi_alcazaba.audit.eventslogger as JSON lines, which warns when it outputs nothing, or to sinks of your own. With asigning_keythey're signed and chained, andverify_audit_trail()finds the ones altered, forged or removed. A sink that fails is logged with the event and counted, and an event is written even if its request is cancelled. Thefastapi_alcazaba.auditandfastapi_alcazaba.audit.undeliveredmetrics, and an example. - Every parameter that takes a key or a secret (
signing_key,encryption_key,pepper, the secrets of one-time codes, cursors and webhooks...) also takes pydantic'sSecretStrorSecretBytes, as pydantic-settings loads them, so the value stays out of the settings' repr until it's used. The examples read their configuration with pydantic-settings, from the environment or a.envfile.
Changed¶
CircuitBreaker.allow()returns the state the call goes through in ("closed", or"half-open"for the single trial) or None when it's refused, instead of a bool; truthiness is the same.- Development, CI and releases use Python's own
venvand pip (26.2 or later) instead of uv.make depssets up.venv; the dependency groups ofpyproject.tomlinstall with pip's--group; pip's--uploaded-prior-to P3Dkeeps releases younger than three days out; the SBOM comes from pip's installation report, with the hashes of the files. There's no lock file: CI installs the latest releases that are at least three days old. - A backend asked for something it can't do (locks, tags, counters) raises a NotImplementedError
that says so, e.g.
MemcachedBackend can't hold locks, instead of one without a message.
Fixed¶
TieredBackend: a tag invalidation didn't reach the copies of processes that had only read the entry (they never saw its tags), not even in the process that invalidated, so they kept serving it for up tolocal_ttl. Invalidations now name the keys they deleted.TieredBackend: a value read from the shared backend, or written to it, while an invalidation arrived could be kept as the local copy although it was already the old one.- The circuit breaker let a second trial through while the first one ran, when a call that wasn't the trial was cancelled.
TieredBackendwith a client that has asocket_timeout: the subscription to the invalidation channel ended every time the channel was quiet for that long, emptying the local level and logging a warning each time. The channel is now read with its own timeout and pinged when quiet; a connection that died silently is given up within 10 seconds, and a reconnection made by the client itself empties the local level (messages may have been missed).DynamoBackend.clear(key=...): of several deletes of one entry at the same time, more than one could be told that it was there. The delete is now conditional, so exactly one is, and a one-time code works only once on DynamoDB too.Lock.release()(and leavingasync with cache.lock(...)) could wait forever when the client swallowed the cancellation of a renewal under way (fakeredis does on Python 3.11): the renewals now stop as soon as the lock is released.
[0.1.0] - 2026-10-24¶
First release of Alcazaba.
Security¶
- Per-user cache entries for requests with
AuthorizationorCookie(vary_headers), sent asCache-Control: privatewithVary: one user's response is never served to another. RedisBackend.clear(namespace=...)no longer builds a Lua script from the namespace (command injection) nor runsKEYS(blocks the server):SCAN+UNLINK, with glob characters escaped.- Optional HMAC-SHA256 signing of entries, bound to their key (
signing_key, with rotation).PickleCoderwithout it raises anUnsignedEntriesWarning. InMemoryBackendis bounded (max_size, LRU) and no longer shared between instances.- Clients can no longer bypass the cache with
Cache-Controlunlessrespect_cache_control=True. - Only 200 responses without
no-storeare cached. - JSON type markers can no longer be triggered by user data.
- SHA-256 instead of MD5 for keys and ETags.
- Cache stampede protection (
single_flight). - The Redis example no longer serves its working directory as static files.
UnstableCacheKeyWarningwhen the default key includes an argument whoserepr()changes on every call (a database session, an injected service), which made every request a miss.- Backend calls have a timeout (
backend_timeout) and go through a circuit breaker (circuit_breaker_failures,circuit_breaker_reset): a hanging backend no longer slows every request down. max_value_sizekeeps oversized values out of the backend.- Optional encryption of the entries (
encryption_key,cryptoextra): AES-256-GCM with a per-entry key derived by HKDF and the cache key as associated data, with key rotation.
Added¶
ValkeyBackend(valkey-py) andGlideBackend(Valkey GLIDE), standalone and cluster.- Invalidation by tag:
@cache(tags=...)andFastAPICache.invalidate_tags()(in-memory, Redis, Valkey, GLIDE). distributed_lock: one process computes a missing value while the others wait for it.early_refresh: probabilistic refresh of entries near their expiry; a failed refresh serves the cached value.- OpenTelemetry metrics (
otelextra): lookups, stores, entry sizes, backend durations and errors; and traces: a span per cached call with its result, and one per backend call. Cacheinstances (one per app, or several with different settings) with their owncached()decorator,get_or_set()for caching inside endpoints,invalidate_tags(),clear()andclose(), and theget_cacheFastAPI dependency.FastAPICacheis now a globalCache.TieredBackend: a per-process memory level in front of any backend, kept in step across processes by pub/sub invalidations (Redis, Valkey) or bounded bylocal_ttl.GlideBackendreads TTL and value in one round trip (a batch) instead of two.- Compression of large entries (
compression="zstd"or"zlib"), read back whatever the setting and bounded against decompression bombs. JsonCoderencodes through pydantic-core and validates hits straight from the bytes: 8 times faster encoding of models, 1.4 times faster hits.exclude_params;stale_while_revalidate;MemcachedBackendandDynamoBackendcan delete single keys;DynamoBackend(endpoint_url=...);cache_status_header=""turns the header off.
Fixed¶
- Cache hits return the annotated return type (Pydantic models, dataclasses).
- Deterministic, quoted ETags;
If-None-Matchlists, weak tags and*. - The response model appears in OpenAPI.
- No
Cache-Control: max-age=None; headers also reachResponses returned by the endpoint. FastAPICache.clear(key=...)with a prefix no longer clears the whole prefix.- Entries that can't be decoded are recomputed instead of failing.
- Importing works without jinja2 (Starlette 1.x).
- In-memory entries without
expireno longer expire immediately; memcached expirations over 30 days; the settings of one call no longer leak into the next.
Changed¶
- Distribution
fastapi-alcazaba, import packagefastapi_alcazaba. Python 3.11+ (3.10 reached its end of life in October 2026), Pydantic v2. redisis optional;uvicornandpendulumare no longer dependencies.