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
4 changes: 2 additions & 2 deletions .github/ISSUE_BACKLOG/PRIORITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
| 8 | [08.md](bodies/08.md) | **JWK / JWKS** — [GitHub #8](https://github.com/QueryaHub/OxyRoute/issues/8). |
| 9 | [09.md](bodies/09.md) | **OpenAPI depth** (optional) — `$ref` / `$defs`. |
| 21 | [21.md](bodies/21.md) | **Sub-routers** — [GitHub #46](https://github.com/QueryaHub/OxyRoute/issues/46). |
| 23 | [23.md](bodies/23.md) | **HTTPException + global exception handlers** — [GitHub #48](https://github.com/QueryaHub/OxyRoute/issues/48). |
| 24 | [24.md](bodies/24.md) | **CORS helper** — [GitHub #49](https://github.com/QueryaHub/OxyRoute/issues/49). |
| 28 | [28.md](bodies/28.md) | **CSRF** (optional) — [GitHub #53](https://github.com/QueryaHub/OxyRoute/issues/53). |
| 29 | [29.md](bodies/29.md) | **Security headers preset** — [GitHub #54](https://github.com/QueryaHub/OxyRoute/issues/54). |
Expand All @@ -36,11 +35,12 @@ This file tracks **priority tiers** for items in [bodies/](bodies/). The **next
- **CI / release / docs / PyO3:** 13, 14, 15, 16
- **Lifespan / `app.state`:** 18 (see `App.state`, `examples/rsgi_lifespan_app.py`, `docs/rsgi.md`)
- **Form bodies:** 22 / [#47](https://github.com/QueryaHub/OxyRoute/issues/47) — `read_form_body`, `form` / `files` kwargs, `docs/handlers.md`
- **HTTPException:** 23 / [#48](https://github.com/QueryaHub/OxyRoute/issues/48) — `oxyroute.exceptions`, `docs/handlers.md` (per-type `register_exception_handler` not in scope)

## Roadmap phasing (summary)

1. **P0:** 4, 17; **18** / **#47 (form)** done (order flexible).
2. **P1:** 8, 46, 48, 49, 53, 54; 9 as polish.
2. **P1:** 8, 46, 49, 53, 54; **#48** done — `HTTPException`; 9 as polish.
3. **Research:** 50, 51, 52 — as capacity allows.

[← Back to README](README.md)
2 changes: 1 addition & 1 deletion docs/feature.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
| **Sub-routers / `include_router`** | Нет | Один плоский `App`; нет префиксов и вложенных без ручного дублирования путей. |
| **`Mount` / static files** | Нет | Отдача `/static` из каталога — обычно отдельный слой (nginx) или Starlette StaticFiles. |
| **Host-based routing** | Нет | |
| **Глобальные exception handlers** | Нет | Только 500 с JSON; нет `@app.exception_handler` и иерархии исключений. |
| **Глобальные exception handlers** | Частично | `HTTPException` → нужный статус/JSON (см. [handlers](handlers.md)); **нет** `register_exception_handler` / иерархии как в FastAPI — [#48](https://github.com/QueryaHub/OxyRoute/issues/48). |
| **Middleware-цепочка** | Один хук | Нет порядка нескольких middleware и `on_request` / `on_response` как в ASGI-стеке. |

---
Expand Down
23 changes: 22 additions & 1 deletion docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,31 @@ For precise behavior and edge cases, refer to the implementation in the reposito

## Errors in handlers and dependencies

If a **dependency factory** or the **route handler** raises a Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients).
### `HTTPException` (non-500 responses)

Raise **`HTTPException`** from `oxyroute` to return a specific **status code** and JSON body without a `try` / `except` at every call site:

```python
from oxyroute import HTTPException

raise HTTPException(404, "not found")
raise HTTPException(422, {"errors": [...]}) # body is this JSON value
raise HTTPException(400, "bad", headers={"X-Reason": "check"}) # optional extra headers
```

- String or other non-`dict` / non-`list` **`detail`** becomes `{"detail": ...}`.
- **`detail=None`** uses a short default message derived from the status (HTTP reason phrase when available).
- **`Content-Type`** is set to `application/json; charset=utf-8` unless you supply a `content-type` in **`headers`**.
- Works from **route handlers**, **dependencies**, **middleware** (when they run in Python), and when **mapping the return value** to a response.

### Other exceptions → 500

If a **dependency factory** or the **route handler** raises any other Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients).

Set the environment variable **`OXYROUTE_DEBUG=1`** (or `true`) to include a **`detail`** string in that JSON for the same error and to log more at the `log` crate target **`oxyroute`** (see `RUST_LOG`, e.g. `RUST_LOG=oxyroute=error`).

There is no **`register_exception_handler`** API yet; map custom exception types by catching them in Python or by a small wrapper.

## Pre-route hook (`set_middleware`)

`App.set_middleware(f)` sets an **optional** callable taking `(scope, protocol)` (same RSGI-like objects as the rest of the stack). It runs **after** the path and method are known, **before** the request body is read or routes are matched.
Expand Down
3 changes: 2 additions & 1 deletion oxyroute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import oxyroute._oxyroute # noqa: F401
from oxyroute._oxyroute import decode_jwt_hs
from oxyroute.app import App, Depends
from oxyroute.exceptions import HTTPException
from oxyroute.response import Response

__all__ = ["App", "Depends", "Response", "__version__", "decode_jwt_hs"]
__all__ = ["App", "Depends", "HTTPException", "Response", "__version__", "decode_jwt_hs"]
__version__ = "0.1.0"
53 changes: 53 additions & 0 deletions oxyroute/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""HTTP errors mapped by the native dispatcher to non-500 responses (issue #48)."""

from __future__ import annotations

import json
from http import HTTPStatus
from typing import Any


def _phrase(code: int) -> str:
try:
return HTTPStatus(code).phrase
except ValueError:
return "Error"


class HTTPException(Exception):
"""
Raise from a route handler or dependency to return a specific status and JSON body.

The response body is ``application/json; charset=utf-8`` unless you set ``Content-Type`` in
``headers``. String ``detail`` becomes ``{"detail": "..."}``; ``dict`` / ``list`` become the
root JSON value.
"""

def __init__(
self,
status_code: int,
detail: Any = None,
*,
headers: dict[str, str] | None = None,
) -> None:
self.status_code = int(status_code)
self.detail = detail
self.headers: dict[str, str] = dict(headers) if headers else {}


def _http_exception_payload(
exc: BaseException,
) -> tuple[int, bytes, list[tuple[str, str]]] | None:
"""Return ``(status, body_bytes, headers)`` for Rust, or ``None`` if not an HTTPException."""
if not isinstance(exc, HTTPException):
return None
st = exc.status_code
if not (100 <= st <= 599):
return None
if exc.detail is None:
body = json.dumps({"detail": _phrase(st)}).encode("utf-8")
elif isinstance(exc.detail, (dict, list)):
body = json.dumps(exc.detail).encode("utf-8")
else:
body = json.dumps({"detail": str(exc.detail)}).encode("utf-8")
return (st, body, list(exc.headers.items()))
90 changes: 77 additions & 13 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use parking_lot::RwLock;
use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{decode, Validation};
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyDict, PyList};
use pyo3::types::{PyBytes, PyDict, PyList, PyTuple};
use serde_json::Value as JsonValue;

use crate::form::{self, ParsedFile};
Expand All @@ -16,6 +16,8 @@ use crate::schema::json_to_py;
use crate::state::{match_route, methods_matching_path, AppState};
use crate::token::{build_decoding_key, extract_bearer, extract_cookie_value};

type HttpExceptionPayload = (u16, Vec<u8>, Vec<(String, String)>);

type RouteCallSnapshot = (
Py<PyAny>,
bool,
Expand Down Expand Up @@ -70,6 +72,68 @@ async fn send_internal_error(
response::send_text(protocol, 500, &body, "application/json; charset=utf-8").await
}

/// If ``err`` is :class:`oxyroute.exceptions.HTTPException`, send status/body/headers; else ``None``.
async fn try_http_exception(protocol: &Py<PyAny>, err: &PyErr) -> PyResult<Option<PyObject>> {
let payload: Option<HttpExceptionPayload> =
Python::with_gil(|py| -> PyResult<Option<HttpExceptionPayload>> {
let m = py.import("oxyroute.exceptions")?;
let f = m.getattr("_http_exception_payload")?;
let exc = err.value(py);
let r = f.call1((exc,))?;
if r.is_none() {
return Ok(None);
}
let tup = r.downcast::<PyTuple>()?;
if tup.len() != 3 {
return Ok(None);
}
let status: u16 = tup.get_item(0)?.extract()?;
let b = tup.get_item(1)?;
let body: Vec<u8> = b.extract()?;
let h = tup.get_item(2)?;
let list = h.downcast::<PyList>()?;
let mut headers = Vec::new();
for i in 0..list.len() {
let item = list.get_item(i)?;
let pair = item.downcast::<PyTuple>()?;
let k: String = pair.get_item(0)?.extract()?;
let v: String = pair.get_item(1)?.extract()?;
headers.push((k, v));
}
Ok(Some((status, body, headers)))
})?;
let Some((st, body, mut headers)) = payload else {
return Ok(None);
};
let has_ct = headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
if !has_ct {
headers.insert(
0,
(
"content-type".to_string(),
"application/json; charset=utf-8".to_string(),
),
);
}
let out = response::send_with_headers(protocol, st, &body, headers).await?;
Ok(Some(out))
}

/// Like [`send_internal_error`], but maps :class:`HTTPException` to its status and body.
async fn send_python_error(
protocol: &Py<PyAny>,
method: &str,
path: &str,
err: PyErr,
) -> PyResult<PyObject> {
if let Some(res) = try_http_exception(protocol, &err).await? {
return Ok(res);
}
send_internal_error(protocol, method, path, err).await
}

pub async fn run_rsgi(
state: Arc<RwLock<AppState>>,
scope: Py<PyAny>,
Expand Down Expand Up @@ -129,15 +193,15 @@ pub async fn run_rsgi(
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
let skip = Python::with_gil(|py| out.bind(py).is_none());
if !skip {
let mapped = match Python::with_gil(|py| map_handler_return(py, &out)) {
Ok(m) => m,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
return send_handler_map(&protocol, is_head, mapped).await;
Expand Down Expand Up @@ -386,7 +450,7 @@ pub async fn run_rsgi(
let ct = match ct {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
if ct.as_deref().map(str::is_empty) != Some(false) {
Expand Down Expand Up @@ -450,7 +514,7 @@ pub async fn run_rsgi(
}) {
Ok(o) => Some(o),
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
}
} else {
Expand Down Expand Up @@ -478,7 +542,7 @@ pub async fn run_rsgi(
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
let fut = match Python::with_gil(|py| {
Expand All @@ -487,13 +551,13 @@ pub async fn run_rsgi(
}) {
Ok(f) => f,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
let o = match fut.await {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
dep_out.push(o);
Expand All @@ -517,7 +581,7 @@ pub async fn run_rsgi(
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
dep_out.push(o);
Expand Down Expand Up @@ -582,7 +646,7 @@ pub async fn run_rsgi(
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
let handler_out: PyObject = if run_async {
Expand All @@ -592,13 +656,13 @@ pub async fn run_rsgi(
}) {
Ok(f) => f,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
match fut.await {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
}
} else {
Expand All @@ -607,7 +671,7 @@ pub async fn run_rsgi(
let mapped = match Python::with_gil(|py| map_handler_return(py, &handler_out)) {
Ok(m) => m,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
return send_python_error(&protocol, &method, &path, e).await;
}
};
send_handler_map(&protocol, is_head, mapped).await
Expand Down
78 changes: 78 additions & 0 deletions tests/test_http_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""``HTTPException`` mapped to HTTP responses (issue #48)."""

from __future__ import annotations

import asyncio

import httpx
from oxyroute import App, HTTPException


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

@app.get("/x")
def x() -> str:
raise HTTPException(404, "nope")

async def _run() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
r = await c.get("/x")
assert r.status_code == 404
assert r.json() == {"detail": "nope"}

asyncio.run(_run())


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

@app.get("/e")
def e() -> str:
raise HTTPException(422, {"errors": [{"loc": "x", "msg": "bad"}]})

async def _run() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
r = await c.get("/e")
assert r.status_code == 422
assert r.json() == {"errors": [{"loc": "x", "msg": "bad"}]}

asyncio.run(_run())


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

@app.get("/h")
def h() -> str:
raise HTTPException(400, "bad", headers={"X-App": "1"})

async def _run() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
r = await c.get("/h")
assert r.status_code == 400
assert r.headers.get("x-app") == "1"
assert "application/json" in (r.headers.get("content-type") or "")

asyncio.run(_run())


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

@app.get("/b")
def b() -> str:
raise ValueError("oops")

async def _run() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
r = await c.get("/b")
assert r.status_code == 500
j = r.json()
assert j.get("error") == "internal server error"

asyncio.run(_run())
Loading