Two pipeline-level guards protect the process before a route does any work.
Both are raw ASGI middleware in simple_module_hosting, configured from
HostSettings (env, then DB, then default) and extended by modules through a
registration hook.
| Guard | Answers | Default | Module hook |
|---|---|---|---|
Body size (BodyLimitMiddleware) |
413 |
10 MiB | register_body_limits |
Rate limit (RateLimitMiddleware) |
429 + Retry-After |
120/minute per IP, anonymous public routes only |
rate= on a public rule |
Both settings are read when the middleware stack is built, so editing them in the admin UI needs a restart (the pre-app settings read picks the DB value up on the next boot; an env var still wins).
A module's own payload check runs only after the framework has read the whole body into memory, so it protects the database, not the process. The guard refuses first:
Content-Lengthover the ceiling is refused before anything reads the body.- No
Content-Length(chunked), or one that lies, is caught by counting the bytes the app pulls throughreceiveand aborting once the ceiling is crossed. A client cannot bypass the guard by streaming.
It sits right inside RequestLogging (so a 413 is logged with its correlation
id) and outside GZip and every module middleware.
Negotiated like every other error (_wants_json): API callers (/api/*, or an
explicit JSON Accept) get 413 {"detail": "...", "max_bytes": N} immediately.
A browser request is not refused at the outer layer, because the error page needs
the session, locale and shared props the inner layers set up; instead the body
read raises HTTPException(413) and the app's normal handler renders the
Inertia error page. A route that never reads its body is therefore not
interrupted on that path.
| Field | Env | Default |
|---|---|---|
max_request_body_bytes |
SM_MAX_REQUEST_BODY_BYTES |
10485760 (10 MiB); 0 disables the guard |
def register_body_limits(self, registry: BodyLimitRegistry) -> None:
# fixed ceiling
registry.add_exact("/api/media/upload", 200 * 1024 * 1024, methods={"POST"})
# a limit that is itself a runtime setting: pass a callable taking the app
registry.add_prefix("/api/x/import", lambda app: app.state.x.settings.max_bytes)Helpers: add (any match kind), add_prefix, add_exact, add_regex. The
first matching rule in registration order wins and replaces the global
ceiling, so it can lower it as well as raise it; 0 means unlimited for that
route. file_storage uses this for its upload endpoint: its ceiling is the
max_file_size_bytes setting plus 1 MiB of multipart framing.
Keyed on the client IP as the ASGI scope reports it, so SM_TRUSTED_PROXY
(ProxyHeaders, outermost) is honoured. It runs after the auth middleware
and after Locale/InertiaLayoutData, so it can tell anonymous from signed-in
and render a translated error page.
Policy:
- A request is limited by
rate_limit_publiconly if the auth middleware judged it anonymous-allowed and nobody is signed in.AuthMiddlewarerecords that decision asscope["state"]["auth_public"](framework defaults, the public-route registry and the provider's legacy public paths), and the limiter reads the flag, so the two cannot disagree, path variants included. The key is a plain scope-state contract (no import, SM009-safe). With no auth provider installed the registry match is used instead./healthand/static/are never limited. - A matching public rule's own
rate=replaces that default and gets its own bucket per client. rate_limit_authenticated(blank by default) opts signed-in traffic in.- Everything else, including routes outside the registry such as
/healthand/static/, is untouched.
| Field | Env | Default |
|---|---|---|
rate_limit_public |
SM_RATE_LIMIT_PUBLIC |
120/minute; blank or off disables |
rate_limit_authenticated |
SM_RATE_LIMIT_AUTHENTICATED |
blank (off) |
redis_url |
SM_REDIS_URL |
unset |
Rates read <count>/<period> with second, minute, hour or day
(5/10s for a multiple). A malformed rate fails at boot rather than silently
disabling protection.
Without SM_TRUSTED_PROXY, every client behind a reverse proxy appears as the
proxy's address and shares one anonymous bucket. Set it (or raise
rate_limit_public) before putting the host behind a proxy.
def register_public_routes(self, registry) -> None:
registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}, rate="600/minute")
registry.add_prefix("/api/records/public/", rate="60/minute")
registry.add_prefix("/api/gis/stac", rate="off") # exempt this rulePublic-route rate= only covers anonymous traffic. To bound a signed-in
action (a POST that sends mail, an export), register a rule; it applies to
anonymous and signed-in requests, before the tiers above:
from simple_module_core.rate_limit import RateLimitRule
def register_rate_limits(self, registry) -> None:
registry.add_exact("/api/invites", RateLimitRule("10/minute", key="user", methods={"POST"}))
registry.add_prefix("/api/reports/", RateLimitRule("30/hour", key="tenant"))key picks whose budget it is: user (the principal id; an API token counts as
its owner), ip (the client address, after ProxyHeaders), user_or_ip (the
default) or tenant (the resolved tenant). Anonymous requests, and a tenant
that did not resolve, fall back to the IP. Two users behind one NAT each get the
full rate. The most specific rule wins (exact, regex, longest prefix); enabled=
is evaluated per request. Buckets are named
rule:<kind>:<methods>:<pattern>:<keykind>=<id>.
For a single handler, use the dependency (same store and key resolution; 429
with Retry-After):
from simple_module_hosting.rate_limit_deps import RateLimit
@router.post("/send", dependencies=[Depends(RateLimit("10/minute", key="user"))])rate_limit_authenticated is now counted per user, not per IP.
- Redis when
SM_REDIS_URLis set (needs theredispackage, whichbackground_tasksalready installs): a fixed window, counted atomically with one LuaINCR+PEXPIRE, shared by every worker. Short socket timeouts keep a stalled Redis from hanging requests. - In process otherwise: counters live in one worker, so with N workers the effective limit is up to N times the configured rate.
- Degrades, never latches. If Redis errors (down, timeout), hits are counted in per-worker counters instead (so the limit is weaker, not gone), a warning is logged (at most once a minute), and Redis is probed again after 5 seconds, so recovery is automatic. The script also re-arms a counter that lost its TTL, so no key can block an IP permanently. The limiter never takes requests down.
create_app builds the store once and exposes it as app.state.sm.rate_limit_store
(a WindowStore), so a module that throttles its own events counts in the same
place, across workers, without opening a second Redis client. Besides hit,
which counts, a store has peek(key, spec), which reports the current window
without counting (allowed answers "would one more hit be within budget"),
reset(key), which forgets a window, and hit(..., sliding=True), which
restarts the window on every hit. The users login lockout
(users/auth_local/lockout/) uses all three. Keys are
namespaced by the caller (login:…); Redis keys get the sm:rl: prefix.
429 with a Retry-After header (seconds until the window resets). API callers
get {"detail": "...", "retry_after": N}; browsers get the Inertia error page.
users/auth_local/rate_limit.py (ThroughputLimiter) stays as it is: a
synchronous per-endpoint budget used from FastAPI dependencies, a different
shape from this middleware's async per-IP window. Password-login lockout uses
the shared store instead (see Sharing the store).
simple_module_core.body_limits.BodyLimitRegistry,simple_module_core.rate_limit(rate parsing, in-process store),PublicRoute.ratesimple_module_hosting._body_limit,_rate_limit,_request_guard_responses- Wiring:
_middleware_pipeline.install_middleware; registry onapp.state.body_limits