Decorator stack & order of evaluation
What actually happens when you stack @guard.rate_limit + @guard.require_ip on a route, and why the order in your source file isn't the order things run.
Coming from middleware patterns, engineers expect decorators to wrap the handler and execute top-down. fastapi-guard's decorators don't work that way. They barely "execute" at all.
Mental model: what people assume vs what actually happens
Assumed: top-down execution. The top decorator runs first. Each decorator wraps the next. Stack order = execution order, like middleware layers. Wrong.
Actual: metadata + fixed pipeline. Decorators don't wrap the handler. They record settings on a per-route config bucket. The middleware reads that bucket at request time and runs checks in a fixed order. Right.
At definition time - decorators populate a config bucket
When Python imports your app.py, each guard decorator runs once, at module load time. It doesn't wrap your handler. It writes settings onto a route-keyed configuration object.
@app.get("/api/restricted")
@guard.rate_limit(requests=3, window=600)
@guard.require_ip(whitelist=["127.0.0.1", "10.0.0.0/8"])
async def restricted_api():
return {...}What gets registered:
route configuration for /api/restricted
rate_limit: 3 req / 600s
ip_whitelist: [127.0.0.1, 10.0.0.0/8]
(other fields): inherit from global configThe handler isn't wrapped. Both decorators return the original async function untouched. The actual handler signature, body, and behavior are unchanged. What changes is a side-effect dictionary the middleware reads later. The handler is just a key into that dictionary.
At request time - middleware enforces in a fixed order
When a request arrives, the middleware's dispatch path runs a numbered sequence of checks. It looks up the route's config and consults it at each step.
0. Resolve route → look up config bucket for this route ID
1. IP filtering → blacklist, whitelist, ban check, geo, cloud-provider
2. Rate limiting → sliding-window check against the route's rate_limit
3. Detection → SQLi, command injection, SSRF, file inclusion
4. Behavioral → pattern-of-behavior rulesShort-circuit: each step can reject the request. If step 1 (IP filtering) rejects, steps 2-4 don't run. The response carries the reason for the first failure only. That's why "only one decorator seems enforced" is misleading.
The order is determined by the middleware code, not by the route, and definitely not by decorator stack position.
The key insight: stack order is irrelevant
Both arrangements below produce the same per-route config and run the same checks in the same order. The middleware doesn't know, or care, which line came first in your file.
# Sample A
@app.get("/api/restricted")
@guard.rate_limit(requests=3, window=600)
@guard.require_ip(whitelist=["10.0.0.0/8"])
async def restricted(): ...
# Sample B (equivalent)
@app.get("/api/restricted")
@guard.require_ip(whitelist=["10.0.0.0/8"])
@guard.rate_limit(requests=3, window=600)
async def restricted(): ...Both produce: rate_limit=3/600, ip_whitelist=[10.0.0.0/8]. Execution: IP filter → rate limit → detection → behavioral.
Write decorators in whatever order reads cleanest. They're declarative metadata, not a pipeline.
Precedence - per-route decorator values beat the global
When a decorator sets a value on a route, that value wins for that route. Other routes, including ones with no decorator, keep using the global default from SecurityConfig.
| Field | Global default | /api/restricted (decorator) | Other routes |
|---|---|---|---|
rate_limit | 50 / 60s | 3 / 600s (overridden) | 50 / 60s (global) |
ip_whitelist | (none) | [10.0.0.0/8] (added) | (none) |
Override is per-field: a decorator setting only rate_limit doesn't touch ip_whitelist (which stays at global / empty).
Walkthrough: a request to /api/restricted from 1.2.3.4
0. route lookup → config: rate_limit=3/600, ip_whitelist=[127.0.0.1, 10.0.0.0/8]
1. IP filtering → 1.2.3.4 not in whitelist → REJECT 403
(short-circuit - rate limiting, detection, behavioral never run)In the source, @rate_limit appears ABOVE @require_ip. But @require_ip's check runs first. IP filtering is step 1. If you swap the two decorator lines, behavior is identical. An allowed IP (e.g., 10.0.0.5) proceeds past step 1 to rate limiting, and is throttled at 3 per 10 min.
Debugging - see what the middleware sees
If a decorator isn't behaving, the fastest diagnosis is to inspect the route config the middleware will read at request time. If the values aren't there, the registration didn't happen.
Startup dump: iterate the registered routes and log the resolved config for each. Best for one-time verification after deploys.
Debug endpoint: expose an internal endpoint that takes a route ID and returns its resolved config (gated by auth and non-prod env). Best for iterative debugging in staging.
Most common cause of per-route values not taking effect: the route was registered AFTER the middleware was added. Confirm middleware registration order in your app initialization.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Stacked decorators but only one seems enforced | Both applied; the response carries the rejection reason for the FIRST failure only. The second check never ran because step 1 short-circuited. | Verify both via route-config inspection. Test each in isolation - comment one decorator out. |
| Want rate-limit to run BEFORE IP filtering for performance | Not configurable. Middleware order is hardcoded: IP filter → rate limit → detection → behavioral. IP filtering is cheaper than rate-limit (which touches Redis), first by design. | Accept the order. If IP filtering is your bottleneck, optimize there (narrower whitelists, faster data structure). |
| Per-route decorator config doesn't show in dashboard | Decorator values stay middleware-local. Never pushed to agent or core API. Dashboard only shows dynamic-rules pipeline data. | Use the inspection techniques above instead of expecting dashboard visibility. |