Middleware
Middleware processes every request before it reaches a view and every response before it’s sent to the client.
Built-in middleware
Section titled “Built-in middleware”These are the default MIDDLEWARE, listed outermost first — the entry at the
top sees a request before every entry below it, and touches the response last:
MIDDLEWARE = [ "buraq.middleware.security.SecurityMiddleware", "buraq.middleware.cors.CORSMiddleware", "buraq.contrib.sessions.middleware.SessionMiddleware", "buraq.contrib.auth.middleware.AuthenticationMiddleware", "buraq.middleware.csrf.CsrfViewMiddleware", "buraq.middleware.gzip.GZipMiddleware",]| Middleware | Purpose |
|---|---|
| Security | X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS, ALLOWED_HOSTS |
| CORS | Cross-Origin Resource Sharing headers |
| Session | Cookie-based sessions |
| Authentication | Reads the session, sets request.user (User or AnonymousUser) |
| CSRF | Rejects an unsafe request without a valid token; @csrf_exempt opts a view out |
| GZip | Response compression (see the note below) |
Order matters: AuthenticationMiddleware reads request.session, so it must be
listed below SessionMiddleware.
— a JSON-only API has no use for cookie sessions, and dropping both session and authentication lines is the way to say so.
CORS settings
Section titled “CORS settings”CORS_ORIGINS = ["https://myfrontend.com", "https://admin.mysite.com"]CORS_ALLOW_CREDENTIALS = TrueCORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]CORS_ALLOW_HEADERS = ["*"]Custom middleware
Section titled “Custom middleware”from starlette.middleware.base import BaseHTTPMiddlewarefrom starlette.requests import Request
class RequestTimingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): import time start = time.perf_counter() response = await call_next(request) duration = time.perf_counter() - start response.headers["X-Response-Time"] = f"{duration:.3f}s" return responseRegister in your app:
from myapp.middleware import RequestTimingMiddleware
app = Buraq(settings_module="config.settings")app.add_middleware(RequestTimingMiddleware)Middleware order
Section titled “Middleware order”Middleware is applied in reverse registration order — the last one registered is the outermost layer (first to process the request, last to process the response).
AuthenticationMiddleware depends on SessionMiddleware being present, so register AuthenticationMiddleware after SessionMiddleware:
from buraq.contrib.auth.middleware import AuthenticationMiddlewarefrom buraq.contrib.sessions import SessionMiddlewarefrom buraq.conf import settings
app.add_middleware(AuthenticationMiddleware) # registered second → runs firstapp.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)CommonMiddleware
Section titled “CommonMiddleware”CommonMiddleware handles two common HTTP concerns:
- Trailing-slash redirects — when
APPEND_SLASH = True(the default), requests for URLs without a trailing slash are redirected to the slash version if the route exists. Content-Lengthheader — automatically adds aContent-Lengthheader to responses that don’t already have one.
MIDDLEWARE = [ ... "buraq.middleware.common.CommonMiddleware",]APPEND_SLASH = True # default; set False to disable redirect behaviourBrokenLinkEmailsMiddleware
Section titled “BrokenLinkEmailsMiddleware”Sends an email to each address in MANAGERS when a 404 response is returned for a request that came from an internal Referer (i.e. a broken link on your own site). Silently ignores 404s from external referrers.
MIDDLEWARE = [ "buraq.middleware.common.CommonMiddleware", "buraq.middleware.common.BrokenLinkEmailsMiddleware", # must come after CommonMiddleware ...]
MANAGERS = [ ("Alice", "alice@example.com"), ("Bob", "bob@example.com"),]If MANAGERS is empty or unset, the middleware is a no-op. The email includes the broken URL and the referrer so you can fix the link.
GZipMiddleware
Section titled “GZipMiddleware”Compresses HTTP responses using gzip when the client sends Accept-Encoding: gzip. Only compresses text-based content types above a minimum size threshold.
MIDDLEWARE = [ ... "buraq.middleware.gzip.GZipMiddleware",]ConditionalGetMiddleware
Section titled “ConditionalGetMiddleware”Adds ETag and Last-Modified headers to responses and returns 304 Not Modified when the browser’s conditional headers (If-None-Match, If-Modified-Since) match, saving bandwidth for unchanged resources.
MIDDLEWARE = [ ... "buraq.middleware.common.ConditionalGetMiddleware",]MessageMiddleware
Section titled “MessageMiddleware”Persists flash messages in the session between requests. Required by buraq.contrib.messages and SuccessMessageMixin:
MIDDLEWARE = [ "buraq.contrib.sessions.middleware.SessionMiddleware", "buraq.contrib.messages.middleware.MessageMiddleware", # must come after SessionMiddleware ...]Rate limiting
Section titled “Rate limiting”RATE_LIMIT limits every route by IP; @ratelimit tightens it for one. Both go
through a single RateLimiter, so they count the same way and share one store.
Global limit — every route, by IP:
RATE_LIMIT = "100/minute" # the defaultRATE_LIMIT = "" # off; @ratelimit still worksOne route, tighter — key defaults to "ip", right for login since there is no
user yet:
from buraq.decorators import ratelimit
@ratelimit("5/minute")async def login(request): ...
@ratelimit("5/minute", "50/day") # both applyasync def send_code(request): ...By signed-in user — the limit follows them across devices and networks:
@ratelimit("100/hour", key="user")async def send_invite(request): ...By anything else — API key, tenant, target account:
@ratelimit("1000/hour", key=lambda r: r.headers.get("x-api-key", ""))async def search(request): ...Shared across workers — otherwise four workers with 100/minute admit 400. If
your cache is already Redis, the counters follow it and there is nothing to set:
CACHE_REDIS_URL = "redis://localhost:6379" # the counters go here too
# only to point them somewhere else, or to keep them per-worker on purposeRATE_LIMIT_STORAGE = "memory://"Weight an expensive route — cost spends more of the allowance per call:
@ratelimit("10/minute", cost=5) # two calls a minute, not tenasync def export(request): ...Skip the limit for some callers — a health check, or staff:
@ratelimit("60/minute", exempt=lambda r: r.scope["user"].is_staff)async def dashboard(request): ...Manual, for a key you compute yourself:
from starlette.responses import JSONResponse
from buraq.ratelimit import parse_rate
ATTEMPTS = parse_rate("5/hour")
async def reset_password(request, email: str): if not await request.app.state.limiter.hit(ATTEMPTS, f"reset:{email}"): return JSONResponse({"detail": "Too many attempts"}, status_code=429)Over the limit is 429 with {"detail": "Rate limit exceeded"} and a
Retry-After header, from both paths.
A limited response also carries X-RateLimit-Limit, -Remaining and -Reset,
so a client can pace itself rather than discovering the limit by hitting it.
They describe the limit the caller is actually spending: a route’s own
@ratelimit where it has one, the global limit elsewhere, and the tightest of
several where a route carries more than one. A route with no limit of its own,
under RATE_LIMIT = "", is not limited and sends none.
The rest of this section is why each of those is shaped the way it is.
The global limit
Section titled “The global limit”When RATE_LIMIT is set, Buraq installs GlobalRateLimitMiddleware — a small
pure-ASGI middleware that checks the limit and answers 429. The counter lives
in memory and the check costs about a microsecond, so it is cheap enough to
leave on — enforcement adds single-digit microseconds to a request, whatever the
size of the project.
Set RATE_LIMIT = "" if something in front of the application already limits by
IP, as Nginx and every CDN can: the middleware is then not installed at all, and
per-route @ratelimit keeps working.
Limit strings are read by parse_rate, which takes 5/minute, 5 per minute,
10/5 minutes, and the s/m/h/d/w abbreviations, over periods from a
second to a week. The same parser reads RATE_LIMIT and @ratelimit, so both
take the same spellings, and both reject a malformed one where it is written
rather than at the first request it would have been checked against.
Several limits may be given — @ratelimit("5/minute", "50/day") — and all of
them apply. Stacking the decorator accumulates rather than replacing. A limit on
a route is counted separately from the global one and from every other route’s;
only the store is shared.
What counts as one client
Section titled “What counts as one client”key decides. It defaults to "ip", resolved by client_ip(): the first entry
in X-Forwarded-For when that header is present, falling back to the socket
address. Without the header every request behind a proxy would arrive from the
proxy, and one heavy client would lock out everyone sharing it.
For anything a signed-in user does, prefer key="user". An office, a school
or a phone network is one address, so limiting a signed-in action by IP rations
it across everybody there at once. Counting the user also stops one person
resetting their own allowance by changing networks. Anonymous callers have no
identity to count, so "user" falls back to their address.
A callable takes the request and returns a string, for limiting by anything else in it.
Sharing the count between workers
Section titled “Sharing the count between workers”A counter in the worker process is counted per worker: four of them with
100/minute admit up to 400. RATE_LIMIT_STORAGE says where the counters go,
and reaches both RATE_LIMIT and @ratelimit.
It is empty by default, which means wherever the cache is. A project running Redis for its cache has already said where its shared state lives, so the limits go there and come out correct across workers without naming the same server twice. With no Redis cache configured, the counters stay in the process and nothing extra needs to run.
Set it to override:
| Value | Where the counters go | Needs |
|---|---|---|
| (empty — the default) | CACHE_REDIS_URL if set, else in-process |
— |
memory:// |
in-process, per worker, deliberately | nothing |
async+redis://host:6379 |
that Redis, not the cache’s | pip install buraq[ratelimit-shared] coredis |
async+mongodb://host:27017 |
that MongoDB | pip install buraq[ratelimit-shared] motor |
resolve_storage() is what works this out, if you want to check what a given
settings file resolves to.
Why limits rather than the cache backend, when the cache is right there:
counting a moving window needs the times of the hits inside the window, and a
cache API of get/set/incr cannot express that. Building on it would force a
fixed window — which admits twice the limit across a boundary. Doing it properly
across processes is also where rate
limiters go subtly wrong, and that correctness is worth the optional dependency.
Only the address is shared with the cache, not the mechanism.
The async+ prefix is required for anything over the network. The synchronous
clients in limits do blocking socket I/O, and one of those on the request path
stalls every request the worker is serving — not just the one being checked — so
Buraq refuses a URI without it rather than quietly costing you the event loop.
Memcached cannot be used: RATE_LIMIT is a moving window, and memcached cannot
read back the timestamps inside one. A missing driver, an unusable store and a
blocking URI are all reported at startup, naming what to install or what to use
instead — not at the first request.
The clients are not bundled, since a project on the default counter should not have to carry a Redis dependency. Limiting at the edge instead — where there is only one counter anyway — is the other good answer, and costs the application nothing.
Checking a limit yourself
Section titled “Checking a limit yourself”The limiter is on the application as app.state.limiter, a RateLimiter — the
same one RATE_LIMIT and @ratelimit use, so a manual check shares their store.
Reach for it when the key is not a property of the caller but of what they are
asking for: password resets per target account, as in the last example above,
so that flooding one mailbox does not depend on the sender staying put.
hit(rate, key) returns a Verdict, which is falsey once that key has spent the
limit and carries limit, remaining and reset_after. Parse the rate once at
import rather than per request, as @ratelimit does.
To tell the caller what is left, as the decorator and the global limit both do,
add rate_headers(verdict) to the response:
from buraq.middleware.ratelimit import rate_headers
verdict = await request.app.state.limiter.hit(ATTEMPTS, f"reset:{email}")response = JSONResponse({"sent": bool(verdict)})for name, value in rate_headers(verdict): response.headers[name.decode()] = value.decode()CSRF protection
Section titled “CSRF protection”Buraq ships a CSRF middleware that validates tokens on state-changing requests. Tokens are stored in the session.
It is in MIDDLEWARE already, so there is nothing to set up:
MIDDLEWARE = [ ... "buraq.middleware.csrf.CsrfViewMiddleware",]Removing that line turns CSRF protection off everywhere. CsrfViewMiddleware
skips validation for safe methods (GET, HEAD, OPTIONS, TRACE), and for
a view decorated @csrf_exempt.
Using the token in templates
Section titled “Using the token in templates”from buraq.contrib.csrf import get_token
async def my_view(request): csrf_token = get_token(request) return await render(request, "form.html", {"csrf_token": csrf_token})<form method="post"> <input type="hidden" name="csrftoken" value="{{ csrf_token }}"> ...</form>Decorators
Section titled “Decorators”from buraq.contrib.csrf import csrf_protect, ensure_csrf_cookie
# Force CSRF validation on this view@csrf_protectasync def payment_view(request): ...
# Set the CSRF cookie even if the response wouldn't normally need it@ensure_csrf_cookieasync def frontend_entry(request): ...See CSRF Protection for the full reference.