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
8 changes: 8 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ Each is a decorator that registers a route and returns the handler unchanged (so
- If no route matches, the response is **404** with a plain `Not Found` body (from the Rust dispatch layer).
- Non-`http` RSGI scopes are ignored in the current implementation (no response is sent for unknown `proto` values).

## Compiled routing snapshot

- OxyRoute now **auto-compiles** the route snapshot on the first HTTP request, so hot-path
matching uses the lock-free compiled tables even if `freeze()` was not called explicitly.
- Calling `freeze()` is still supported and remains the strict "no more route registration" switch.
- If routes are added before `freeze()`, OxyRoute invalidates the previous compiled snapshot and
lazily rebuilds it on the next request.

## OpenAPI and discovery

If OpenAPI is enabled, route registration also updates a minimal OpenAPI document. See [openapi.md](openapi.md).
Expand Down
10 changes: 10 additions & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ async fn send_python_error(
send_internal_error(protocol, method, path, err).await
}

fn ensure_compiled_snapshot(state: &Arc<RwLock<AppState>>) {
let mut st = state.write();
if st.compiled.is_none() {
st.compiled = Some(Arc::new(st.snapshot_routers()));
}
}

pub async fn run_rsgi(
state: Arc<RwLock<AppState>>,
scope: Py<PyAny>,
Expand Down Expand Up @@ -249,6 +256,9 @@ pub async fn run_rsgi(
return send_handler_map(&protocol, is_head, mapped).await;
}
}
// Auto-enable compiled route snapshot on first request to keep hot-path matching lock-free
// even when users forget to call `freeze()` explicitly.
ensure_compiled_snapshot(&state);
let route_out: Option<(usize, HashMap<String, String>)> = {
let st = state.read();
match match_route(&st, &method, &path) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ impl App {
m.insert(&path, idx)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e}")))?;
}
// Keep auto-compiled routing snapshots fresh when routes are added before explicit freeze().
st.compiled = None;
Ok(())
}

Expand Down
52 changes: 52 additions & 0 deletions tests/test_routing_autocompile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Issue #75: routing snapshot auto-compiles on first request."""

from __future__ import annotations

import asyncio

import httpx
from oxyroute import App


def test_routes_added_after_first_request_still_resolve() -> None:
app = App()

@app.get("/a")
def a() -> str:
return "a"

async def _run() -> None:
tr = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=tr, base_url="http://test") as c:
r1 = await c.get("/a")
assert r1.status_code == 200
assert r1.text == "a"

# This route is registered after first request/auto-compile snapshot.
@app.get("/b")
def b() -> str:
return "b"

r2 = await c.get("/b")
assert r2.status_code == 200
assert r2.text == "b"

asyncio.run(_run())


def test_autocompile_keeps_405_behavior() -> None:
app = App()

@app.post("/x")
def x() -> str:
return "ok"

async def _run() -> None:
tr = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=tr, base_url="http://test") as c:
r = await c.get("/x")
assert r.status_code == 405
allow = (r.headers.get("allow") or "").upper()
assert "POST" in allow

asyncio.run(_run())
Loading