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.

5 min read · verified against [email protected]

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 config

The 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 rules

Short-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.

FieldGlobal default/api/restricted (decorator)Other routes
rate_limit50 / 60s3 / 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

SymptomLikely causeFix
Stacked decorators but only one seems enforcedBoth 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 performanceNot 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 dashboardDecorator 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.