Files
Owlbot/owlbot/registries/routes.py
T
LogalDeveloper 4918547c7d
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m48s
CI / Tests (Python 3.13) (push) Successful in 2m53s
CI / Tests (Python 3.14) (push) Successful in 2m43s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
Fixed route dispatch handling for aiohttp HTTP exceptions.
2026-05-05 14:47:38 -04:00

847 lines
31 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Route registry, module-scoped wrapper, and dispatcher.
Internal infrastructure for managing HTTP route registration and dispatch.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast, overload
import orjson
from aiohttp import web
from aiohttp.web import DynamicResource
from owlbot.api.context import RouteContext
from owlbot.api.routes import RouteHandler, RouteInfo, RouteMark
from owlbot.sessions import SESSION_COOKIE_NAME
from owlbot.web.sessions import connect_guidance_response
if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType
from owlbot.api.context import ModuleContext
from owlbot.sessions import BrowserSession, SessionManager
logger = logging.getLogger("owlbot.web")
@dataclass(slots=True)
class _RouteGroup:
"""Groups route handlers that share the same path pattern.
Each group holds a single ``DynamicResource`` for path matching
and a list of ``RouteInfo`` entries whose method sets must not overlap.
"""
resource: DynamicResource
full_path: str
handlers: list[RouteInfo]
class RouteRegistry:
"""Holds all registered routes for a bot instance.
Routes are namespaced by module to prevent conflicts. Supports path
patterns using aiohttp's ``{name}`` and ``{name:regex}`` syntax via
``DynamicResource`` for pattern compilation and matching.
Multiple handlers may be registered on the same path as long as their
HTTP method sets do not overlap.
"""
def __init__(self) -> None:
"""Initialize an empty route registry."""
# Ordered list of route groups for pattern matching.
# Each group represents a unique path pattern with one or more handlers.
self._groups: list[_RouteGroup] = []
# Maps module_name -> list of full_paths (for cleanup).
self._module_routes: dict[str, list[str]] = defaultdict(list)
logger.debug("RouteRegistry initialized.")
def _find_group(self, full_path: str) -> _RouteGroup | None:
"""Find a route group by its exact full path.
:param full_path: The full route path including namespace.
:return: The route group if found, None otherwise.
"""
for group in self._groups:
if group.full_path == full_path:
return group
return None
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
module_name: str,
streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo:
"""Register a route handler.
:param path: URL path relative to module namespace. Supports
``{name}`` and ``{name:regex}`` patterns.
:param handler: Async function to handle the route.
:param methods: List of HTTP methods. Default: ["GET"].
:param module_name: Name of the module registering this route.
:param streaming: Whether this route streams its response and should
bypass the handler timeout. Default: False.
:return: RouteInfo for the registered route.
:raises ValueError: If any method overlaps with an existing handler
on the same path.
"""
if methods is None:
methods = ["GET"]
if not path.startswith("/"):
path = "/" + path
full_path = f"/owlbot/{module_name}{path}"
new_methods = frozenset(m.upper() for m in methods)
group = self._find_group(full_path)
if group is not None:
for existing in group.handlers:
overlap = existing.methods & new_methods
if overlap:
raise ValueError(
f"Route '{full_path}' already has a handler for "
f"method(s): {', '.join(sorted(overlap))}"
)
info = RouteInfo(
path=path,
full_path=full_path,
methods=new_methods,
handler=handler,
module_name=module_name,
streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
)
if group is None:
group = _RouteGroup(
resource=DynamicResource(full_path),
full_path=full_path,
handlers=[info],
)
self._groups.append(group)
else:
group.handlers.append(info)
if full_path not in self._module_routes[module_name]:
self._module_routes[module_name].append(full_path)
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
module_logger.debug(
"Registered route '%s' [%s].",
full_path,
", ".join(sorted(info.methods)),
)
return info
def unregister(self, full_path: str, *, method: str | None = None) -> bool:
"""Unregister route handler(s) by full path.
When ``method`` is ``None``, removes all handlers for the path
(and the route group itself). When ``method`` is given, removes
only the handler covering that method; the group is removed if
no handlers remain.
:param full_path: The full route path including namespace.
:param method: Optional HTTP method to target a specific handler.
:return: True if anything was removed, False otherwise.
"""
for i, group in enumerate(self._groups):
if group.full_path != full_path:
continue
if method is None:
# Remove entire group.
self._groups.pop(i)
for handler in group.handlers:
if handler.module_name in self._module_routes:
self._module_routes[handler.module_name] = [
p
for p in self._module_routes[handler.module_name]
if p != full_path
]
module_names = {h.module_name for h in group.handlers}
for name in module_names:
module_logger = logging.getLogger(f"owlbot.modules.{name}.routes")
module_logger.debug("Unregistered route '%s'.", full_path)
return True
# Remove only the handler for the specified method.
method_upper = method.upper()
for j, handler in enumerate(group.handlers):
if method_upper in handler.methods:
group.handlers.pop(j)
# Only remove from _module_routes if the module has no
# remaining handlers at this path.
module_name = handler.module_name
still_owns_path = any(
h.module_name == module_name for h in group.handlers
)
if not still_owns_path and module_name in self._module_routes:
self._module_routes[module_name] = [
p
for p in self._module_routes[module_name]
if p != full_path
]
# Remove the group if no handlers remain.
if not group.handlers:
self._groups.pop(i)
module_logger = logging.getLogger(
f"owlbot.modules.{module_name}.routes"
)
module_logger.debug(
"Unregistered route '%s' [%s].", full_path, method_upper
)
return True
return False
return False
@overload
def get(self, full_path: str) -> list[RouteInfo]: ...
@overload
def get(self, full_path: str, *, method: str) -> RouteInfo | None: ...
def get(
self, full_path: str, *, method: str | None = None
) -> list[RouteInfo] | RouteInfo | None:
"""Look up routes by full path.
When ``method`` is ``None``, returns all handlers registered at
the path. When ``method`` is given, returns the single handler
covering that method, or ``None``.
:param full_path: The full route path including namespace.
:param method: Optional HTTP method to look up a specific handler.
:return: List of RouteInfo (no method), RouteInfo or None (with method).
"""
group = self._find_group(full_path)
if group is None:
return [] if method is None else None
if method is None:
return list(group.handlers)
method_upper = method.upper()
for handler in group.handlers:
if method_upper in handler.methods:
return handler
return None
def match(
self, full_path: str, method: str
) -> tuple[RouteInfo, dict[str, str]] | tuple[None, frozenset[str], dict[str, str]]:
"""Match a request path and method against registered routes.
Scans route groups in registration order. Uses
``DynamicResource._match()`` for both plain and parameterized paths.
:param full_path: The request path to match.
:param method: The HTTP method of the request.
:return: ``(RouteInfo, match_dict)`` on success, or
``(None, allowed_methods, match_dict)`` if the path matches
but the method is not allowed.
:raises LookupError: If no route group matches the path (404).
"""
method = method.upper()
for group in self._groups:
match_dict = group.resource._match(full_path) # noqa: SLF001 # no public API alternative
if match_dict is not None:
for handler in group.handlers:
if method in handler.methods:
return handler, match_dict
# Path matched but method not allowed.
all_methods: frozenset[str] = frozenset().union(
*(h.methods for h in group.handlers)
)
return None, all_methods, match_dict
raise LookupError(full_path)
def get_all(self) -> list[RouteInfo]:
"""Get all registered route handlers.
:return: List of all RouteInfo across all groups.
"""
return [handler for group in self._groups for handler in group.handlers]
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""Get all routes registered by a specific module.
:param module_name: The module name.
:return: List of RouteInfo for that module.
"""
paths = set(self._module_routes.get(module_name, []))
return [
handler
for group in self._groups
if group.full_path in paths
for handler in group.handlers
if handler.module_name == module_name
]
def unregister_by_module(self, module_name: str) -> int:
"""Remove all routes registered by a specific module.
:param module_name: The module whose routes should be removed.
:return: Number of route handlers removed.
"""
module_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
paths = set(self._module_routes.get(module_name, []))
if not paths:
return 0
count = 0
# Iterate in reverse to allow safe removal.
for i in range(len(self._groups) - 1, -1, -1):
group = self._groups[i]
if group.full_path not in paths:
continue
before = len(group.handlers)
group.handlers = [h for h in group.handlers if h.module_name != module_name]
removed = before - len(group.handlers)
count += removed
if not group.handlers:
self._groups.pop(i)
if count > 0:
module_logger.debug("Unregistered all routes (%d handler(s) total).", count)
self._module_routes.pop(module_name, None)
return count
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""Scan a Python module for @on_route-decorated functions and register them.
Looks for functions with the ``_owlbot_route`` attribute set by
the ``@on_route`` decorator and registers each one.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
for obj in vars(module).values():
if callable(obj):
route_info = getattr(obj, "_owlbot_route", None)
if route_info is not None:
mark = cast("RouteMark", route_info)
self.register(
path=mark["path"],
handler=obj,
methods=mark["methods"],
module_name=module_name,
streaming=mark["streaming"],
requires_session=mark["requires_session"],
requires_authenticated=mark["requires_authenticated"],
requires_moderator=mark["requires_moderator"],
)
class RouteDispatcher:
"""Dispatches HTTP requests to registered module route handlers.
Looks up routes in the RouteRegistry, validates methods, creates
RouteContext, and calls the handler with timeout enforcement.
"""
def __init__(
self,
get_module_context: Callable[[str], ModuleContext],
handler_timeout: float,
session_manager: SessionManager,
command_prefix: str = "!",
) -> None:
"""Initialize the route dispatcher.
Creates and owns a :class:`RouteRegistry` internally.
:param get_module_context: Callable that looks up a
ModuleContext by module name.
:param handler_timeout: Timeout for route handlers in seconds.
"""
self._route_registry = RouteRegistry()
self._get_module_context = get_module_context
self._handler_timeout = handler_timeout
self._session_manager = session_manager
self._command_prefix = command_prefix
self._streaming_tasks: set[asyncio.Task[Any]] = set()
self._handler_tasks: set[asyncio.Task[Any]] = set()
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
module_name: str,
streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo:
"""Register a route handler.
Delegates to the internal RouteRegistry.
:param path: URL path relative to module namespace.
:param handler: Async function to handle the route.
:param methods: List of HTTP methods. Default: ["GET"].
:param module_name: Name of the module registering this route.
:param streaming: If True, handler runs without timeout. Default: False.
:return: RouteInfo for the registered route.
:raises ValueError: If route conflicts with existing route.
"""
return self._route_registry.register(
path=path,
handler=handler,
methods=methods,
module_name=module_name,
streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
)
def unregister(self, full_path: str, *, method: str | None = None) -> bool:
"""Unregister route handler(s) by full path.
Delegates to the internal RouteRegistry.
:param full_path: The full route path including namespace.
:param method: Optional HTTP method to target a specific handler.
:return: True if anything was removed, False otherwise.
"""
return self._route_registry.unregister(full_path, method=method)
@overload
def get(self, full_path: str) -> list[RouteInfo]: ...
@overload
def get(self, full_path: str, *, method: str) -> RouteInfo | None: ...
def get(
self, full_path: str, *, method: str | None = None
) -> list[RouteInfo] | RouteInfo | None:
"""Look up routes by full path.
Delegates to the internal RouteRegistry.
:param full_path: The full route path including namespace.
:param method: Optional HTTP method to look up a specific handler.
:return: List of RouteInfo (no method), RouteInfo or None (with method).
"""
if method is None:
return self._route_registry.get(full_path)
return self._route_registry.get(full_path, method=method)
def get_by_module(self, module_name: str) -> list[RouteInfo]:
"""Get all routes registered by a specific module.
Delegates to the internal RouteRegistry.
:param module_name: The module name.
:return: List of RouteInfo for that module.
"""
return self._route_registry.get_by_module(module_name)
def register_from_module(self, module: ModuleType, module_name: str) -> None:
"""Scan a Python module for @on_route-decorated functions and register them.
Delegates to the internal RouteRegistry.
:param module: The loaded Python module to scan.
:param module_name: Name of the module (for ownership tracking).
"""
self._route_registry.register_from_module(module, module_name)
def unregister_by_module(self, module_name: str) -> int:
"""Remove all routes registered by a specific module.
Delegates to the internal RouteRegistry.
:param module_name: The module whose routes should be removed.
:return: Number of routes removed.
"""
return self._route_registry.unregister_by_module(module_name)
async def drain_handlers(self) -> None:
"""Drain all active route handlers during shutdown.
Waits for in-flight non-streaming handlers to complete, then
cancels long-lived streaming handlers so their connections
close promptly instead of blocking until aiohttp's shutdown
timeout expires.
Intended to be called via ``app.on_shutdown``.
"""
if self._handler_tasks:
logger.info(
"Waiting for %d non-streaming handler(s) to complete...",
len(self._handler_tasks),
)
await asyncio.gather(*list(self._handler_tasks), return_exceptions=True)
logger.debug("All non-streaming handlers completed.")
if not self._streaming_tasks:
return
logger.info(
"Cancelling %d active streaming handler(s)...",
len(self._streaming_tasks),
)
tasks = list(self._streaming_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
logger.debug("All streaming handlers cancelled.")
async def dispatch(self, request: web.Request) -> web.StreamResponse:
"""Dispatch an HTTP request to the appropriate module route handler.
Extracts module_name and path from the URL, matches it against
registered routes (supporting path patterns), validates the HTTP
method, and calls the handler.
:param request: The aiohttp request object.
:return: HTTP response.
"""
module_name = request.match_info["module_name"]
path = request.match_info.get("path", "")
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session = self._session_manager.get_session(session_id)
relative_path = f"/{path}" if path else "/"
full_path = f"/owlbot/{module_name}{relative_path}"
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
try:
result = self._route_registry.match(full_path, request.method)
except LookupError:
mod_logger.debug("No route registered for '%s'.", full_path)
return web.Response(status=404)
if result[0] is None:
# Path matched, method not allowed.
_, allowed_methods, _ = result
allowed = ", ".join(sorted(allowed_methods))
mod_logger.debug(
"Method %s not allowed for '%s' (allowed: %s)",
request.method,
full_path,
allowed,
)
return web.Response(status=405, headers={"Allow": allowed})
route_info, match_info = result
return await self._handle_module_route(
request,
route_info,
match_info,
session=session,
)
async def _handle_module_route(
self,
request: web.Request,
route_info: RouteInfo,
match_info: dict[str, str] | None = None,
*,
session: BrowserSession | None,
) -> web.StreamResponse:
"""Handle an HTTP request to a module-registered route.
:param request: The aiohttp request object.
:param route_info: Information about the registered route.
:param match_info: Captured path parameters from pattern matching.
:return: HTTP response.
"""
module_name = route_info.module_name
mod_logger = logging.getLogger(f"owlbot.modules.{module_name}.routes")
module_ctx = self._get_module_context(module_name)
ctx = RouteContext(
request=request,
module=module_ctx,
match_info=match_info if match_info is not None else {},
session=session,
)
guard_response = self._guard_response(
route_info,
session=session,
)
if guard_response is not None:
return guard_response
logger.debug(
"Calling route handler: %s from module: %s",
route_info.full_path,
module_name,
)
task = asyncio.current_task()
if task is not None:
if route_info.streaming:
self._streaming_tasks.add(task)
else:
self._handler_tasks.add(task)
try:
start = time.perf_counter()
if route_info.streaming:
mod_logger.debug(
"Streaming handler '%s' dispatched (no timeout).",
route_info.full_path,
)
result = await route_info.handler(ctx)
else:
result = await asyncio.wait_for(
route_info.handler(ctx), timeout=self._handler_timeout
)
elapsed = (time.perf_counter() - start) * 1000
mod_logger.debug(
"Route handler '%s' completed in %.1fms.",
route_info.full_path,
elapsed,
)
if result is None:
return web.Response(status=204) # No Content.
if isinstance(result, web.StreamResponse):
return result
if isinstance(result, dict):
return web.Response(
body=orjson.dumps(result),
content_type="application/json",
)
# pragma: no branch — defensive against untyped handlers
mod_logger.error( # type: ignore[unreachable]
"Route handler '%s' returned unsupported type: %s",
route_info.full_path,
type(result).__name__,
)
return web.Response(status=500)
except web.HTTPException:
raise
except TimeoutError:
mod_logger.warning(
"Route handler '%s' timed out after %ss.",
route_info.full_path,
self._handler_timeout,
)
return web.Response(status=500)
except Exception:
mod_logger.exception(
"Route handler '%s' raised exception.",
route_info.full_path,
)
return web.Response(status=500)
finally:
if task is not None:
self._streaming_tasks.discard(task)
self._handler_tasks.discard(task)
def _guard_response(
self,
route_info: RouteInfo,
*,
session: BrowserSession | None,
) -> web.Response | None:
requires_session = (
route_info.requires_session
or route_info.requires_authenticated
or route_info.requires_moderator
)
if requires_session and session is None:
return connect_guidance_response(
status=401,
command_prefix=self._command_prefix,
)
if session is None:
return None
if route_info.requires_authenticated and not session.is_authenticated:
return connect_guidance_response(
status=403,
command_prefix=self._command_prefix,
title="Authentication required",
message="You must be authenticated in Owncast to access this page.",
command_message=(
"If you believe this is in error, try using "
f"{self._command_prefix}connect in chat to reconnect your "
"Owncast account."
),
)
if route_info.requires_moderator and not session.is_moderator:
return connect_guidance_response(
status=403,
command_prefix=self._command_prefix,
title="Moderator access required",
message="Only moderators can access this page.",
command_message=(
"If you believe this is in error, try using "
f"{self._command_prefix}connect in chat to reconnect your "
"Owncast account."
),
)
return None
class ModuleRoutes:
"""Module-scoped wrapper around RouteDispatcher.
This wrapper auto-supplies the module name for route operations,
so modules don't need to know the internal routing namespace.
Follows the same pattern as ModuleCommands.
"""
def __init__(
self,
dispatcher: RouteDispatcher,
module_name: str,
public_base_url: str,
) -> None:
"""Initialize the module-scoped routes wrapper.
:param dispatcher: The RouteDispatcher that owns the route registry.
:param module_name: The name of the module using this wrapper.
:param public_base_url: The public base URL for building route URLs.
"""
self._dispatcher = dispatcher
self._module_name = module_name
self._public_base_url = public_base_url
@property
def module_routes(self) -> list[RouteInfo]:
"""Routes registered by this module."""
return self._dispatcher.get_by_module(self._module_name)
def url_for(self, path: str) -> str:
"""Build a public URL for a route registered by this module.
:param path: The route path (e.g., "/list").
:return: Full public URL (e.g., "http://host/owlbot/quotes/list").
"""
return f"{self._public_base_url}{self._full_path(path)}"
def register(
self,
path: str,
handler: RouteHandler,
*,
methods: list[str] | None = None,
streaming: bool = False,
requires_session: bool = False,
requires_authenticated: bool = False,
requires_moderator: bool = False,
) -> RouteInfo:
"""Register a route handler for this module.
The module name is automatically supplied.
:param path: URL path relative to module namespace (e.g., "/stats").
:param handler: Async function to handle the route.
:param methods: List of HTTP methods (e.g., ["GET"]). Default: ["GET"].
:param streaming: If True, handler runs without timeout. Default: False.
:return: RouteInfo for the registered route.
:raises ValueError: If route conflicts with existing route.
"""
return self._dispatcher.register(
path=path,
handler=handler,
methods=methods,
module_name=self._module_name,
streaming=streaming,
requires_session=requires_session,
requires_authenticated=requires_authenticated,
requires_moderator=requires_moderator,
)
def unregister(self, path: str, *, method: str | None = None) -> bool:
"""Unregister route handler(s) by relative path.
:param path: Relative route path (e.g., "/stats").
:param method: Optional HTTP method to target a specific handler.
When ``None``, removes all handlers for the path.
:return: True if anything was removed, False otherwise.
"""
return self._dispatcher.unregister(self._full_path(path), method=method)
@overload
def get(self, path: str) -> list[RouteInfo]: ...
@overload
def get(self, path: str, *, method: str) -> RouteInfo | None: ...
def get(
self, path: str, *, method: str | None = None
) -> list[RouteInfo] | RouteInfo | None:
"""Look up routes by relative path.
:param path: Relative route path (e.g., "/stats").
:param method: Optional HTTP method to look up a specific handler.
:return: List of RouteInfo when ``method`` is ``None``,
or ``RouteInfo | None`` when ``method`` is given.
"""
if method is None:
return self._dispatcher.get(self._full_path(path))
return self._dispatcher.get(self._full_path(path), method=method)
def exists(self, path: str, *, method: str | None = None) -> bool:
"""Check if a route is registered at the given relative path.
:param path: Relative route path (e.g., "/stats").
:param method: Optional HTTP method to check for a specific handler.
:return: True if a matching route exists, False otherwise.
"""
if method is None:
return len(self.get(path)) > 0
return self.get(path, method=method) is not None
def _full_path(self, path: str) -> str:
"""Normalize a relative path into the full namespaced path.
:param path: Relative route path (e.g., "/list" or "list").
:return: Full path (e.g., "/owlbot/quotes/list").
"""
if not path.startswith("/"):
path = "/" + path
return f"/owlbot/{self._module_name}{path}"