Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dependencies = [
"aiosqlite>=0.21.0",
"alembic>=1.16.4",
"asyncpg>=0.30.0",
"crudauth[all]>=0.7.0,<0.8.0",
"crudauth[all]>=0.7.1,<0.8.0",
"faker>=37.1.0",
"fastapi[standard]>=0.115.8",
"fastcrud>=0.21.0",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/infrastructure/app_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import anyio
import fastapi
from crudauth.ratelimit import RateLimitHeadersMiddleware
from fastapi import APIRouter, Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
Expand Down Expand Up @@ -270,6 +271,7 @@ def create_application(
register_exception_handlers(application)

application.include_router(router)
application.add_middleware(RateLimitHeadersMiddleware)

if isinstance(settings, CacheSettings) and settings.CACHE_ENABLED and hasattr(settings, "CLIENT_CACHE_ENABLED"):
if settings.CLIENT_CACHE_ENABLED:
Expand Down
3 changes: 2 additions & 1 deletion backend/tests/integration/auth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ async def test_a_state_this_browser_never_started_is_refused(client: AsyncClient
follow_redirects=False,
)

assert response.status_code == 400
assert response.status_code == 307
assert response.headers["location"] == f"{BASE}?error=invalid_state"
assert "session_id" not in response.cookies


Expand Down
9 changes: 9 additions & 0 deletions backend/tests/integration/test_api_rate_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ async def test_the_default_limit_is_enforced(client: AsyncClient, limits: dict):
assert statuses[3] == 429


async def test_a_refused_request_still_reports_the_budget_it_spent(client: AsyncClient, limits: dict):
"""A 401 after the limiter counted the request tells the client what it has left."""
responses = [await client.get("/api/v1/tiers/", headers=limits) for _ in range(4)]

assert [response.status_code for response in responses] == [401, 401, 401, 429]
assert [response.headers.get("X-RateLimit-Remaining") for response in responses] == ["2", "1", "0", "0"]
assert all(response.headers.get("X-RateLimit-Limit") == "3" for response in responses)


async def test_each_path_keeps_its_own_budget(client: AsyncClient, limits: dict):
"""Spending the budget on one route never throttles another."""
for _ in range(3):
Expand Down
4 changes: 3 additions & 1 deletion docs/user-guide/authentication/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ Google console; `OAUTH_REDIRECT_BASE_URL` is the public origin of the API, witho

A failed sign-in - the user declined, or their address is longer than the `email` column - sends
the browser to `OAUTH_REDIRECT_BASE_URL?error=<code>`. A callback whose `state` doesn't match the
cookie set when the flow started gets a plain 400 instead, since it may be a login-CSRF attempt.
cookie set when the flow started, or whose state was already used or has expired, lands there too
with `error=invalid_state` and no session, since it may be a login-CSRF attempt; the usual cause is
a sign-in that took too long or finished in another browser, so offer to start again.
New accounts take their display name from the Google profile.

Only Google is wired when its credentials are configured. The router is supplied by crudauth: PKCE,
Expand Down
9 changes: 5 additions & 4 deletions docs/user-guide/rate-limiting/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The configured crudauth backend is initialized with the auth singleton in the ap
2. **`resolve_api_rate_limit`** looks up the user's tier and matching path row from the database.
3. **`api_rate_limit_key`** names the budget: the caller (user ID when signed in, client IP otherwise) plus the request path, so every path has its own counter.
4. **crudauth's limiter** atomically increments the counter for the current window and returns `(count, is_limited)`. Windows are `period` seconds long and aligned to the clock, and each window's key expires on its own.
5. **If `is_limited`**, raises a 429 with `Retry-After`. Otherwise the limiter attaches `X-RateLimit-Limit` and `X-RateLimit-Remaining` to the response. A request that fails authentication afterwards still counts, but its 401 doesn't carry the headers.
5. **If `is_limited`**, raises a 429 with `Retry-After`. Otherwise the limiter attaches `X-RateLimit-Limit` and `X-RateLimit-Remaining` to the response. A request refused afterwards (a 401, a 404, a 422) still counts, and its response carries the headers too.

The key shape in Redis, ending in the start of the current window:

Expand Down Expand Up @@ -184,15 +184,16 @@ Mirror `UserAdmin` and `TierAdmin` to add a `RateLimitAdmin` view — see [Admin

## Response Headers

Responses the route answers carry:
Every response to a counted request carries these, errors included:

| Header | Meaning |
|-------------------------|--------------------------------------------------|
| `X-RateLimit-Limit` | The configured limit for this caller × path |
| `X-RateLimit-Remaining` | How many requests are left in the current window |

A 429 also carries `Retry-After`, the seconds until the window resets. A request that fails
authentication after the limiter counted it answers 401 without these headers.
A 429 also carries `Retry-After`, the seconds until the window resets. A request refused after
the limiter counted it - a 401 from authentication, a 404, a 422 - still reports the budget it
spent, through crudauth's `RateLimitHeadersMiddleware`, which the app factory installs.

These are standard-ish (formatted like the GitHub / Stripe convention, not RFC 6585). Frontends can read them to surface graceful "you're approaching your limit" UI.

Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading