Skip to content
Open
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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ where = ["python"]
[tool.setuptools.package-data]
turboapi = ["*.so", "*.dylib", "*.pyd"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.ruff]
target-version = "py314"
line-length = 100
Expand Down
10 changes: 8 additions & 2 deletions python/turboapi/main_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ def __init__(
redoc_url: str | None = "/redoc",
openapi_url: str | None = "/openapi.json",
lifespan: Callable | None = None,
debug: bool = False,
**kwargs,
):
super().__init__()
self.title = title
self.version = version
self.description = description
self.debug = debug
self.middleware_stack = []
self.startup_handlers = []
self.shutdown_handlers = []
Expand Down Expand Up @@ -406,7 +408,10 @@ async def handle_request(self, method: str, path: str, **kwargs) -> dict[str, An
}

except Exception as e:
return {"error": "Internal Server Error", "status_code": 500, "detail": str(e)}
response = {"error": "Internal Server Error", "status_code": 500}
if self.debug:
response["detail"] = str(e)
return response

def run_legacy(self, host: str = "127.0.0.1", port: int = 8000, workers: int = 1, **kwargs):
"""Run the TurboAPI application with legacy loop sharding (DEPRECATED).
Expand Down Expand Up @@ -707,7 +712,8 @@ async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None
else:
result = route.handler(**call_args)
except Exception as e:
resp_body = _json.dumps({"detail": str(e)}).encode("utf-8")
detail = str(e) if self.debug else "Internal Server Error"
resp_body = _json.dumps({"detail": detail}).encode("utf-8")
await send(
{
"type": "http.response.start",
Expand Down
73 changes: 37 additions & 36 deletions python/turboapi/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@

_NO_COERCION = object()


def _internal_error_body(exc: Exception, *, debug: bool = False, include_traceback: bool = False) -> dict:
"""Return the response body for unexpected 500 errors.

Production defaults intentionally avoid echoing exception strings or tracebacks,
which can contain secrets. Debug mode keeps the previous developer-friendly
details.
"""
body = {"error": "Internal Server Error"}
if debug:
body["detail"] = str(exc)
if include_traceback:
import traceback

body["traceback"] = traceback.format_exc()
return body


def _collect_streaming_body_sync(result):
"""If result is a StreamingResponse, collect its body synchronously."""
if not hasattr(result, "body_iterator"):
Expand Down Expand Up @@ -665,23 +683,18 @@ def normalize_response(result: Any) -> tuple[Any, int]:
if isinstance(body, bytes):
# Try to decode as JSON for JSONResponse
try:
import json

body = _turbo_loads(body)
except json.JSONDecodeError:
# Not JSON, try as plain text
except Exception:
# Not JSON, try as plain text. The active JSON backend may
# raise json.JSONDecodeError, msgspec.DecodeError, or a
# backend-specific exception type.
try:
body = body.decode("utf-8")
except UnicodeDecodeError:
# Binary data - return with content_type
if extra_headers:
return body, result.status_code, content_type, extra_headers
return body, result.status_code, content_type
except UnicodeDecodeError:
# Binary data - return with content_type
if extra_headers:
return body, result.status_code, content_type, extra_headers
return body, result.status_code, content_type
if extra_headers:
return body, result.status_code, content_type, extra_headers
return body, result.status_code, content_type
Expand Down Expand Up @@ -804,7 +817,7 @@ def _format_zig_tuple(content, status_code, content_type=None):
return (status_code, "application/json", _json_dumps({"error": str(content)}))


def create_enhanced_handler(original_handler, route_definition):
def create_enhanced_handler(original_handler, route_definition, *, debug: bool = False):
"""
Create an enhanced handler with automatic body parsing and response normalization.

Expand Down Expand Up @@ -1064,14 +1077,8 @@ async def enhanced_handler(**kwargs):

if isinstance(e, HTTPException):
return ResponseHandler.format_json_response({"detail": e.detail}, e.status_code)
import traceback

return ResponseHandler.format_json_response(
{
"error": "Internal Server Error",
"detail": str(e),
"traceback": traceback.format_exc(),
},
_internal_error_body(e, debug=debug, include_traceback=True),
500,
)

Expand Down Expand Up @@ -1247,21 +1254,15 @@ def enhanced_handler(**kwargs):

if isinstance(e, HTTPException):
return ResponseHandler.format_json_response({"detail": e.detail}, e.status_code)
import traceback

return ResponseHandler.format_json_response(
{
"error": "Internal Server Error",
"detail": str(e),
"traceback": traceback.format_exc(),
},
_internal_error_body(e, debug=debug, include_traceback=True),
500,
)

return enhanced_handler


def create_pos_handler(original_handler):
def create_pos_handler(original_handler, *, debug: bool = False):
"""Minimal positional wrapper for PyObject_Vectorcall dispatch.

Zig assembles args from path/query params and calls this positionally —
Expand Down Expand Up @@ -1297,12 +1298,12 @@ def pos_handler(*args):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return pos_handler


def create_async_pos_handler(original_handler):
def create_async_pos_handler(original_handler, *, debug: bool = False):
"""Minimal positional wrapper for async PyObject_Vectorcall dispatch."""
import json as _json

Expand Down Expand Up @@ -1333,12 +1334,12 @@ async def pos_handler(*args):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return pos_handler


def create_fast_handler(original_handler, route_definition):
def create_fast_handler(original_handler, route_definition, *, debug: bool = False):
"""Create a minimal-overhead handler for simple sync routes.

Returns a 3-tuple (status_code, content_type, body_str) so Zig can unpack
Expand Down Expand Up @@ -1397,7 +1398,7 @@ def fast_handler_noargs(**kwargs):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return fast_handler_noargs

Expand Down Expand Up @@ -1466,12 +1467,12 @@ def fast_handler(**kwargs):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return fast_handler


def create_fast_async_handler(original_handler, route_definition, eager: bool = False):
def create_fast_async_handler(original_handler, route_definition, eager: bool = False, *, debug: bool = False):
"""Create a minimal-overhead tuple handler for async routes that need kwargs."""
import json as _json

Expand Down Expand Up @@ -1555,7 +1556,7 @@ def fast_handler_eager(**kwargs):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact errors from the eager async runner

For body_async_eager routes, fast_handler_eager calls run_coroutine_response_eager(), which catches exceptions raised inside the coroutine and formats them through async_pool._exception_response_tuple as {"error": str(exc)}; this redacted fallback only runs for setup/parsing errors outside the coroutine. A no-await async POST handler that raises RuntimeError("secret-token") will still return the secret in production despite debug=False.

Useful? React with 👍 / 👎.


return fast_handler_eager

Expand Down Expand Up @@ -1589,12 +1590,12 @@ async def fast_handler(**kwargs):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return fast_handler


def create_fast_model_handler(original_handler, model_class, param_name):
def create_fast_model_handler(original_handler, model_class, param_name, *, debug: bool = False):
"""Create a minimal handler for model_sync routes.

Zig has already validated the JSON body against the schema.
Expand Down Expand Up @@ -1630,6 +1631,6 @@ def fast_model_handler(**kwargs):
return (e.status_code, "application/json", _dumps({"detail": e.detail}))
except ImportError:
pass
return (500, "application/json", _dumps({"error": str(e)}))
return (500, "application/json", _dumps(_internal_error_body(e, debug=debug)))

return fast_model_handler
3 changes: 2 additions & 1 deletion python/turboapi/testclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,10 @@ def _request(
content=_json_encode(error_body),
headers=getattr(e, "headers", None) or {},
)
detail = str(e) if getattr(self.app, "debug", False) else "Internal Server Error"
return TestResponse(
status_code=500,
content=_json_encode({"detail": str(e)}),
content=_json_encode({"detail": detail}),
)

# Run background tasks if any
Expand Down
46 changes: 39 additions & 7 deletions python/turboapi/zig_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,36 @@ def _initialize_zig_server(self, host: str = "127.0.0.1", port: int = 8000):
for method, path, status, ct, body in getattr(self, "_static_routes", []):
self.zig_server.add_static_route(method, path, status, ct, body)

# Register generated docs/schema routes for the native server path.
if self.openapi_url:
self.zig_server.add_static_route(
"GET",
self.openapi_url,
200,
"application/json",
json.dumps(self.openapi()),
)
if self.docs_url:
from .openapi import get_swagger_ui_html

self.zig_server.add_static_route(
"GET",
self.docs_url,
200,
"text/html",
get_swagger_ui_html(self.title, self.openapi_url or "/openapi.json"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard docs registration on an OpenAPI URL

When an app disables schema serving with openapi_url=None but leaves docs_url enabled or custom, this registers the native docs page pointing at the hard-coded /openapi.json; the guard above skips registering that schema route, so the UI fetches a missing spec. Gate docs/ReDoc registration on self.openapi_url instead of falling back to an unregistered path.

Useful? React with 👍 / 👎.

)
if self.redoc_url:
from .openapi import get_redoc_html

self.zig_server.add_static_route(
"GET",
self.redoc_url,
200,
"text/html",
get_redoc_html(self.title, self.openapi_url or "/openapi.json"),
)

# Configure DB pool (if configured)
if hasattr(self, "_db_config"):
conn_str, pool_size = self._db_config
Expand Down Expand Up @@ -916,7 +946,7 @@ def _register_routes_with_zig(self):
# Middleware present: register as "enhanced" so Zig uses callPythonHandler
# (dict response path). Pre-GIL dhi validation is skipped — middleware
# overhead already dominates, so the tradeoff is acceptable.
enhanced_handler = create_enhanced_handler(route.handler, route)
enhanced_handler = create_enhanced_handler(route.handler, route, debug=self.debug)
enhanced_handler = self._wrap_with_middleware(enhanced_handler)
self.zig_server.add_route_fast(
route.method.value,
Expand All @@ -935,6 +965,7 @@ def _register_routes_with_zig(self):
route.handler,
model_info["model_class"],
model_info["param_name"],
debug=self.debug,
)

# Extract dhi model schema for Zig-native validation
Expand Down Expand Up @@ -968,15 +999,15 @@ def _register_routes_with_zig(self):
# Middleware present: wrap with enhanced handler, register as "enhanced"
# so Zig dispatches through callPythonHandler (dict response path)
# instead of the fast tuple path which doesn't support middleware
enhanced_handler = create_enhanced_handler(route.handler, route)
enhanced_handler = create_enhanced_handler(route.handler, route, debug=self.debug)
enhanced_handler = self._wrap_with_middleware(enhanced_handler)
registered_type = "enhanced"
elif handler_type == "simple_sync":
# Vectorcall path: Zig assembles args, calls positionally
enhanced_handler = create_pos_handler(route.handler)
enhanced_handler = create_pos_handler(route.handler, debug=self.debug)
registered_type = handler_type
else:
enhanced_handler = create_fast_handler(route.handler, route)
enhanced_handler = create_fast_handler(route.handler, route, debug=self.debug)
registered_type = handler_type

# simple_sync: ordered "name:type[?]|..." string for Zig vectorcall arg assembly
Expand Down Expand Up @@ -1012,7 +1043,7 @@ def _register_routes_with_zig(self):
):
# ASYNC FAST PATH: Register with async runtime
if self._middleware_instances:
enhanced_handler = create_enhanced_handler(route.handler, route)
enhanced_handler = create_enhanced_handler(route.handler, route, debug=self.debug)
enhanced_handler = self._wrap_with_middleware(enhanced_handler)
registered_type = "enhanced"
param_meta_str = "{}"
Expand All @@ -1033,6 +1064,7 @@ def _register_routes_with_zig(self):
route.handler,
route,
eager=handler_type == "body_async_eager",
debug=self.debug,
)
registered_type = handler_type
param_meta_str = json.dumps(param_types)
Expand All @@ -1049,7 +1081,7 @@ def _register_routes_with_zig(self):
elif handler_type in ("form_sync", "file_sync"):
# FORM/FILE PATH: Enhanced handler with dedicated Zig dispatch
# Zig skips DHI validation and parses multipart/urlencoded natively
enhanced_handler = create_enhanced_handler(route.handler, route)
enhanced_handler = create_enhanced_handler(route.handler, route, debug=self.debug)
if self._middleware_instances:
enhanced_handler = self._wrap_with_middleware(enhanced_handler)
registered_type = "enhanced"
Expand All @@ -1067,7 +1099,7 @@ def _register_routes_with_zig(self):
print(f"{CHECK_MARK} [{registered_type}] {route.method.value} {route.path}")
else:
# ENHANCED PATH: Full Python wrapper needed
enhanced_handler = create_enhanced_handler(route.handler, route)
enhanced_handler = create_enhanced_handler(route.handler, route, debug=self.debug)
if self._middleware_instances:
enhanced_handler = self._wrap_with_middleware(enhanced_handler)
self.zig_server.add_route(
Expand Down
Loading