Skip to content

HTTP Server API Reference

Auto-generated API documentation for the HTTP Server module.

httpserver

Zero-dependency async HTTP server with decorator-based routing.

Part of zerodep: https://github.com/Oaklight/zerodep Copyright (c) 2026 Peng Ding. MIT License.

Async HTTP/1.1 server built on asyncio.start_server(). Supports decorator-based routing, JSON request/response, static file serving, streaming responses (SSE), and graceful shutdown.

Usage::

from httpserver import App, JSONResponse

app = App()

@app.route("/status")
async def status(request):
    return JSONResponse({"state": "idle"})

@app.route("/echo", methods=["POST"])
def echo(request):
    return JSONResponse(request.json())

app.run(host="127.0.0.1", port=8000)

HTTPException

Bases: Exception

HTTP error that maps to a specific status code.

Raise directly or via :func:abort to short-circuit request handling.

Parameters:

Name Type Description Default
status_code int

HTTP status code (e.g. 404, 500).

required
message str | None

Optional human-readable error message.

None
Source code in httpserver/httpserver.py
class HTTPException(Exception):
    """HTTP error that maps to a specific status code.

    Raise directly or via :func:`abort` to short-circuit request handling.

    Args:
        status_code: HTTP status code (e.g. 404, 500).
        message: Optional human-readable error message.
    """

    __slots__ = ("status_code", "message")

    def __init__(self, status_code: int, message: str | None = None):
        self.status_code = status_code
        self.message = message or _STATUS_REASONS.get(status_code, "Error")
        super().__init__(self.message)

Request

Parsed HTTP request.

Attributes:

Name Type Description
method

Uppercase HTTP method (GET, POST, ...).

path

URL path, percent-decoded.

query_string

Raw query string (without leading ?).

query_params dict[str, list[str]]

Parsed query string as {key: [values]}.

headers

Case-insensitive header dict (keys stored lowercase).

body

Raw request body bytes.

path_params dict[str, Any]

Parameters extracted from the route pattern.

client_addr

Client (host, port) tuple.

app

Reference to the :class:App instance handling this request.

Source code in httpserver/httpserver.py
class Request:
    """Parsed HTTP request.

    Attributes:
        method: Uppercase HTTP method (GET, POST, ...).
        path: URL path, percent-decoded.
        query_string: Raw query string (without leading ``?``).
        query_params: Parsed query string as ``{key: [values]}``.
        headers: Case-insensitive header dict (keys stored lowercase).
        body: Raw request body bytes.
        path_params: Parameters extracted from the route pattern.
        client_addr: Client ``(host, port)`` tuple.
        app: Reference to the :class:`App` instance handling this request.
    """

    __slots__ = (
        "method",
        "path",
        "query_string",
        "query_params",
        "headers",
        "body",
        "path_params",
        "client_addr",
        "app",
        "_json",
    )

    def __init__(
        self,
        method: str,
        path: str,
        query_string: str,
        headers: dict[str, str],
        body: bytes,
        client_addr: tuple[str, int],
        app: App | None = None,
    ):
        self.method = method
        self.path = path
        self.query_string = query_string
        self.query_params: dict[str, list[str]] = parse_qs(query_string)
        self.headers = headers
        self.body = body
        self.path_params: dict[str, Any] = {}
        self.client_addr = client_addr
        self.app = app
        self._json: Any = _SENTINEL

    def json(self) -> Any:
        """Parse body as JSON (cached)."""
        if self._json is _SENTINEL:
            self._json = _json.loads(self.body)
        return self._json

    def text(self) -> str:
        """Decode body as UTF-8."""
        return self.body.decode("utf-8")

    def form(self) -> dict[str, list[str]]:
        """Parse URL-encoded form body."""
        return parse_qs(self.body.decode("utf-8"))

json()

Parse body as JSON (cached).

Source code in httpserver/httpserver.py
def json(self) -> Any:
    """Parse body as JSON (cached)."""
    if self._json is _SENTINEL:
        self._json = _json.loads(self.body)
    return self._json

text()

Decode body as UTF-8.

Source code in httpserver/httpserver.py
def text(self) -> str:
    """Decode body as UTF-8."""
    return self.body.decode("utf-8")

form()

Parse URL-encoded form body.

Source code in httpserver/httpserver.py
def form(self) -> dict[str, list[str]]:
    """Parse URL-encoded form body."""
    return parse_qs(self.body.decode("utf-8"))

Response

HTTP response with a fixed body.

Parameters:

Name Type Description Default
body bytes | str

Response body (bytes or str).

b''
status_code int

HTTP status code.

200
headers dict[str, str] | None

Extra response headers.

None
content_type str | None

Shorthand for Content-Type header.

None
Source code in httpserver/httpserver.py
class Response:
    """HTTP response with a fixed body.

    Args:
        body: Response body (bytes or str).
        status_code: HTTP status code.
        headers: Extra response headers.
        content_type: Shorthand for ``Content-Type`` header.
    """

    __slots__ = ("status_code", "headers", "body")

    def __init__(
        self,
        body: bytes | str = b"",
        status_code: int = 200,
        headers: dict[str, str] | None = None,
        content_type: str | None = None,
    ):
        self.status_code = status_code
        self.headers: dict[str, str] = headers.copy() if headers else {}
        if isinstance(body, str):
            self.body = body.encode("utf-8")
        else:
            self.body = body
        if content_type is not None:
            self.headers["Content-Type"] = content_type

    async def _write(self, writer: asyncio.StreamWriter) -> None:
        """Serialize and write the full HTTP response."""
        reason = _STATUS_REASONS.get(self.status_code, "Unknown")
        self.headers.setdefault("Content-Length", str(len(self.body)))
        self.headers.setdefault("Content-Type", "text/plain; charset=utf-8")
        self.headers.setdefault("Date", _http_date())
        self.headers.setdefault("Connection", "close")

        buf = bytearray()
        buf.extend(f"HTTP/1.1 {self.status_code} {reason}\r\n".encode("latin-1"))
        for k, v in self.headers.items():
            buf.extend(f"{k}: {v}\r\n".encode("latin-1"))
        buf.extend(b"\r\n")
        buf.extend(self.body)
        writer.write(bytes(buf))
        await writer.drain()

JSONResponse

Bases: Response

Response serialized as JSON.

Parameters:

Name Type Description Default
data Any

Python object to serialize.

required
status_code int

HTTP status code.

200
headers dict[str, str] | None

Extra response headers.

None
Source code in httpserver/httpserver.py
class JSONResponse(Response):
    """Response serialized as JSON.

    Args:
        data: Python object to serialize.
        status_code: HTTP status code.
        headers: Extra response headers.
    """

    def __init__(
        self,
        data: Any,
        status_code: int = 200,
        headers: dict[str, str] | None = None,
    ):
        body = _json.dumps(data, ensure_ascii=False).encode("utf-8")
        super().__init__(
            body=body,
            status_code=status_code,
            headers=headers,
            content_type="application/json; charset=utf-8",
        )

StreamingResponse

HTTP response streamed from an async generator.

All streaming responses use Transfer-Encoding: chunked for maximum compatibility with reverse proxies and intermediaries. SSE (text/event-stream) responses additionally set Cache-Control: no-cache.

Parameters:

Name Type Description Default
generator AsyncIterator[bytes | str]

Async iterator yielding bytes or str chunks.

required
status_code int

HTTP status code.

200
headers dict[str, str] | None

Extra response headers.

None
content_type str

MIME type (default application/octet-stream).

'application/octet-stream'
Source code in httpserver/httpserver.py
class StreamingResponse:
    """HTTP response streamed from an async generator.

    All streaming responses use ``Transfer-Encoding: chunked`` for
    maximum compatibility with reverse proxies and intermediaries.
    SSE (``text/event-stream``) responses additionally set
    ``Cache-Control: no-cache``.

    Args:
        generator: Async iterator yielding ``bytes`` or ``str`` chunks.
        status_code: HTTP status code.
        headers: Extra response headers.
        content_type: MIME type (default ``application/octet-stream``).
    """

    __slots__ = ("_generator", "status_code", "headers", "content_type")

    def __init__(
        self,
        generator: AsyncIterator[bytes | str],
        status_code: int = 200,
        headers: dict[str, str] | None = None,
        content_type: str = "application/octet-stream",
    ):
        self._generator = generator
        self.status_code = status_code
        self.headers: dict[str, str] = headers.copy() if headers else {}
        self.content_type = content_type

    async def _write(self, writer: asyncio.StreamWriter) -> None:
        """Write status line, headers, then stream the body."""
        reason = _STATUS_REASONS.get(self.status_code, "Unknown")
        is_sse = self.content_type.startswith("text/event-stream")

        self.headers["Content-Type"] = self.content_type
        self.headers.setdefault("Transfer-Encoding", "chunked")
        if is_sse:
            self.headers.setdefault("Cache-Control", "no-cache")
        self.headers.setdefault("Date", _http_date())
        self.headers.setdefault("Connection", "close")

        buf = bytearray()
        buf.extend(f"HTTP/1.1 {self.status_code} {reason}\r\n".encode("latin-1"))
        for k, v in self.headers.items():
            buf.extend(f"{k}: {v}\r\n".encode("latin-1"))
        buf.extend(b"\r\n")
        writer.write(bytes(buf))
        await writer.drain()

        try:
            async for chunk in self._generator:
                if isinstance(chunk, str):
                    chunk = chunk.encode("utf-8")
                writer.write(f"{len(chunk):x}\r\n".encode("latin-1"))
                writer.write(chunk)
                writer.write(b"\r\n")
                await writer.drain()
            writer.write(b"0\r\n\r\n")
            await writer.drain()
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            logger.debug("Client disconnected during streaming")
        finally:
            aclose = getattr(self._generator, "aclose", None)
            if aclose is not None:
                await aclose()

FileResponse

Bases: Response

Response serving a file from disk.

Parameters:

Name Type Description Default
path str | Path

Path to the file.

required
content_type str | None

MIME type (auto-detected from extension if None).

None
status_code int

HTTP status code.

200
Source code in httpserver/httpserver.py
class FileResponse(Response):
    """Response serving a file from disk.

    Args:
        path: Path to the file.
        content_type: MIME type (auto-detected from extension if ``None``).
        status_code: HTTP status code.
    """

    def __init__(
        self,
        path: str | Path,
        content_type: str | None = None,
        status_code: int = 200,
    ):
        file_path = Path(path)
        body = file_path.read_bytes()
        if content_type is None:
            guessed, _ = mimetypes.guess_type(str(file_path))
            content_type = guessed or "application/octet-stream"
        headers = {
            "Last-Modified": _http_date(os.path.getmtime(file_path)),
        }
        super().__init__(
            body=body,
            status_code=status_code,
            headers=headers,
            content_type=content_type,
        )

App

Async HTTP server application.

Parameters:

Name Type Description Default
max_body_size int

Maximum request body size in bytes.

DEFAULT_MAX_BODY_SIZE
read_timeout float

Timeout for reading a single request (seconds).

DEFAULT_READ_TIMEOUT

Example::

app = App()

@app.get("/hello")
async def hello(request):
    return {"message": "Hello, world!"}

app.run()
Source code in httpserver/httpserver.py
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
class App:
    """Async HTTP server application.

    Args:
        max_body_size: Maximum request body size in bytes.
        read_timeout: Timeout for reading a single request (seconds).

    Example::

        app = App()

        @app.get("/hello")
        async def hello(request):
            return {"message": "Hello, world!"}

        app.run()
    """

    def __init__(
        self,
        *,
        max_body_size: int = DEFAULT_MAX_BODY_SIZE,
        read_timeout: float = DEFAULT_READ_TIMEOUT,
    ):
        self._routes: list[_Route] = []
        self._static_routes: list[tuple[str, str]] = []
        self._before_request_handlers: list[Callable[..., Any]] = []
        self._after_request_handlers: list[Callable[..., Any]] = []
        self._error_handlers: dict[int | type, Callable[..., Any]] = {}
        self._server: asyncio.Server | None = None
        self._shutdown_event: asyncio.Event | None = None
        self.max_body_size = max_body_size
        self.read_timeout = read_timeout
        self.port: int | None = None
        self.host: str | None = None

    # ── Route Registration ───────────────────────────────────────────────

    def route(
        self, url_pattern: str, methods: list[str] | None = None
    ) -> Callable[..., Any]:
        """Register a route handler.

        Args:
            url_pattern: URL pattern with optional parameters.
            methods: HTTP methods to handle (e.g. ``["GET", "POST"]``).
                Defaults to ``["GET"]``.

        Example::

            @app.route("/users/<int:id>", methods=["GET", "POST"])
            async def user(request, id):
                return {"id": id}
        """
        if methods is None:
            methods = ["GET"]

        def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
            pattern, param_names, converters = _compile_route(url_pattern)
            is_async = inspect.iscoroutinefunction(handler)
            self._routes.append(
                _Route(
                    methods=[m.upper() for m in methods],
                    pattern=pattern,
                    handler=handler,
                    is_async=is_async,
                    param_names=param_names,
                    param_converters=converters,
                )
            )
            return handler

        return decorator

    def get(self, path: str) -> Callable[..., Any]:
        """Shorthand for ``@app.route(path, methods=["GET"])``."""
        return self.route(path, methods=["GET"])

    def post(self, path: str) -> Callable[..., Any]:
        """Shorthand for ``@app.route(path, methods=["POST"])``."""
        return self.route(path, methods=["POST"])

    def put(self, path: str) -> Callable[..., Any]:
        """Shorthand for ``@app.route(path, methods=["PUT"])``."""
        return self.route(path, methods=["PUT"])

    def delete(self, path: str) -> Callable[..., Any]:
        """Shorthand for ``@app.route(path, methods=["DELETE"])``."""
        return self.route(path, methods=["DELETE"])

    def patch(self, path: str) -> Callable[..., Any]:
        """Shorthand for ``@app.route(path, methods=["PATCH"])``."""
        return self.route(path, methods=["PATCH"])

    def static(self, url_prefix: str, directory: str) -> None:
        """Register a directory for static file serving.

        Args:
            url_prefix: URL prefix (e.g. ``"/static"``).
            directory: Filesystem path to the directory.

        Example::

            app.static("/assets", "./public")
        """
        url_prefix = url_prefix.rstrip("/")
        abs_dir = os.path.abspath(directory)
        self._static_routes.append((url_prefix, abs_dir))

    # ── Middleware ────────────────────────────────────────────────────────

    def before_request(self, handler: Callable[..., Any]) -> Callable[..., Any]:
        """Register a before-request hook.

        The hook receives ``(request)`` and may return a ``Response`` to
        short-circuit the route handler.
        """
        self._before_request_handlers.append(handler)
        return handler

    def after_request(self, handler: Callable[..., Any]) -> Callable[..., Any]:
        """Register an after-request hook.

        The hook receives ``(request, response)`` and may return a new
        or modified ``Response``.
        """
        self._after_request_handlers.append(handler)
        return handler

    def errorhandler(self, code_or_exc: int | type) -> Callable[..., Any]:
        """Register an error handler.

        Args:
            code_or_exc: HTTP status code (int) or exception class.

        The handler receives ``(request, exception)`` and must return a
        ``Response``.
        """

        def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
            self._error_handlers[code_or_exc] = handler
            return handler

        return decorator

    # ── Request Dispatch ─────────────────────────────────────────────────

    def _match_route(
        self, request: Request
    ) -> tuple[_Route | None, re.Match[str] | None, bool]:
        """Find the first route matching the request path and method.

        Returns:
            (matched_route, regex_match, path_existed).  *path_existed* is
            True when a route matched the path but not the HTTP method.
        """
        path_existed = False
        for route in self._routes:
            m = route.pattern.match(request.path)
            if m is None:
                continue
            path_existed = True
            if "*" not in route.methods and request.method not in route.methods:
                continue
            return route, m, True
        return None, None, path_existed

    async def _invoke_route(
        self, route: _Route, match: re.Match[str], request: Request
    ) -> Response | StreamingResponse:
        """Extract path params, call the handler, return a response."""
        for name, converter, value in zip(
            route.param_names, route.param_converters, match.groups()
        ):
            request.path_params[name] = converter(value)

        try:
            result = await _invoke(route.handler, request, **request.path_params)
            return _coerce_response(result)
        except HTTPException as exc:
            return await self._handle_error(request, exc)
        except Exception as exc:
            logger.exception(
                "Unhandled exception in handler %s",
                getattr(route.handler, "__name__", repr(route.handler)),
            )
            return await self._handle_error(request, exc)

    async def _run_after_hooks(
        self, request: Request, response: Response | StreamingResponse
    ) -> Response | StreamingResponse:
        """Run after-request hooks, allowing them to replace the response."""
        for hook in self._after_request_handlers:
            hook_result = await _invoke(hook, request, response)
            if hook_result is not None:
                response = _coerce_response(hook_result)
        return response

    async def _dispatch(self, request: Request) -> Response | StreamingResponse:
        """Match a request to a route and invoke the handler."""
        # -- before_request hooks --
        for hook in self._before_request_handlers:
            result = await _invoke(hook, request)
            if result is not None:
                return _coerce_response(result)

        # -- Static file check --
        for prefix, directory in self._static_routes:
            file_resp = _resolve_static_file(request.path, prefix, directory)
            if file_resp is not None:
                return file_resp

        # -- Route matching --
        route, match, path_existed = self._match_route(request)

        if route is not None and match is not None:
            response = await self._invoke_route(route, match, request)
            return await self._run_after_hooks(request, response)

        # -- No route matched --
        if path_existed:
            allowed = sorted(
                {
                    m
                    for r in self._routes
                    if r.pattern.match(request.path)
                    for m in r.methods
                    if m != "*"
                }
            )
            exc = HTTPException(405, "Method Not Allowed")
            response = await self._handle_error(request, exc)
            if isinstance(response, Response):
                response.headers["Allow"] = ", ".join(allowed)
            return response

        exc = HTTPException(404, "Not Found")
        return await self._handle_error(request, exc)

    async def _handle_error(
        self, request: Request, exc: Exception
    ) -> Response | StreamingResponse:
        """Resolve an error into a response, consulting registered handlers."""
        if isinstance(exc, HTTPException):
            handler = self._error_handlers.get(exc.status_code)
            if handler is not None:
                result = await _invoke(handler, request, exc)
                return _coerce_response(result)

        for exc_cls, handler in self._error_handlers.items():
            if isinstance(exc_cls, type) and isinstance(exc, exc_cls):
                result = await _invoke(handler, request, exc)
                return _coerce_response(result)

        if isinstance(exc, HTTPException):
            return JSONResponse(
                {"error": exc.message},
                status_code=exc.status_code,
            )
        return JSONResponse(
            {"error": "Internal Server Error"},
            status_code=500,
        )

    # ── Connection Handling ───────────────────────────────────────────────

    @staticmethod
    async def _send_error_and_close(
        writer: asyncio.StreamWriter,
        response: Response | JSONResponse,
    ) -> None:
        """Write an error response to the client and close the connection.

        Swallows broken-pipe / reset errors that occur when the client has
        already disconnected.
        """
        try:
            await response._write(writer)
        except (BrokenPipeError, ConnectionResetError):
            pass
        writer.close()

    async def _handle_connection(
        self,
        reader: asyncio.StreamReader,
        writer: asyncio.StreamWriter,
    ) -> None:
        """Handle a single client connection."""
        peer = writer.get_extra_info("peername")
        client_addr = (peer[0], peer[1]) if peer else ("unknown", 0)

        try:
            method, path, qs, headers, body = await _read_request(
                reader, self.read_timeout, self.max_body_size
            )
        except _BadRequest as exc:
            logger.debug("Bad request from %s: %s", client_addr, exc)
            await self._send_error_and_close(
                writer,
                Response(
                    body=str(exc),
                    status_code=400,
                    content_type="text/plain; charset=utf-8",
                ),
            )
            return
        except HTTPException as exc:
            await self._send_error_and_close(
                writer,
                JSONResponse({"error": exc.message}, status_code=exc.status_code),
            )
            return
        except asyncio.TimeoutError:
            logger.debug("Request read timed out from %s", client_addr)
            await self._send_error_and_close(
                writer,
                JSONResponse({"error": "Request Timeout"}, status_code=408),
            )
            return
        except asyncio.IncompleteReadError:
            logger.debug("Incomplete request body from %s", client_addr)
            await self._send_error_and_close(
                writer,
                JSONResponse(
                    {"error": "Bad Request: incomplete body"}, status_code=400
                ),
            )
            return
        except Exception:
            logger.debug("Failed to read request from %s", client_addr, exc_info=True)
            await self._send_error_and_close(
                writer,
                JSONResponse({"error": "Internal Server Error"}, status_code=500),
            )
            return

        request = Request(
            method=method,
            path=path,
            query_string=qs,
            headers=headers,
            body=body,
            client_addr=client_addr,
            app=self,
        )

        logger.debug("%s %s from %s", method, path, client_addr)

        try:
            response = await self._dispatch(request)
            await response._write(writer)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
            logger.debug("Connection reset by %s during response", client_addr)
        except Exception:
            logger.exception("Error writing response to %s", client_addr)
        finally:
            try:
                writer.close()
                await writer.wait_closed()
            except Exception:
                pass

    # ── Server Lifecycle ─────────────────────────────────────────────────

    def run(
        self,
        host: str = DEFAULT_HOST,
        port: int = DEFAULT_PORT,
        *,
        socket: str | None = None,
    ) -> None:
        """Start the server (blocking).

        Args:
            host: Bind address (ignored when *socket* is set).
            port: Bind port. Use ``0`` for OS-assigned port (ignored when
                *socket* is set).
            socket: Unix domain socket path. When set, the server listens
                on a Unix socket instead of TCP. The socket file permissions
                are restricted to owner-only (``0o600``) after creation.
                Only available on Unix-like systems.
        """
        try:
            asyncio.run(self._serve(host, port, socket=socket))
        except KeyboardInterrupt:
            pass

    async def _serve(self, host: str, port: int, *, socket: str | None = None) -> None:
        """Internal async server loop."""
        self._shutdown_event = asyncio.Event()
        self._socket_path: str | None = None

        if socket:
            server = await self._start_unix_socket(socket)
        else:
            server = await asyncio.start_server(
                self._handle_connection,
                host,
                port,
            )
            addrs = server.sockets[0].getsockname() if server.sockets else (host, port)
            self.host = addrs[0]
            self.port = addrs[1]
            logger.info("Serving on %s:%d", self.host, self.port)

        self._server = server

        loop = asyncio.get_running_loop()
        if sys.platform != "win32":
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, self._shutdown_event.set)

        async with server:
            await self._shutdown_event.wait()
            logger.info("Shutting down server")
            if self._socket_path:
                self._cleanup_socket()

    async def _start_unix_socket(self, socket_path: str) -> asyncio.Server:
        """Start listening on a Unix domain socket.

        Handles stale socket cleanup, permission hardening (``0o600``),
        and registers the path for shutdown cleanup.

        Args:
            socket_path: Path for the Unix domain socket file.

        Returns:
            The ``asyncio.Server`` instance.

        Raises:
            SystemExit: If the path exists but is not a socket, or the
                parent directory does not exist.
        """
        import stat as stat_mod

        path = os.path.realpath(socket_path)

        # Remove stale socket if present
        if os.path.exists(path):
            try:
                st = os.stat(path)
                if stat_mod.S_ISSOCK(st.st_mode):
                    os.unlink(path)
                    logger.info("Removed stale socket: %s", path)
                else:
                    logger.error("Socket path exists and is not a socket: %s", path)
                    sys.exit(1)
            except OSError as exc:
                logger.error("Cannot remove stale socket %s: %s", path, exc)
                sys.exit(1)

        # Ensure parent directory exists
        parent = os.path.dirname(path)
        if not os.path.isdir(parent):
            logger.error("Socket parent directory does not exist: %s", parent)
            sys.exit(1)

        server = await asyncio.start_unix_server(
            self._handle_connection,
            path=path,
        )

        # Restrict permissions to owner-only
        if os.path.exists(path):
            os.chmod(path, 0o600)

        self._socket_path = path
        logger.info("Serving on unix:%s (mode 0600)", path)
        return server

    def _cleanup_socket(self) -> None:
        """Remove the Unix socket file on shutdown."""
        if self._socket_path and os.path.exists(self._socket_path):
            try:
                os.unlink(self._socket_path)
                logger.info("Removed socket: %s", self._socket_path)
            except OSError:
                pass

    def shutdown(self) -> None:
        """Request a graceful server shutdown.

        Safe to call from a request handler.
        """
        if self._shutdown_event is not None:
            self._shutdown_event.set()

route(url_pattern, methods=None)

Register a route handler.

Parameters:

Name Type Description Default
url_pattern str

URL pattern with optional parameters.

required
methods list[str] | None

HTTP methods to handle (e.g. ["GET", "POST"]). Defaults to ["GET"].

None

Example::

@app.route("/users/<int:id>", methods=["GET", "POST"])
async def user(request, id):
    return {"id": id}
Source code in httpserver/httpserver.py
def route(
    self, url_pattern: str, methods: list[str] | None = None
) -> Callable[..., Any]:
    """Register a route handler.

    Args:
        url_pattern: URL pattern with optional parameters.
        methods: HTTP methods to handle (e.g. ``["GET", "POST"]``).
            Defaults to ``["GET"]``.

    Example::

        @app.route("/users/<int:id>", methods=["GET", "POST"])
        async def user(request, id):
            return {"id": id}
    """
    if methods is None:
        methods = ["GET"]

    def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
        pattern, param_names, converters = _compile_route(url_pattern)
        is_async = inspect.iscoroutinefunction(handler)
        self._routes.append(
            _Route(
                methods=[m.upper() for m in methods],
                pattern=pattern,
                handler=handler,
                is_async=is_async,
                param_names=param_names,
                param_converters=converters,
            )
        )
        return handler

    return decorator

get(path)

Shorthand for @app.route(path, methods=["GET"]).

Source code in httpserver/httpserver.py
def get(self, path: str) -> Callable[..., Any]:
    """Shorthand for ``@app.route(path, methods=["GET"])``."""
    return self.route(path, methods=["GET"])

post(path)

Shorthand for @app.route(path, methods=["POST"]).

Source code in httpserver/httpserver.py
def post(self, path: str) -> Callable[..., Any]:
    """Shorthand for ``@app.route(path, methods=["POST"])``."""
    return self.route(path, methods=["POST"])

put(path)

Shorthand for @app.route(path, methods=["PUT"]).

Source code in httpserver/httpserver.py
def put(self, path: str) -> Callable[..., Any]:
    """Shorthand for ``@app.route(path, methods=["PUT"])``."""
    return self.route(path, methods=["PUT"])

delete(path)

Shorthand for @app.route(path, methods=["DELETE"]).

Source code in httpserver/httpserver.py
def delete(self, path: str) -> Callable[..., Any]:
    """Shorthand for ``@app.route(path, methods=["DELETE"])``."""
    return self.route(path, methods=["DELETE"])

patch(path)

Shorthand for @app.route(path, methods=["PATCH"]).

Source code in httpserver/httpserver.py
def patch(self, path: str) -> Callable[..., Any]:
    """Shorthand for ``@app.route(path, methods=["PATCH"])``."""
    return self.route(path, methods=["PATCH"])

static(url_prefix, directory)

Register a directory for static file serving.

Parameters:

Name Type Description Default
url_prefix str

URL prefix (e.g. "/static").

required
directory str

Filesystem path to the directory.

required

Example::

app.static("/assets", "./public")
Source code in httpserver/httpserver.py
def static(self, url_prefix: str, directory: str) -> None:
    """Register a directory for static file serving.

    Args:
        url_prefix: URL prefix (e.g. ``"/static"``).
        directory: Filesystem path to the directory.

    Example::

        app.static("/assets", "./public")
    """
    url_prefix = url_prefix.rstrip("/")
    abs_dir = os.path.abspath(directory)
    self._static_routes.append((url_prefix, abs_dir))

before_request(handler)

Register a before-request hook.

The hook receives (request) and may return a Response to short-circuit the route handler.

Source code in httpserver/httpserver.py
def before_request(self, handler: Callable[..., Any]) -> Callable[..., Any]:
    """Register a before-request hook.

    The hook receives ``(request)`` and may return a ``Response`` to
    short-circuit the route handler.
    """
    self._before_request_handlers.append(handler)
    return handler

after_request(handler)

Register an after-request hook.

The hook receives (request, response) and may return a new or modified Response.

Source code in httpserver/httpserver.py
def after_request(self, handler: Callable[..., Any]) -> Callable[..., Any]:
    """Register an after-request hook.

    The hook receives ``(request, response)`` and may return a new
    or modified ``Response``.
    """
    self._after_request_handlers.append(handler)
    return handler

errorhandler(code_or_exc)

Register an error handler.

Parameters:

Name Type Description Default
code_or_exc int | type

HTTP status code (int) or exception class.

required

The handler receives (request, exception) and must return a Response.

Source code in httpserver/httpserver.py
def errorhandler(self, code_or_exc: int | type) -> Callable[..., Any]:
    """Register an error handler.

    Args:
        code_or_exc: HTTP status code (int) or exception class.

    The handler receives ``(request, exception)`` and must return a
    ``Response``.
    """

    def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
        self._error_handlers[code_or_exc] = handler
        return handler

    return decorator

run(host=DEFAULT_HOST, port=DEFAULT_PORT, *, socket=None)

Start the server (blocking).

Parameters:

Name Type Description Default
host str

Bind address (ignored when socket is set).

DEFAULT_HOST
port int

Bind port. Use 0 for OS-assigned port (ignored when socket is set).

DEFAULT_PORT
socket str | None

Unix domain socket path. When set, the server listens on a Unix socket instead of TCP. The socket file permissions are restricted to owner-only (0o600) after creation. Only available on Unix-like systems.

None
Source code in httpserver/httpserver.py
def run(
    self,
    host: str = DEFAULT_HOST,
    port: int = DEFAULT_PORT,
    *,
    socket: str | None = None,
) -> None:
    """Start the server (blocking).

    Args:
        host: Bind address (ignored when *socket* is set).
        port: Bind port. Use ``0`` for OS-assigned port (ignored when
            *socket* is set).
        socket: Unix domain socket path. When set, the server listens
            on a Unix socket instead of TCP. The socket file permissions
            are restricted to owner-only (``0o600``) after creation.
            Only available on Unix-like systems.
    """
    try:
        asyncio.run(self._serve(host, port, socket=socket))
    except KeyboardInterrupt:
        pass

shutdown()

Request a graceful server shutdown.

Safe to call from a request handler.

Source code in httpserver/httpserver.py
def shutdown(self) -> None:
    """Request a graceful server shutdown.

    Safe to call from a request handler.
    """
    if self._shutdown_event is not None:
        self._shutdown_event.set()

abort(status_code, message=None)

Raise an :class:HTTPException with the given status code.

Parameters:

Name Type Description Default
status_code int

HTTP status code.

required
message str | None

Optional error message.

None
Source code in httpserver/httpserver.py
def abort(status_code: int, message: str | None = None) -> None:
    """Raise an :class:`HTTPException` with the given status code.

    Args:
        status_code: HTTP status code.
        message: Optional error message.
    """
    raise HTTPException(status_code, message)