Decorators¶
Decorators are the primary way to declare the lifecycle state of a route. Each one stamps a __shield_meta__ dictionary onto the endpoint function without modifying it. ShieldRouter reads this metadata at startup and registers the initial state with the engine.
All decorators are importable from shield.fastapi or directly from shield.fastapi.decorators.
from shield.fastapi import maintenance, disabled, env_only, force_active, deprecated
from shield.fastapi.decorators import rate_limit
Decorator order
Always apply the shield decorator directly below the router decorator, so it wraps the function before the router sees it:
@maintenance¶
Mark a route as temporarily unavailable. Returns 503 with a structured JSON body and an optional Retry-After header.
from shield.fastapi import maintenance
@router.get("/payments")
@maintenance(reason="DB migration in progress")
async def get_payments():
return {"payments": []}
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
reason |
str |
"" |
Human-readable explanation shown in the 503 error response and recorded in the audit log |
start |
datetime \| None |
None |
When maintenance should activate. If omitted, the route enters maintenance immediately on startup. |
end |
datetime \| None |
None |
When maintenance should deactivate. Sets the Retry-After response header. Read more in MaintenanceWindow. |
response |
callable \| None |
None |
Custom response factory for this route. Read more in Custom responses. |
Scheduled window¶
Pass start and end to schedule maintenance for a future window. The scheduler activates maintenance at start and restores ACTIVE at end automatically.
from datetime import datetime, UTC
from shield.fastapi import maintenance
@router.post("/orders")
@maintenance(
reason="Order system upgrade",
start=datetime(2025, 6, 1, 2, 0, tzinfo=UTC),
end=datetime(2025, 6, 1, 4, 0, tzinfo=UTC),
)
async def create_order():
...
Response body¶
{
"error": {
"code": "MAINTENANCE_MODE",
"message": "This endpoint is temporarily unavailable",
"reason": "DB migration in progress",
"path": "GET:/payments",
"retry_after": "2025-06-01T04:00:00Z"
}
}
@disabled¶
Permanently disable a route. Returns 503. Use for routes that should never be called again — removed features, deprecated API versions that have passed sunset, or endpoints replaced by a successor path.
from shield.fastapi import disabled
@router.get("/legacy/report")
@disabled(reason="Replaced by /v2/reports. Update your client.")
async def legacy_report():
...
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
reason |
str |
"" |
Shown in the 503 error response and recorded in the audit log |
response |
callable \| None |
None |
Custom response factory for this route. Read more in Custom responses. |
Response body¶
{
"error": {
"code": "ROUTE_DISABLED",
"message": "This endpoint has been disabled",
"reason": "Replaced by /v2/reports. Update your client.",
"path": "GET:/legacy/report",
"retry_after": null
}
}
Prefer @disabled over deleting the route
Removing a route entirely causes clients to receive unhandled 404s with no explanation. @disabled returns a 503 with a machine-readable error code and a human-readable reason, making it easier for API consumers to diagnose and migrate.
@env_only¶
Restrict a route to specific environment names. In any other environment the route returns a 403 Forbidden with a JSON body containing the current environment and the list of allowed environments.
Use this for internal tools, debug endpoints, admin utilities, or staging-only features that should never be accessible in production.
from shield.fastapi import env_only
@router.get("/internal/metrics")
@env_only("dev", "staging")
async def internal_metrics():
...
Parameters¶
@env_only accepts one or more positional string arguments — the environment names where the route is accessible.
@env_only("dev") # single environment
@env_only("dev", "staging") # multiple environments
@env_only("dev", "staging", "qa") # three environments
Setting the current environment¶
The engine compares the environment names against the current_env value it was constructed with:
# Explicit
engine = ShieldEngine(current_env="production")
# Via config helper (reads SHIELD_ENV env var)
engine = make_engine()
403 with JSON body
Env-gated routes return 403 with a structured JSON error so callers can distinguish an environment restriction from a genuine missing route. The body includes code, current_env, allowed_envs, and path.
@force_active¶
Bypass all shield checks — both per-route and global maintenance. Use for health check endpoints, readiness probes, and any path that must remain reachable regardless of system state.
from shield.fastapi import force_active
@router.get("/health")
@force_active
async def health():
return {"status": "ok"}
@force_active takes no arguments
Use it without parentheses, directly on the function.
Behavior¶
- Routes marked
@force_activecannot be disabled or put in maintenance via the engine, CLI, or admin dashboard. - Global maintenance skips these routes by default. They are only blocked when
enable_global_maintenance(include_force_active=True)is explicitly passed.
Always mark health checks @force_active
Load balancers and orchestrators rely on health endpoints to determine if a pod should receive traffic. If your health route is blocked during a maintenance window, the orchestrator may restart or decommission the instance.
@deprecated¶
Mark a route as deprecated. Requests still succeed (no blocking), but the middleware injects RFC-compliant headers into every response to warn API consumers and tooling.
from shield.fastapi import deprecated
@router.get("/v1/users")
@deprecated(
sunset="Sat, 01 Jan 2027 00:00:00 GMT",
use_instead="/v2/users",
)
async def v1_users():
return {"users": []}
Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
sunset |
str |
required | RFC 7231 date string indicating when the route will be removed. Shown in the Sunset response header. |
use_instead |
str |
"" |
Path or URL of the successor resource. Shown in the Link response header. |
Response headers injected¶
The route is also automatically marked deprecated: true in the OpenAPI schema, so clients and generated SDKs pick up the deprecation without any manual annotation.
Use @deprecated before @disabled
Give API consumers time to migrate. Mark the route @deprecated with a sunset date, then switch to @disabled after the sunset date passes.
Custom responses¶
By default, blocked routes return a structured JSON error body. You can replace this with any Starlette Response subclass — HTML, plain text, a redirect, or a different JSON shape — in two ways:
- Per-route: pass
response=on the decorator - Global default: pass
responses=onShieldMiddleware
Resolution order per request: per-route response= → global responses= default → built-in JSON.
Per-route: response= parameter¶
Every blocking decorator (@maintenance, @disabled, @env_only) accepts an optional response= keyword. The value is a sync or async callable:
HTML maintenance page
from starlette.requests import Request
from starlette.responses import HTMLResponse
from shield.fastapi import maintenance
def maintenance_page(request: Request, exc) -> HTMLResponse:
return HTMLResponse(
f"<h1>Down for maintenance</h1><p>{exc.reason}</p>",
status_code=503,
)
@router.get("/payments")
@maintenance(reason="DB migration", response=maintenance_page)
async def payments():
return {"payments": []}
Redirect to a status page
Custom JSON shape
from starlette.requests import Request
from starlette.responses import JSONResponse
from shield.fastapi import maintenance
def branded_error(request: Request, exc) -> JSONResponse:
return JSONResponse(
{"ok": False, "message": str(exc), "support": "https://status.example.com"},
status_code=503,
)
@router.get("/payments")
@maintenance(reason="DB migration", response=branded_error)
async def payments():
return {"payments": []}
Async factory (template rendering)
from starlette.requests import Request
from starlette.responses import HTMLResponse
from shield.fastapi import maintenance
async def maintenance_page(request: Request, exc) -> HTMLResponse:
html = await render_template("maintenance.html", reason=exc.reason)
return HTMLResponse(html, status_code=503)
@router.get("/payments")
@maintenance(reason="DB migration", response=maintenance_page)
async def payments():
return {"payments": []}
Global default: responses= on ShieldMiddleware¶
Set app-wide response defaults once on the middleware. Any route without a per-route response= will use these.
from starlette.requests import Request
from starlette.responses import HTMLResponse
from shield.fastapi import ShieldMiddleware
def maintenance_page(request: Request, exc) -> HTMLResponse:
return HTMLResponse(
f"<h1>Down for maintenance</h1><p>{exc.reason}</p>",
status_code=503,
)
app.add_middleware(
ShieldMiddleware,
engine=engine,
responses={
"maintenance": maintenance_page,
"disabled": lambda req, exc: HTMLResponse("<h1>Gone</h1>", status_code=503),
# omit "env_gated" to keep the default 403 JSON
},
)
| Key | Triggered by | Default behavior |
|---|---|---|
"maintenance" |
MaintenanceException (per-route or global) |
503 JSON |
"disabled" |
RouteDisabledException |
503 JSON |
"env_gated" |
EnvGatedException |
403 JSON |
"rate_limited" |
RateLimitExceededException |
429 JSON |
Factory signature¶
# Sync — works fine for most cases
def my_factory(request: Request, exc: ShieldException) -> Response: ...
# Async — identical interface, use when you need to await something
async def my_factory(request: Request, exc: ShieldException) -> Response: ...
The exc argument carries useful context for building your response:
| Shield state | Exception type | Useful attributes |
|---|---|---|
| Maintenance | MaintenanceException |
exc.reason, exc.retry_after, exc.path |
| Disabled | RouteDisabledException |
exc.reason, exc.path |
| Env-gated | EnvGatedException |
exc.path, exc.current_env, exc.allowed_envs |
| Rate limited | RateLimitExceededException |
exc.limit, exc.retry_after_seconds, exc.reset_at, exc.remaining, exc.key |
Read more in Exceptions.
@rate_limit¶
Cap the number of requests a client can make in a given time window. Returns 429 with Retry-After and X-RateLimit-* headers when the limit is exceeded.
from shield.fastapi.decorators import rate_limit
@router.get("/public/posts")
@rate_limit("10/minute")
async def list_posts():
return {"posts": [...]}
Basic parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
str \| dict |
required | "100/minute" or a tier dict {"free": "10/minute", "pro": "100/minute"} |
algorithm |
str |
"fixed_window" |
fixed_window, sliding_window, moving_window, or token_bucket |
key |
str \| callable |
"ip" |
"ip", "user", "api_key", "global", or an async callable |
on_missing_key |
str \| None |
strategy default | "exempt", "fallback_ip", or "block" |
burst |
int |
0 |
Extra requests above the base limit |
exempt_ips |
list[str] \| None |
[] |
CIDR ranges that bypass the limit |
exempt_roles |
list[str] \| None |
[] |
Roles that bypass the limit |
Key strategies¶
@rate_limit("100/minute") # per IP (default)
@rate_limit("100/minute", key="user") # per request.state.user_id
@rate_limit("50/minute", key="api_key") # per X-API-Key header
@rate_limit("5/minute", key="global") # shared counter for all callers
@rate_limit("100/minute", key=my_async_fn) # custom extractor
Tiered limits¶
The tier is read from request.state.plan by default. Override with tier_resolver="your_attr".
See Tutorial: Rate Limiting and Reference: Rate Limiting for the full API.
Using decorators as Depends() dependencies¶
All decorators also work as FastAPI dependencies. This lets you enforce route state without middleware — useful in testing or when you need per-handler enforcement in a router that does not use ShieldRouter.
from fastapi import Depends
from shield.fastapi import disabled
@router.get("/admin/report", dependencies=[Depends(disabled(reason="Use /v2/report"))])
async def admin_report():
return {}
See FastAPI adapter: Dependency injection for full details.
Composition rules¶
- A route can carry at most one shield decorator. If multiple are applied, the last one to write
__shield_meta__wins. Use@maintenanceor@disabled, not both. - All decorators preserve
asyncandsyncfunction signatures using@functools.wraps. - Decorators are compatible with both
ShieldRouterand plainAPIRouter. When using a plainAPIRouter, the decorator metadata is applied, but initial state registration at startup requiresShieldRouter. Read more in ShieldRouter.