RYNE
Fast, secure, full-stack Python backend framework with a native Rust core.
Write your entire application in pure Python. RYNE runs the HTTP server (HTTP/1.1 + HTTP/2, native TLS), routing, JSON, validation, the SQLite driver and cryptography in compiled Rust — and installing it never requires a Rust toolchain:
pip install ryne
Why RYNE
| Goal | How RYNE delivers it |
|---|---|
| Python-only install | Prebuilt wheels (CPython 3.10+, Windows / Linux / macOS, abi3) bundle the Rust core via PyO3. |
| Native Rust core | hyper + tokio event loop, trie router, JSON, schema validation, SQLite and argon2 — all native. |
| Developer experience | Django-style models, migrations, decorators, a CLI with hot reload, and a built-in test client. No Rust knowledge needed, ever. |
| Full-stack | REST API, WebSockets, Jinja2 templates, static files, uploads, ORM, migrations, authentication, revocable sessions, CORS — plus a built-in admin panel and a PostgreSQL backend. |
| Performance-first | Rust owns the socket, serializes responses, serves statics and runs WebSockets; Python is invoked exactly once per request. |
| Security-first | Native TLS (rustls), rate limiting, backpressure, signed CSRF, revocable sessions, argon2id, parameterized SQL, request IDs and access logs — safe defaults everywhere. |
Benchmarks
11 frameworks × 6 scenarios × 60 seconds per test, oha load generator,
50 concurrent connections, identical endpoints and payloads
(full methodology and complete tables).
| scenario | RYNE | Sanic | Starlette | Litestar | FastAPI | Flask | Django |
|---|---|---|---|---|---|---|---|
| json | 30 636 | 14 532 | 9 180 | 8 059 | 5 629 | 3 942 | 1 578 |
| plaintext | 27 829 | 12 881 | 9 098 | 7 546 | 6 447 | 4 281 | 925 |
| router | 27 472 | 10 984 | 8 168 | 6 861 | 4 641 | 3 995 | 988 |
| http | 22 333 | 12 506 | 7 347 | 6 159 | 4 316 | 3 929 | 1 281 |
| db (SQLite) | 7 348 | 2 162 | 2 104 | 2 520 | 1 625 | 1 488 | 1 323 |
| validation | 17 066 | 9 640 | 6 516 | 5 366 | 4 503 | 3 621 | 1 004 |
| peak RAM (db) | 38 MB | 79 MB | 73 MB | 204 MB | 87 MB | 115 MB | 125 MB |
How to read this. RYNE is the fastest full-featured Python framework in all six scenarios — 1.9–2.7× ahead of the runner-up (Sanic), 3.5–5.4× ahead of FastAPI, 7–30× ahead of Flask and Django — while using the least memory (now with WebSockets, templates, statics and uploads built in). Native Rust frameworks (Actix, Axum, Hyper, Rocket) are still 3–6× faster than RYNE: that is the honest price of running user code in Python, and the trade-off RYNE makes to stay a pure-Python framework.
Documentation
Everything below is the complete documentation, inlined — the same content as docs/ in the repository.
| Section | Contents |
|---|---|
| Quickstart | Install, first app in 5 commands |
| Tutorial | A small blog API end to end |
| Validation | Schemas, coercion, nested objects, fast paths |
| ORM and migrations | Django-style models, queries, migrations |
| PostgreSQL backend | Same ORM on postgres:// URLs |
| Auth and sessions | Users, login, rotation, logout everywhere |
| WebSockets | Native RFC 6455 in Rust, origin checks |
| Templates | Jinja2 integration, autoescaping, CSRF injection |
| Static files and uploads | Rust-served assets, multipart parsing |
| Admin panel | Zero-config CRUD for every model |
| Middleware, CORS, lifespan and background | Cross-origin requests, hooks, fire-and-forget |
| Security | Defaults, TLS, proxies, hardening checklist |
| Testing | TestClient, patterns |
| Configuration | Every setting, env vars, CLI |
| Performance | Benchmarks, methodology, tuning |
| The full stack | All of it as one code page |
| CLI reference | Every ryne command |
Quickstart
Install
pip install ryne
Prebuilt wheels (CPython 3.10+, Windows/Linux/macOS, abi3) bundle the whole Rust core — no compiler required.
Your first project
ryne startproject mysite
cd mysite
ryne makemigrations && ryne migrate
ryne run
Open http://127.0.0.1:8000 — done. ryne run --reload restarts the server
automatically on every *.py change while you develop.
Or a single file
# hello.py
from ryne import Ryne
app = Ryne()
@app.get("/hello/{name}")
async def hello(request, name: str):
return {"message": f"Hello, {name}!"}
app.run() # ryne run hello:app
Handlers return dict (JSON), str (plain text) or a Response object.
Route parameters arrive as keyword arguments.
CLI cheat sheet
ryne run [module:app] [--reload] [--host H] [--port P] [--debug]
[--tls-cert C --tls-key K]
ryne startproject <name> scaffold a project
ryne makemigrations [--name] generate a migration from model changes
ryne migrate apply pending migrations
ryne createsuperuser create an admin user
ryne shell [module:app] interactive shell with your app loaded
ryne check [module:app] validate configuration
Next steps
- Tutorial — build a small blog API
- Validation — schemas compiled to Rust
- WebSockets — real-time endpoints in pure Python
- Templates + Static files — server-rendered pages
- Security — what's on by default, how to deploy with TLS
Tutorial: a small blog API
This walks through a complete application: models, validation, auth, and a
few routes. The finished version lives in examples/demo/.
1. Project
ryne startproject blog && cd blog
2. Models
# models.py
from ryne.models import Model, CharField, IntField, ForeignKey, TextField
class Author(Model):
name = CharField(max_length=100)
class Article(Model):
title = CharField(max_length=200)
body = TextField(default="")
views = IntField(default=0)
author = ForeignKey(Author, null=True)
3. App and routes
# app.py
from ryne import Ryne, HTTPException
from models import Article, Author # noqa: E402
app = Ryne()
@app.get("/articles")
async def list_articles(request):
articles = await Article.objects.order_by("-id").limit(20)
return {"articles": [a.to_dict() for a in await articles.all()]}
@app.get("/articles/{id}")
async def get_article(request, id: int):
article = await Article.objects.get(id=int(id))
if article is None:
raise HTTPException(404, "no such article")
article.views += 1
await article.save()
return article.to_dict()
4. Validation
from ryne.validation import Schema, Str, Int, List
class CreateArticle(Schema):
title: Str(min_length=1, max_length=200)
body: Str(max_length=50_000) = ""
tags: List(Str(), max_items=10) = []
views: Int(ge=0) = 0
@app.post("/articles")
async def create_article(request):
data = CreateArticle.validate_body(request) # raises ValidationError -> 422
article = await Article.objects.create(**data)
return article.to_dict(), 201
validate_body(request) is the fast path — the body is parsed and validated
in Rust without a Python round trip. Malformed JSON raises ValueError,
schema violations raise ValidationError (rendered as 422 with all errors
accumulated).
5. Migrations
ryne makemigrations # writes migrations/0001_auto.py — review it
ryne migrate # applies atomically, records in ryne_migrations
6. Users and login
ryne createsuperuser --username ann
from ryne import HTTPException
from ryne.auth import authenticate, login, get_user, requires_login
@app.post("/login")
async def login_route(request):
data = request.json()
user = await authenticate(data["username"], data["password"])
if user is None:
raise HTTPException(401, "bad credentials")
await login(request, user) # rotates the session id server-side
return {"ok": True}
@app.post("/logout")
async def logout_route(request):
from ryne.auth import logout
await logout(request) # revokes the session server-side
return {"ok": True}
@app.get("/me")
@requires_login
async def me(request):
return {"user": (await get_user(request)).username}
Sessions are signed cookies backed by a server-side registry: logout kills
the session for every holder of that cookie, and logout_all(user_id) kills
all of a user's sessions.
7. Run it
ryne makemigrations && ryne migrate
ryne run --reload
curl -X POST localhost:8000/articles -d '{"title": "Hi"}'
# {"id": 1, "title": "Hi", "body": "", "views": 0, "author": null}
8. Test it
# test_blog.py
from ryne.test import TestClient
from app import app
def test_create_and_get():
with TestClient(app) as client:
r = client.post("/articles", json={"title": "Hi"})
assert r.status_code == 201
r = client.get(f"/articles/{r.json()['id']}")
assert r.json()["title"] == "Hi"
Run with pytest (pip install ryne[test]).
Where to next
- Validation for the full schema language
- ORM and migrations for queries and lookups
- Security to deploy this behind TLS
Validation
Schemas are declared in Python and compiled to Rust at import time — regexes pre-compiled, constraints native. Validation runs parse → coerce → validate → convert in native code and accumulates all errors.
Declaring a schema
import enum
from ryne.validation import Schema, Str, Int, Float, Bool, List, Object, Enum
class Color(enum.Enum):
RED = "red"
BLUE = "blue"
class Address(Schema):
city: Str(max_length=80)
class CreateArticle(Schema):
title: Str(min_length=1, max_length=200)
tags: List(Str(), max_items=10) = [] # default -> optional
views: Int(ge=0) = 0
rating: Float(gt=0, le=5) = 1.0
published: Bool() = False
color: Enum(Color) # -> Color member on output
address: Object(nested=Address) # nested schema
nickname: Str(max_length=30) = None # optional + nullable
Field kinds: Str, Int, Float, Bool, DateTime, Date, Email,
Url, Uuid, List, Object, Enum, Any.
Common options: min_length / max_length (strings), ge / le / gt /
lt / multiple_of (numbers), min_items / max_items (lists),
pattern (regex, linear-time — ReDoS-safe), choices.
Validating
data = CreateArticle.validate(payload) # dict/any object
data = CreateArticle.validate_body(request) # raw request body (fast path)
clean, errors = CreateArticle.try_validate(payload) # no raising
one = CreateArticle.validate_field("title", "Hi") # one field in isolation
- Coercion is safe by default:
"42"→42,"true"→True(documented, deliberate — HTML forms and query strings are strings). - Unknown fields are rejected (
forbid_unknown) with anunknown fielderror. ValidationErrorcarries.errors: a list of(path, message)with dotted paths and list indices (address.zip_code,tags[2]).validate_bodyraisesValueErrorfor malformed JSON (likerequest.json()) andValidationErrorfor schema violations.
Fast paths (performance)
@app.post("/articles")
async def create(request):
data = CreateArticle.validate_body(request) # parse+validate in Rust, GIL released
return {"ok": data["title"]}
validate_body(request) skips the Python-dict round trip entirely: the raw
body is parsed and validated in Rust while the GIL is released, so concurrent
requests validate in parallel. On the benchmarked 8-field schema it is
~2.7x faster than validate(request.json()).
Errors as JSON
ValidationError is an HTTPException (422) raised inside a handler; the
default renderer returns the accumulated errors as a summary string:
{"detail": "title: field required; views: must be greater than or equal to 0"}
exc.errors on the exception itself holds the structured list of
(path, message) tuples — use it in a custom renderer:
Custom error rendering
from ryne.exceptions import ValidationError
@app.on_error
def render_errors(request, exc):
if isinstance(exc, ValidationError):
return {"errors": [{"path": p, "message": m} for p, m in exc.errors]}, 422
return {"detail": exc.detail}, exc.status
ORM and migrations
A Django-style ORM over the bundled SQLite engine (WAL mode, read pool, one serialized writer, GIL released for every query) — or over PostgreSQL; see the PostgreSQL backend below.
Models
from ryne.models import Model, CharField, IntField, FloatField, BoolField, TextField, DateTimeField, ForeignKey, JSONField
class Author(Model):
name = CharField(max_length=100)
class Article(Model):
title = CharField(max_length=200)
body = TextField(default="")
views = IntField(default=0)
score = FloatField(default=0.0)
published = BoolField(default=False)
created_at = DateTimeField(auto_now_add=True)
author = ForeignKey(Author, null=True, on_delete="SET NULL")
meta = JSONField(default=dict)
The table name is the pluralized class name (articles); every model has an
auto-increment id primary key exposed as .pk.
CRUD
article = await Article.objects.create(title="Hi", views=0)
article = await Article.objects.get(id=1) # None when missing
article.views += 1
await article.save()
await article.delete()
await Article.objects.bulk_create([Article(title="a"), Article(title="b")])
Sync contexts (management commands, scripts) can call the sync variants:
Article.objects.create_sync(...), save_sync(), etc. — the server always
uses the async forms.
Queries
await Article.objects.filter(views__gte=10, title__contains="ry")
await Article.objects.exclude(published=False)
await Article.objects.filter(author__isnull=True)
await Article.objects.order_by("-created_at").limit(10).offset(5)
await Article.objects.count()
await Article.objects.first() # None when empty
await Article.objects.values("id", "title") # dicts, skips model construction
Supported lookups: exact (default), gt, gte, lt, lte, contains,
startswith, endswith, in, isnull. Every value is parameterized —
identifiers are validated against the model definition, so SQL injection is
structurally impossible.
Migrations
ryne makemigrations [--name initial] # diff models -> reviewable Python file
ryne migrate # apply pending, atomically, recorded
Generated files live in migrations/ and are plain Python (operations = [...])
— review and edit them like any code. Adding tables/columns is automated;
SQLite cannot ALTER a column, so type changes/removals produce a warning and
expect a manual edit of the generated file.
Applied migrations are recorded in the ryne_migrations table; re-running
ryne migrate is a no-op when nothing is pending.
PostgreSQL backend
The ORM runs on two engines: bundled SQLite (the default) and PostgreSQL.
The engine is selected by the database URL — models, lookups, migrations
and the admin panel work identically on both:
from ryne import Ryne, Settings
app = Ryne(settings=Settings(
database="postgres://user:pass@localhost:5432/ryne", # postgres:// → PostgreSQL
db_pool_size=8, # pool size (RYNE_DB_POOL)
))
- URLs starting with
postgres://orpostgresql://select the native PostgreSQL driver compiled into the wheel — nopsycopgor any other Python driver is needed. Anything else is treated as a SQLite file path. - The pool is bounded (
db_pool_size, default 8); queries are parameterized and run with the GIL released, exactly like SQLite. ryne.db.backend()reports the active engine ('sqlite'/'postgres');ryne.db.set_default(handle)overrides the default database explicitly.- Migrations work the same way:
ryne migraterecords applied migrations in theryne_migrationstable on either engine.
A runnable example lives in
examples/pg_demo — point
RYNE_TEST_PG at your server and run it.
Auth and sessions
The model
ryne.auth.User is a built-in model (users table) with argon2id password
hashing done in native code:
from ryne.auth import User
user = await User.objects.create(username="ann")
user.set_password("wonderland")
await user.save()
user.check_password("wonderland") # True
Login / logout
from ryne import HTTPException
from ryne.auth import authenticate, login, logout, logout_all, get_user, requires_login
@app.post("/login")
async def login_route(request):
data = request.json()
user = await authenticate(data["username"], data["password"])
if user is None:
raise HTTPException(401, "bad credentials")
await login(request, user)
return {"ok": True}
@app.post("/logout")
async def logout_route(request):
await logout(request)
return {"ok": True}
@app.get("/me")
@requires_login
async def me(request):
return {"user": (await get_user(request)).username}
authenticate verifies with argon2id in Rust (GIL released) and runs a
dummy verification for unknown usernames, so response timing does not leak
which usernames exist.
How sessions work
A session is a signed cookie (HMAC-SHA256, constant-time verified in
Rust) carrying a JSON payload plus _exp. On top of that, RYNE keeps a
server-side registry of session ids (enabled by default via
session_store=True):
- Rotation —
login()issues a fresh session id and revokes the old one, so a session fixed before authentication cannot be reused. - Revocation —
logout()revokes the id server-side: every holder of a copy of that cookie is logged out immediately, not just at cookie expiry. - Logout everywhere —
await logout_all(user.pk)revokes all of a user's live sessions. - Fail closed — a cookie whose id is not in the registry (server restart, revoked, expired) is treated as anonymous. The registry is in-memory by design: restarting the server invalidates all sessions.
Set session_store=False to fall back to purely stateless signed cookies
(survives restarts, but revocation stops working).
Cookies are HttpOnly; SameSite=Lax; Secure is added automatically when
the server runs TLS (or set session_secure_cookies=True).
Decorators
from ryne.auth import requires_login, requires_superuser
@app.get("/admin")
@requires_superuser
async def admin_area(request):
...
401 for anonymous requests, 403 for authenticated non-superusers.
CSRF
Form submissions (application/x-www-form-urlencoded and
multipart/form-data) require a CSRF token; JSON APIs are exempt. In a
template/form, embed the token from the ryne_csrf cookie into a
csrfmiddlewaretoken field or an x-csrf-token header:
<form method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
...
</form>
The token is signed (nonce.timestamp.hmac over the app's secret key) and
expires after csrf_token_age (1 day) — an attacker who can plant cookies on
a sibling domain still cannot forge a valid token. See
Security for the full model.
WebSockets
RYNE implements WebSockets natively in Rust (RFC 6455: framing, masking,
fragmentation, pings, close handshakes, message limits) — Python only sees
messages. HTTP/1.1 upgrades, WebSockets over HTTP/2 (RFC 8441 extended
CONNECT) and TLS (wss:// via native TLS) are supported.
A handler
@app.websocket("/ws")
async def chat(ws):
await ws.send_text("welcome!")
async for message in ws: # str for text, bytes for binary
await ws.send_text(f"echo: {message}")
ws is a ryne.websockets.WebSocket:
| API | Meaning |
|---|---|
await ws.recv() |
Next message (str/bytes), or None after close |
await ws.send_text(str) / await ws.send_bytes(bytes) |
Queue a frame (never blocks; raises when closed or the send buffer is full) |
await ws.close(code=1000) |
Begin the close handshake |
async for message in ws |
Iterate until the peer disconnects |
ws.closed |
True after the close handshake started |
The handler's cleanup is guaranteed: when it returns (or raises), the socket
is closed automatically. Errors close with 1011.
@app.websocket("/ws/rooms/{room}")
async def room(ws, room: str):
...
Where the protocol runs
- Handshake — validated in Rust:
Sec-WebSocket-Key→Sec-WebSocket-Accept(SHA-1 per RFC 6455), version check, origin check (see below). - Frames — read/written in Rust on the connection's tokio task. Pings are answered automatically; fragmented messages are reassembled before delivery.
- Python —
recv()awaits an asyncio future that the Rust reader resolves; sends go into a bounded channel (1024frames). A slow consumer that never callsrecv()gets disconnected with1008once its buffer (64 messages) overflows.
Origin policy (CSRF for WebSockets)
Browsers always send Origin on the handshake. RYNE rejects a handshake when
the Origin is present and matches neither the request's Host nor
ws_allowed_origins:
Settings(ws_allowed_origins=["https://app.example.com"])
# env: RYNE_WS_ORIGINS="https://app.example.com,https://staging.example.com"
Absent Origin (non-browser clients, curl, tests) is always allowed.
Cross-origin handshakes get 403.
Limits
ws_max_message(default 1 MiB): messages (across fragments) larger than this end the session with1009.- A message size below the limit but larger than the frame buffer is handled incrementally; control frames (ping/pong/close, ≤125 bytes) are bounded by the protocol.
Testing WebSockets
The websockets client library (pip install ryne[test]) works directly:
import asyncio
import websockets
def test_echo():
async def go():
async with websockets.connect("ws://127.0.0.1:8000/ws") as ws:
await ws.send("hi")
assert await ws.recv() == "echo: hi"
asyncio.run(go())
See examples/chat_demo/ for a complete multi-client chat.
Templates
RYNE integrates Jinja2 — the same engine Flask uses — as an optional dependency. Autoescaping for HTML/XML is on by default.
pip install ryne[templates]
Setup the templates directory
from ryne import Ryne
app = Ryne()
app.templates("templates") # directory next to your app module
Rendering
@app.get("/")
async def home(request):
return app.render(request, "home.html", title="Home", posts=posts)
app.render(request, name, **context):
- returns an
HTMLResponse(autoescaping on), - injects
requestandcsrf_tokeninto the context automatically,
<!-- templates/home.html -->
{% extends "base.html" %}
{% block content %}
<h1>{{ title }}</h1>
{% for post in posts %}
<article><h2>{{ post.title }}</h2><p>{{ post.body }}</p></article>
{% endfor %}
<form method="post" action="/comment">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<textarea name="body"></textarea>
<button>post</button>
</form>
{% endblock %}
Everything Jinja2 supports works: {% extends %}, {% include %}, filters,
macros, custom globals (app._templates.environment.globals.update(...)).
Forms and CSRF
HTML forms are CSRF-protected (see Security); the token is
injected as csrf_token in every template context — put it in the form:
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
File uploads through forms
<form method="post" action="/upload" enctype="multipart/form-data">
<input type="file" name="avatar">
<button>upload</button>
</form>
@app.post("/upload")
async def upload(request):
files = request.files["avatar"] # list of {"filename", "content_type", "data"}
form = request.form() # text fields
(Path("uploads") / files[0]["filename"]).write_bytes(files[0]["data"])
return {"ok": True, "saved": files[0]["filename"]}
Serving static assets
Pair templates with a static mount — files are served by Rust with ETag / Cache-Control / 304 support and traversal protection:
app.mount_static("/static/", "static")
<link rel="stylesheet" href="/static/style.css">
See Static files and uploads for details, and
examples/templates_demo/ for a runnable app.
Static files and uploads
Static mounts
app.mount_static("/static/", "static")
Files under static/ are served directly by the Rust core — a static
request never touches Python, no matter how many you serve:
- Content types for the common web formats (html, css, js, images,
fonts, wasm, media...) — unknown extensions default to
application/octet-stream. - ETag (
mtime-size) +Cache-Control: public, max-age=...(static_cache_seconds, default 3600) +If-None-Match→ 304. - HEAD requests work (body suppressed per spec).
- Range requests are not supported (v1) — media players needing seeking should use a CDN/object store in front.
Safety
Mounts are traversal-safe by construction: after percent-decoding, path
segments that are .., absolute, or dot-files (.env, .git/...) are
rejected with 404 before the filesystem is touched. Filenames uploaded by
users are reduced to their basename on save-side handling too.
Settings(static_cache_seconds=86400) # aggressive caching for immutable assets
Parsing forms and uploads
multipart/form-data bodies are parsed in Rust (no Python round trip):
@app.post("/upload")
async def upload(request):
form = request.form() # {"field": "value", ...} (urlencoded or multipart text parts)
files = request.files # {"field": [{"filename", "content_type", "data"}, ...]}
...
request.form()— cached dict of text fields (works for plainapplication/x-www-form-urlencodedbodies too).request.files— dict offield name → list of files; one field may carry several files (<input type="file" multiple>).- Each file is
{"filename": str, "content_type": str, "data": bytes}. - The whole body is already bounded by
max_body(default 1 MiB — raise it for large uploads:Settings(max_body=64 * 1024 * 1024)).
CSRF applies to form posts exactly like anywhere else (JSON APIs are exempt):
include csrfmiddlewaretoken or the x-csrf-token header.
Layouts
A typical project:
myapp/
app.py
templates/ # Jinja2 (app.templates("templates"))
base.html
home.html
static/ # app.mount_static("/static/", "static")
style.css
app.js
See examples/templates_demo/ for a runnable example with both.
Admin panel
An opt-in, zero-dependency admin panel: CRUD for every registered model, server-rendered HTML, no JavaScript and no per-model configuration — the panel renders straight from the model registry:
from ryne.admin import setup_admin
setup_admin(app, "/admin/") # any prefix works
- Dashboard with per-model row counts, paginated lists with text search, add/edit forms generated from the field metadata, and delete buttons.
- Access requires a logged-in superuser: anonymous requests get
401, authenticated non-superusers get403. Create the first superuser withryne createsuperuser --username ann. - Every form carries the CSRF token; responses are marked
noindex.
Works on both the SQLite and the PostgreSQL backend.
Middleware, CORS, lifespan and background tasks
CORS — preflight answering and access-control headers:
from ryne.cors import CORSMiddleware
app.middlewares.append(
CORSMiddleware(
allow_origins=["https://app.example.com"],
allow_methods=["GET", "POST"],
allow_credentials=True, # requires explicit origins, not '*'
max_age=600,
)
)
Preflight OPTIONS requests are answered immediately (204); ordinary
responses get Access-Control-Allow-Origin and friends. allow_origins
defaults to every origin; allow_credentials=True combined with * is
rejected at construction time. With CORS installed, OPTIONS dispatches to
the pipeline instead of the built-in auto-OPTIONS.
Lifespan hooks — run before serving / after serving:
@app.on_startup
async def open_pool(): ...
@app.on_shutdown
async def close_pool(): ...
Startup failures abort serving; shutdown hooks run only after a completed startup. Both accept sync or async functions.
Background tasks — fire-and-forget, started immediately (not after the response, unlike FastAPI's BackgroundTasks):
app.background(send_email, user_id=user.pk) # sync → daemon thread
app.background(notify_ws(message)) # async → framework loop
Async callables run on the framework's event loop, sync ones on a daemon thread; errors are logged, never raised into the caller. Usable from sync and async handlers alike.
Security
RYNE's philosophy: safe defaults, opt-in escape hatches. This page lists what is enforced by default, how to deploy securely, and the hardening checklist.
On by default
| Protection | How it works |
|---|---|
| Security headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, COOP, Permissions-Policy on every response (user values are never clobbered) |
| Sessions | Signed cookies (HMAC-SHA256, constant-time in Rust) + server-side registry: rotation on login, revocation on logout, logout_all |
| Passwords | argon2id (OWASP-level defaults), verified in native code, timing-equalized against unknown users |
| CSRF | Signed double-submit tokens for form posts; JSON APIs exempt; multipart supported |
| Rate limiting | Per-IP token bucket in Rust → 429 + Retry-After (off unless rate_limit is set; burst defaults to the rate) |
| Backpressure | max_inflight caps concurrent dispatches → immediate 503 instead of unbounded queues |
| Body limits | 1 MiB default, enforced mid-stream — oversized bodies are cut off at 413, never buffered |
| Slowloris | Idle timeout between body frames (body_timeout, 30s) → 408 |
| Connection cap | max_connections (10 000) — extra connections are refused |
| SQL injection | Every value parameterized; identifiers validated against model definitions |
| ReDoS | Router is a trie (no regexes); validation regexes use Rust's linear-time engine |
| WebSocket origins | Handshakes with a browser Origin that mismatches the Host / ws_allowed_origins get 403 (CSRF for WebSockets) |
| Static mounts | Path traversal, absolute paths and dot-files rejected before touching the filesystem; uploaded filenames reduced to basenames |
| Error hygiene | debug=False never leaks internals; unhandled exceptions log a traceback in debug, a generic 500 in production |
| Observability | x-request-id on every response + one access-log line per request (access_log) |
Native TLS
RYNE terminates TLS itself (rustls, HTTP/2 via ALPN):
ryne run --tls-cert cert.pem --tls-key key.pem
# or: RYNE_TLS_CERT / RYNE_TLS_KEY, or settings tls_cert=/tls_key=
When TLS is on, session/CSRF cookies get the Secure flag automatically.
For a quick self-signed certificate:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout key.pem -out cert.pem -subj "/CN=localhost"
Behind a reverse proxy
If nginx/traefik terminates TLS for you, declare it as trusted so client IPs
resolve correctly for rate limiting and request.client:
Settings(trusted_proxies=["127.0.0.1", "10.0.0.5"])
# env: RYNE_TRUSTED_PROXIES="127.0.0.1,10.0.0.5"
Only requests arriving from a trusted proxy consult X-Forwarded-For
(rightmost untrusted entry wins); direct connections always use the TCP peer.
Secrets
Set a strong secret_key in production (RYNE_SECRET_KEY or settings) —
it signs sessions and CSRF tokens. Without it a random key is generated per
start (sessions do not survive restarts). ryne check warns about missing,
short or placeholder keys:
python -c "import secrets; print(secrets.token_urlsafe(48))"
ryne check
Opt-in hardening
Settings(
hsts=True, # Strict-Transport-Security (only with TLS!)
csp=True, # Content-Security-Policy: default-src 'self'
)
Override any default header per-response: setting the header yourself always wins over the framework default.
Hardening checklist
-
RYNE_SECRET_KEYset to a long random value - TLS enabled (
--tls-cert/--tls-key) or a trusted proxy in front -
debug=False(the default) -
rate_limitset for public endpoints (e.g.100) -
trusted_proxiesconfigured when behind a proxy -
hsts=Trueonce HTTPS is stable -
csp=Truefor anything serving HTML -
session_max_agetuned (default 14 days; shorter for sensitive apps) -
ryne checkgreen in CI
Testing
RYNE ships a built-in test client — no ASGI adapter, no mocks: your tests hit the real server (hyper, routing, sessions, CSRF, limits) on an ephemeral local port.
Install the test extras
pip install ryne[test] # brings httpx
Usage
from ryne.test import TestClient
from app import app # your Ryne application
def test_hello():
with TestClient(app) as client:
r = client.get("/hello/world")
assert r.status_code == 200
assert r.json() == {"message": "Hello, world!"}
def test_create():
with TestClient(app) as client:
r = client.post("/articles", json={"title": "Hi"})
assert r.status_code == 201
def test_auth_flow():
with TestClient(app) as client:
client.post("/login", json={"username": "ann", "password": "wonderland"})
assert client.get("/me").status_code == 200
The client is an httpx.Client under the hood — every httpx feature
(cookies, JSON, multipart, timeouts) works. Entering the context starts the
server on a free port; leaving it stops supervising.
Notes and patterns
- One server per process: RYNE's core allows a single live server per
process, so one
TestClientper test function (fresh state per test) is the natural pattern. - Settings per test: pass overrides via
TestClient(app, rate_limit=10)— anySettingsattribute works. - CSRF in tests: form posts need a token; JSON posts do not. Grab the
cookie if you test forms:
client.get("/") # mints the ryne_csrf cookie token = client.cookies["ryne_csrf"] client.post("/form", data={"csrfmiddlewaretoken": token})
- Database: point
Settings(database=...)at a tmp_path file per test and run migrations in a fixture.
WebSockets in tests
The websockets client (pip install ryne[test]) connects to the live port:
import asyncio
import websockets
from ryne.test import TestClient
from app import app
def test_echo():
with TestClient(app) as client:
async def go():
async with websockets.connect(f"ws://127.0.0.1:{client.port}/ws") as ws:
await ws.send("hi")
assert await ws.recv() == "echo: hi"
asyncio.run(go())
What RYNE itself tests
The framework's own suite (python -m pytest) covers the dispatch paths,
HTTP/2, TLS, rate limiting, backpressure, sessions/CSRF, the ORM and
migrations — 86 tests including live-server and subprocess scenarios.
Configuration
Everything is configured through Settings (per-app), environment variables
(process-wide) and CLI flags (per-invocation). Precedence: CLI flag →
Settings(...) override → env var → default.
from ryne import Ryne, Settings
app = Ryne(settings=Settings(debug=False, port=8080))
Unknown settings raise a ValueError listing the offenders — typos fail
loudly.
Reference
Server
| Setting | Env | Default | Meaning |
|---|---|---|---|
host |
RYNE_HOST |
127.0.0.1 |
Bind address (use 0.0.0.0 for containers) |
port |
RYNE_PORT / PORT |
8000 |
Listen port |
workers |
RYNE_WORKERS |
CPU count | Tokio worker threads |
debug |
RYNE_DEBUG |
False |
Tracebacks on stderr + error details (never in production) |
max_body |
RYNE_MAX_BODY |
1 MiB | Request-body cap, enforced mid-stream |
tls_cert / tls_key |
RYNE_TLS_CERT / RYNE_TLS_KEY |
"" |
PEM paths; both set → native TLS (HTTP/2 via ALPN) |
Protection
| Setting | Env | Default | Meaning |
|---|---|---|---|
rate_limit |
RYNE_RATE_LIMIT |
None | Requests/second per client IP; 0 disables |
rate_burst |
RYNE_RATE_BURST |
rate |
Bucket depth (how many requests can land at once) |
max_inflight |
RYNE_MAX_INFLIGHT |
2048 | Concurrent Python dispatch cap; 0 disables |
max_connections |
RYNE_MAX_CONNECTIONS |
10000 | Concurrent connection cap; 0 disables |
body_timeout |
RYNE_BODY_TIMEOUT |
30 s | Idle timeout between body frames (slowloris) |
trusted_proxies |
RYNE_TRUSTED_PROXIES |
[] |
Proxy IPs allowed to set X-Forwarded-For |
access_log |
RYNE_ACCESS_LOG |
True |
One log line per request on stderr |
Security headers & sessions
| Setting | Env | Default | Meaning |
|---|---|---|---|
security_headers |
RYNE_SECURITY_HEADERS |
True |
nosniff / DENY / Referrer-Policy / COOP / Permissions-Policy |
hsts |
RYNE_HSTS |
False |
Strict-Transport-Security (enable with TLS) |
csp |
RYNE_CSP |
False |
Content-Security-Policy: default-src 'self' |
secret_key |
RYNE_SECRET_KEY |
random | HMAC key for sessions/CSRF (set in production) |
session_backend |
RYNE_SESSION_BACKEND |
cookie |
cookie or none |
session_cookie |
RYNE_SESSION_COOKIE |
ryne_session |
Cookie name |
session_max_age |
RYNE_SESSION_MAX_AGE |
14 days | Cookie + server-registry lifetime |
session_secure_cookies |
RYNE_SESSION_SECURE |
auto | Secure flag (auto-on with TLS) |
session_store |
RYNE_SESSION_STORE |
True |
Server-side revocation/rotation registry |
csrf_enabled |
RYNE_CSRF |
True |
Signed double-submit CSRF for forms |
csrf_token_age |
RYNE_CSRF_TOKEN_AGE |
1 day | CSRF token lifetime |
ws_allowed_origins |
RYNE_WS_ORIGINS |
[] |
Extra WebSocket origins (same-host always allowed) |
ws_max_message |
RYNE_WS_MAX_MESSAGE |
1 MiB | WebSocket message cap (fragments included) |
static_cache_seconds |
RYNE_STATIC_CACHE |
3600 | Cache-Control for mount_static() |
Performance tuning
| Setting | Env | Default | Meaning |
|---|---|---|---|
sync_pool |
RYNE_SYNC_POOL |
False |
Run sync handlers on a bounded blocking pool (for slow/blocking handlers) |
sync_pool_size |
RYNE_SYNC_POOL_SIZE |
64 | Blocking-pool bound when sync_pool=True |
database |
RYNE_DATABASE |
db.sqlite3 |
SQLite path (empty string disables the ORM) |
db_pool_size |
RYNE_DB_POOL |
8 | SQLite read-connection pool |
CLI flags (ryne run)
--host H --port P --debug --reload --tls-cert C --tls-key K
--reload watches *.py files in the project (watchfiles when installed,
otherwise mtime polling) and restarts the server on changes.
Performance
Numbers
11 frameworks × 6 scenarios × 60 seconds per test, oha load generator,
50 concurrent connections, identical endpoints and payloads — full
methodology and tables in BENCHMARKS.md.
RYNE vs the fastest established Python frameworks (requests/second, higher is better):
| scenario | RYNE | Sanic | Starlette | FastAPI | Flask | Django |
|---|---|---|---|---|---|---|
| json | 30 636 | 14 532 | 9 180 | 5 629 | 3 942 | 1 578 |
| plaintext | 27 829 | 12 881 | 9 098 | 6 447 | 4 281 | 925 |
| router | 27 472 | 10 984 | 8 168 | 4 641 | 3 995 | 988 |
| http | 22 333 | 12 506 | 7 347 | 4 316 | 3 929 | 1 281 |
| db (SQLite) | 7 348 | 2 162 | 2 104 | 1 625 | 1 488 | 1 323 |
| validation | 17 066 | 9 640 | 6 516 | 4 503 | 3 621 | 1 004 |
| peak RAM (db) | 38 MB | 79 MB | 73 MB | 87 MB | 115 MB | 125 MB |
RYNE is the fastest full-featured Python framework in all six scenarios, 1.9–2.7x ahead of the runner-up, 3.5–5.4x ahead of FastAPI, and 16–30x ahead of Django — while using the least memory. (Native Rust frameworks like Actix remain 3–6x faster still: that is the honest price of running user code in Python.)
Where the speed comes from
- Rust owns the socket. hyper + tokio parse HTTP, route, apply limits and serialize JSON; Python is invoked exactly once per request.
- Deferred response serialization. Handlers returning
dict/strskip Pythonjson.dumps— the Rust boundary serializes with serde directly. - GIL-free validation.
Schema.validate_body(request)parses and validates raw bytes in Rust with the GIL released. - Cached request views. Headers, cookies, body and parsed JSON convert to Python exactly once per request.
- Inline sync dispatch. Short sync handlers run directly under the GIL on
tokio workers — no channel hops.
sync_pool=Truemoves slow handlers onto a bounded executor instead. - SQLite done right. WAL readers run in parallel on a pool; a single
serialized writer eliminates
SQLITE_BUSY; the GIL is released for every query.
Tuning checklist
- Return
dictfrom handlers (hits the Rust serialization fast path). - Use
Schema.validate_body(request)on hot POST endpoints. - Slow/blocking handlers: enable
sync_pool=True, or make themasync. workersdefaults to CPU count; containers should leave it be.- Behind a proxy: set
trusted_proxiesso rate limiting keys on real IPs. access_log=Falseshaves the log write off ultra-hot paths.
Reproducing
python benches/run_bench.py --suite ryne # RYNE only
python benches/run_bench.py --suite all # every framework
python benches/report.py # aggregate results/*.json
The harness falls back to a built-in asyncio load generator when oha is
not installed. CI runs a shorter suite on every push.
The full stack
Validation — compiled to Rust at import time
import enum
from ryne import HTTPException
from ryne.validation import Schema, Str, Int, List, Enum, Object
class Color(enum.Enum):
RED = "red"
BLUE = "blue"
class Address(Schema):
city: Str(max_length=80)
class CreateArticle(Schema):
title: Str(min_length=1, max_length=200)
tags: List(Str(), max_items=10) = []
views: Int(ge=0) = 0
color: Enum(Color) # -> Color.RED member on output
author: Object(nested=Address) # nested schema
nickname: Str(max_length=30) = None # optional + nullable
@app.post("/articles")
async def create_article(request):
data = CreateArticle.validate_body(request) # parse+validate in Rust, GIL-free
...
ORM + migrations — Django-style
from ryne.models import Model, CharField, IntField, ForeignKey
class Article(Model):
title = CharField(max_length=200)
views = IntField(default=0)
author = ForeignKey(Author, null=True)
article = await Article.objects.create(title="Hi")
articles = await Article.objects.filter(views__gte=1).order_by("-id").limit(10)
ryne makemigrations # schema diff -> reviewable Python migration files
ryne migrate # applied atomically, recorded in ryne_migrations
SQLite ships compiled inside the wheel: WAL mode, a read connection pool and
a single serialized writer connection (in-process writes never fight over
SQLite's one-writer lock). The GIL is released during every query.
Point database at a postgres:// URL and the same ORM runs on PostgreSQL — see the PostgreSQL backend.
Auth & revocable sessions
from ryne.auth import authenticate, login, logout, logout_all, get_user, requires_login
@app.post("/login")
async def login_route(request):
user = await authenticate(request.json()["username"], request.json()["password"])
if user is None:
raise HTTPException(401)
await login(request, user) # rotates the server-side session id
return {"ok": True}
@app.post("/logout")
async def logout_route(request):
await logout(request) # revokes it server-side
return {"ok": True}
@app.get("/me")
@requires_login
async def me(request):
return {"user": (await get_user(request)).username}
Sessions are signed cookies (HMAC-SHA256, constant-time verification in
Rust) backed by a server-side registry: login rotates the session id, so
stolen pre-auth cookies are useless; logout revokes; logout_all(user_id)
kills every session of a user. Passwords are hashed with argon2id in native
code; unknown usernames cost the same as known ones (no timing oracle).
Security defaults
- Native TLS (rustls, HTTP/2 via ALPN):
ryne run --tls-cert C --tls-key K X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy,COOPon every response (opt-in HSTS and CSP)- Signed CSRF tokens for form submissions (JSON APIs unaffected)
- Per-IP rate limiting (sharded token bucket in Rust) →
429+Retry-After - Backpressure:
max_inflightcaps concurrent dispatches → instant503 - Slowloris body timeout + connection cap
- 1 MiB request-body limit, enforced mid-stream in Rust
x-request-idon every response, access log, tracebacks in debugDEBUG=Falseby default — unexpected errors never leak internals- Trusted-proxy aware client IPs (
trusted_proxies)
For blocking sync workloads: settings.sync_pool = True runs sync handlers
on a bounded blocking executor (sync_pool_size, default 64) so a slow
handler can never starve the async runtime.
WebSockets, templates, statics — the full stack
@app.websocket("/ws/{room}")
async def chat(ws, room: str):
async for message in ws:
await ws.send_text(f"{room}: {message}")
@app.get("/")
async def page(request):
return app.render(request, "index.html", posts=posts) # Jinja2
app.mount_static("/static/", "static") # served by Rust
Origin-checked handshakes, RFC 6455 framing, multipart uploads
(request.files) and typed route params ({id:int}) are all built in.
CLI reference
ryne startproject <name> scaffold a project
ryne run [module:app] run the server (--reload, --host, --port,
--debug, --tls-cert/--tls-key)
ryne makemigrations [--name] generate a migration from model changes
ryne migrate apply pending migrations
ryne createsuperuser create an admin user (--username)
ryne shell [module:app] interactive shell with your app loaded
ryne check [module:app] validate configuration
ryne version print the installed version
Development
python -m venv .venv && .venv/Scripts/pip install -e '.[dev]'
python -m maturin develop # build the Rust core into the venv
python -m pytest # full test suite
cargo test --workspace # Rust unit tests
python -m ruff check . # lint
See CHANGELOG.md for the release history — 0.4.x shipped WebSockets, templates, statics, uploads, the admin panel and the PostgreSQL backend.
License
MIT — see LICENSE.
Metadata
Release files for ryne 0.4.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ryne-0.4.4.tar.gz | 4.2 MB | Without provenance | Details for ryne-0.4.4.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ryne-0.4.4-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details for ryne-0.4.4-cp310-abi3-win_amd64.whl |
Total release size: 7.8 MB
Release files / ryne-0.4.4.tar.gz
| Download URL | ryne-0.4.4.tar.gz |
|---|---|
| Size | 4.2 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e42930fa72bd380c751704e6a346abec1ff3862efa661a9b38385070342d950c
|
|
BLAKE2b-256 checksum How to use checksums |
f1c61750e7f962ad37c50d67914a920a2d14a04d629f15f1baefac05d848e771
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / ryne-0.4.4-cp310-abi3-win_amd64.whl
| Download URL | ryne-0.4.4-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 3.6 MB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
c822a2e977acbd00b8e7f793aa91957f3862f608f43986654ac69566b72effcf
|
|
BLAKE2b-256 checksum How to use checksums |
c6ad7d0e76a3933307b0f00058276a5d08358872496d28e3b2ac94bbcf2c6f2e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Security for ryne 0.4.4
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.