Skip to main content

SlowAPI

One handler. Two protocols.

Express ergonomics · FastAPI typing · NestJS structure — on WSGI and ASGI, at the same time.

CI Python License Dependencies Docs

Documentation · Quickstart · Guide · Why it exists · How it works


from slowfw import SlowAPI

app = SlowAPI()


@app.get("/users/{id:int}")
def get_user(id: int) -> dict:
    return {"id": id}


@app.get("/fanout")
async def fanout() -> dict:
    return {"results": await asyncio.gather(*(fetch(i) for i in range(10)))}
gunicorn app:app --workers 4     # WSGI — and the async handler still works
uvicorn  app:app                 # ASGI — and the sync handler still works

Same file. No wsgi.py shim. No rewrite. No decision made in week one that you have to live with in year two.


Why this exists

Every Python web framework makes you choose sync or async before you write a line of code, and that choice decides your deployment for years.

Pick FastAPI, then find a library whose only client is blocking. Pick Flask, then need server-sent events. Deploy on a platform that only speaks WSGI, and typed handlers with generated OpenAPI are simply unavailable to you.

That split is an artefact, not a necessity. Routing does not care about the protocol. Neither does validation, dependency injection, serialisation, or OpenAPI generation. Only two things care: how bytes arrive, and how bytes leave. In SlowAPI that is two files of about 150 lines, and nothing else in the codebase imports either of them.

So: write the handler that fits the work. Choose the server separately, later, and reversibly.

The full argument is in docs/growth.md.


What makes it different

It is not a compatibility shim. A fully synchronous request on the WSGI path never creates an event loop at all — no task, no scheduler, no thread hop. The dispatch pipeline is written once as async def so both protocols share one implementation, and then stepped to completion by hand when nothing in the chain can suspend:

def drive(coro):
    try:
        coro.send(None)          # a coroutine that never awaits a future
    except StopIteration as stop:
        return stop.value        # runs straight through, no loop required

SlowAPI works out, at route registration, whether a route's entire chain — handler, middleware, guards, interceptors, pipes, and every transitive dependency — is synchronous, and picks the cheapest correct strategy per route.

Handler Server Strategy Cost
def WSGI called directly, no loop exists none
async def ASGI awaited on the server's loop none
def ASGI offloaded to a worker thread one thread hop
async def WSGI driven on a shared background loop one thread hop

The fourth row is the one most frameworks refuse. It works because SlowAPI keeps one long-lived loop per process rather than calling asyncio.run per request — so async connection pools and locks survive across requests on a gunicorn worker.

The claim is asserted, not promised:

def test_a_fully_sync_wsgi_request_creates_no_event_loop():
    shutdown_loop_thread()
    TestClient(app, protocol="wsgi").get("/plain")
    assert _LoopThread._instance is None

And the fast path is defended, not just achieved. A helper that is async def but awaits nothing except call_next() cannot suspend, so @never_suspends lets it keep the loop-free path instead of quietly costing it — which is what the built-in interceptors and TimeoutMiddleware would otherwise do to every synchronous route just by being installed.

Details: Dual-protocol dispatch.


Broken routes fail the build, not the deploy

Every handler signature, Depends chain, guard, interceptor, pipe and injected provider is analysed before the process serves anything:

$ slowfw check main:app
FAIL  2 route(s) failed validation:
  GET /reports/{id} (get_report): Could not resolve type hints for 'get_report':
    name 'ReportService' is not defined.
  POST /items (create_item): Parameter 'body' declares Body() inside Annotated[...]
    and Query() as its default. Pick one.

Every broken route, not just the first. The same analysis runs during startup, so a typo that would have surfaced on the first production request instead stops the process from coming up — and in CI, stops the merge.


Three frameworks, one file

Two of the three things Python teams keep asking for live in JavaScript. SlowAPI brings them together, because they are complementary rather than competing.

Express gives it ergonomics

@app.get("/users/:id")                       # Express path syntax works
def get_user(req, res, id):
    res.status(200).json({"id": id})         # chainable response


def timing(req, res, next):                  # (req, res, next) middleware
    started = time.perf_counter()
    next()                                   # skip it and nothing downstream runs
    res.set("X-Elapsed", f"{(time.perf_counter() - started) * 1000:.1f}ms")


app.use(timing)

FastAPI gives it types

@dataclass
class CreateUser:
    email: str
    age: int = 18


@app.post("/users", status_code=201)
def create(payload: CreateUser, notify: bool = Query(False)) -> User:
    return service.create(payload)

Coerced from the wire, validated, every error reported at once, and documented at /docs — from the same annotations that enforce it. Dataclasses need no dependency; Pydantic works if you have it.

NestJS gives it structure

@controller("/users", tags=["users"])
@use_guards(RoleGuard)
class UserController:
    def __init__(self, users: UserService):        # constructor injection
        self.users = users

    @Get("/:id")
    @roles("admin")
    def show(self, id: int) -> User:
        return self.users.find(id)


@module(
    imports=[CoreModule],
    controllers=[UserController],
    providers=[UserService, RoleGuard],
    exports=[UserService],
)
class UserModule: ...


app = SlowAPI(modules=[UserModule])

A real container with singleton/request/transient scopes, guards that run before anything is injected, interceptors that see the returned object, and pipes that transform one argument.

All three styles mix in one file. None of them is a legacy path.


Output shaping, because over-serialisation is the bug

@dataclass
class User:
    id: int
    email: str = field(metadata=expose(groups=("admin",)))
    password_hash: str = field(default="", metadata=hidden())
    created: datetime = field(default=None, metadata=expose(alias="createdAt"))
@Get("")
@public
def index(self) -> list[User]: ...              # {"id":1,"createdAt":"..."}

@Get("/directory")
@roles("admin")
@serialize_with(groups=("admin",))
def directory(self) -> list[User]: ...          # ...plus "email"

Same objects, different shapes, one decorator apart. password_hash never leaves the process, and making it leak takes a deliberate act.


Install

pip install slowfw

Zero required runtime dependencies. No Pydantic, no Starlette, no anyio, no click. Everything else is opt-in:

pip install "slowfw[asgi]"        # uvicorn
pip install "slowfw[wsgi]"        # gunicorn
pip install "slowfw[templates]"   # jinja2
pip install "slowfw[pydantic]"    # pydantic models as DTOs
pip install "slowfw[all]"

On the name. slowapi on PyPI is an unrelated rate-limiting library for Starlette. This project is slowfw everywhere — the distribution, the import, and the command — so the two never meet.

python -m slowfw new my-service     # scaffold a deployable project
cd my-service && python -m slowfw run main:app --reload

Batteries, all of them optional

Routing Trie matching, {id:int} and :id syntaxes, six converters plus regex, reverse URLs, correct HEAD/405/Allow
Validation Annotated or defaults; dataclasses, TypedDict, Pydantic; every error at once; constraints in the schema
Injection Depends with caching and generator teardown, plus a scoped DI container
Structure Controllers, modules, enforced exports, guards, interceptors, pipes
Middleware CORS, security headers, trusted host, gzip, proxy headers, rate limit, sessions, request id, access logs, timeouts
Responses JSON, HTML, redirects, streaming, SSE, files with ETag + byte ranges, automatic 304s
Operations Background tasks, liveness/readiness probes, per-request deadlines
Templating Autoescaping engine with inheritance, loops, filters — or Jinja2
Static files ETags, 304s, byte ranges, traversal and symlink protection, SPA fallback
OpenAPI 3.1 generated from the running code, Swagger UI and ReDoc
Config Typed settings from the environment, .env loader, production guardrails
Observability Structured JSON logs, correlation ids threaded through logs and error bodies
Testing TestClient over real WSGI and ASGI adapters, with uploads and a settable peer address
CLI run, check, routes, openapi, secret, new

Security posture

On by default: no tracebacks to clients, autoescaped templates, HttpOnly + SameSite=Lax cookies, confined static paths, body and multipart limits, X-Forwarded-* ignored unless the hop is trusted, HMAC-signed sessions with constant-time comparison, and a correlation id on every response.

Opt-in with your values: CORS, trusted hosts, HSTS and CSP, proxy networks, rate limits.

Not provided, on purpose: authentication, an ORM, an admin. See docs/guide/security.md for the full line — including what remains yours.


Performance

make bench — in-process through the real adapters, so this includes building the request rather than only dispatch. M-series laptop, Python 3.14, median of 20,000 iterations, best of three runs:

Route WSGI ASGI
plain text, def 21µs 110µs
JSON, def 28µs 115µs
typed params + validation, def 38µs 126µs
Depends, def 35µs 127µs
async def 78µs 75µs

Read the diagonal. A synchronous handler is 3–5× cheaper on WSGI, because the fast path never touches an event loop. An async handler is cheapest on ASGI, because there is no thread hop back to a background loop.

The ASGI column for def handlers is dominated by asyncio.to_thread — the honest cost of running blocking code without stalling the loop. Every ASGI framework pays it. SlowAPI is the one that lets you stop paying it by changing a deployment command rather than a codebase.

Against FastAPI

make bench-vs runs both frameworks on identical handlers through the raw ASGI protocol, with no test client involved, and compares their responses byte for byte before timing anything:

Route FastAPI (ASGI) SlowAPI (ASGI) SlowAPI (WSGI)
plain text 154.1µs 63.3µs 17.8µs
JSON 155.8µs 68.7µs 22.8µs
path + query validated 175.5µs 78.1µs 32.8µs
async def JSON 15.6µs 17.2µs

Read that as one result rather than four: the gap is the thread hop, not the framework. SlowAPI is 5–8× cheaper on synchronous routes because on WSGI it never makes the hop, not because its routing or validation is cleverer. Where no hop is involved, the two are level — FastAPI is ahead by about 8% on async def handlers, roughly what SlowAPI spends minting the request ID FastAPI does not.

So the honest claim is narrow. Synchronous code deployed on WSGI is substantially cheaper here; asynchronous code is a wash, and you should be choosing on features and ecosystem, where FastAPI is far ahead.

Framework overhead is rarely your bottleneck. A handler that opens a database connection has already spent more than every figure above. The point of the tables is not the absolute numbers; it is that the right protocol depends on your code, and here that is a decision you can defer and revisit.


Documentation

Quickstart Ten minutes to a working typed service
Guide Routing, requests, responses, validation, DI, modules, deployment
Dual-protocol dispatch How one handler serves two protocols
Architecture Module map and request lifecycle
Migrating From Flask, FastAPI, Express, or NestJS
Growth plan Why this exists and where it goes
FAQ Including the honest "should I use this?"
Examples Five runnable applications

Project status

0.1.0. The API is stable enough to build on and young enough to change before 1.0.

  • 214 tests, every dispatch scenario asserted on both protocols
  • CI across Python 3.10–3.13 on Linux, macOS and Windows
  • A CI job that installs with no extras and proves the zero-dependency claim
  • Every configuration error raises at import, with a message naming the fix

Read what is deliberately absent before adopting it. A framework that tells you what it will not do is easier to plan around than one that implies it will do everything.


Contributing

Two rules cover most of it:

  1. Tests run on both protocols. Use the parameterised client fixture.
  2. No new required runtime dependencies. Optional extras only.

See CONTRIBUTING.md and the invariants they come from.

git clone https://github.com/Somilg11/slowfw && cd slowfw
make install
make check        # lint, types, and the full suite

License

MIT. See LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

slowfw-0.1.0.tar.gz (226.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

slowfw-0.1.0-py3-none-any.whl (132.2 kB view details)

Uploaded Python 3

File details

Details for the file slowfw-0.1.0.tar.gz.

File metadata

  • Download URL: slowfw-0.1.0.tar.gz
  • Upload date:
  • Size: 226.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for slowfw-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e5fc254aec42da0722d3e94e47ca5d2d29c9ff3559e5adea1bf2cae6c5b2787a
MD5 4326f1071953d472903b638dfb6731f6
BLAKE2b-256 57296a604d23029ca8f7b3fea3e02572a4091381af11d07e5531af4e4bc02dea

See more details on using hashes here.

Provenance

The following attestation bundles were made for slowfw-0.1.0.tar.gz:

Publisher: release.yml on Somilg11/slowfw

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file slowfw-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: slowfw-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 132.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for slowfw-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6b6de58c053c8f6a793ef6267476fd1b9bf690ac39fd7e7ed1e1dd937a6a8cc1
MD5 628dc5edf0c58edc07215a6f50162300
BLAKE2b-256 48307702d2b05045e5f27c4cc588b9f08ae808f80c358d8c061077f0744bced2

See more details on using hashes here.

Provenance

The following attestation bundles were made for slowfw-0.1.0-py3-none-any.whl:

Publisher: release.yml on Somilg11/slowfw

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page