├── tests ├── __init__.py ├── e2e │ ├── __init__.py │ └── test_httpx.py ├── trio │ ├── __init__.py │ ├── test_lifespan.py │ └── test_keep_alive.py ├── asyncio │ ├── __init__.py │ ├── test_task_group.py │ ├── helpers.py │ ├── test_tcp_server.py │ ├── test_lifespan.py │ └── test_keep_alive.py ├── middleware │ ├── __init__.py │ ├── test_proxy_fix.py │ ├── test_dispatcher.py │ └── test_http_to_https.py ├── assets │ ├── config.toml │ ├── config.py │ ├── config_ssl.py │ ├── cert.pem │ └── key.pem ├── conftest.py ├── test_utils.py ├── test___main__.py ├── protocol │ └── test_h2.py ├── test_logging.py ├── helpers.py └── test_config.py ├── src └── hypercorn │ ├── py.typed │ ├── __init__.py │ ├── events.py │ ├── middleware │ ├── __init__.py │ ├── wsgi.py │ ├── http_to_https.py │ ├── proxy_fix.py │ └── dispatcher.py │ ├── trio │ ├── statsd.py │ ├── __init__.py │ ├── udp_server.py │ ├── worker_context.py │ ├── task_group.py │ ├── lifespan.py │ ├── tcp_server.py │ └── run.py │ ├── asyncio │ ├── statsd.py │ ├── __init__.py │ ├── worker_context.py │ ├── task_group.py │ ├── udp_server.py │ ├── lifespan.py │ └── tcp_server.py │ ├── protocol │ ├── events.py │ ├── __init__.py │ └── h3.py │ ├── statsd.py │ ├── run.py │ └── app_wrappers.py ├── artwork ├── logo.png ├── logo_small.png ├── LICENSE └── logo_small.svg ├── docs ├── _static │ ├── logo.png │ └── logo_small.png ├── reference │ ├── index.rst │ └── api.rst ├── tutorials │ ├── index.rst │ ├── installation.rst │ ├── usage.rst │ └── quickstart.rst ├── discussion │ ├── index.rst │ ├── design_choices.rst │ ├── backpressure.rst │ ├── workers.rst │ ├── flow.rst │ ├── http2.rst │ └── closing.rst ├── how_to_guides │ ├── index.rst │ ├── server_names.rst │ ├── wsgi_apps.rst │ ├── statsd.rst │ ├── binds.rst │ ├── dispatch_apps.rst │ ├── proxy_fix.rst │ ├── http_https_redirect.rst │ ├── logging.rst │ └── api_usage.rst ├── Makefile ├── make.bat ├── index.rst └── conf.py ├── setup.cfg ├── .gitignore ├── .readthedocs.yaml ├── compliance ├── autobahn │ ├── summarise.py │ ├── fuzzingclient.json │ └── server.py └── h2spec │ ├── server.py │ ├── cert.pem │ └── key.pem ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── LICENSE ├── tox.ini ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/trio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hypercorn/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /artwork/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/hypercorn/HEAD/artwork/logo.png -------------------------------------------------------------------------------- /artwork/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/hypercorn/HEAD/artwork/logo_small.png -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/hypercorn/HEAD/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgjones/hypercorn/HEAD/docs/_static/logo_small.png -------------------------------------------------------------------------------- /tests/assets/config.toml: -------------------------------------------------------------------------------- 1 | access_log_format = "bob" 2 | bind = "127.0.0.1:5555" 3 | h11_max_incomplete_size = 4 4 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Reference 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | api.rst 9 | -------------------------------------------------------------------------------- /src/hypercorn/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .config import Config 4 | 5 | __all__ = ("Config",) 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E252, FI58, W503, W504 3 | max_line_length = 100 4 | min_version = 3.8 5 | require_code = True 6 | -------------------------------------------------------------------------------- /docs/reference/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | source/modules.rst 9 | -------------------------------------------------------------------------------- /docs/tutorials/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Tutorials 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | installation.rst 9 | quickstart.rst 10 | usage.rst 11 | -------------------------------------------------------------------------------- /tests/assets/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ssl # noqa: F401 4 | 5 | access_log_format = "bob" 6 | bind = "127.0.0.1:5555" 7 | h11_max_incomplete_size = 4 8 | -------------------------------------------------------------------------------- /tests/assets/config_ssl.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | access_log_format = "bob" 4 | bind = ["127.0.0.1:5555"] 5 | certfile = "tests/assets/cert.pem" 6 | ciphers = "ECDHE+AESGCM" 7 | h11_max_incomplete_size = 4 8 | keyfile = "tests/assets/key.pem" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | venv/ 3 | __pycache__/ 4 | Hypercorn.egg-info/ 5 | .cache/ 6 | .tox/ 7 | TODO 8 | .mypy_cache/ 9 | .pytest_cache/ 10 | .hypothesis/ 11 | docs/_build/ 12 | docs/reference/source/ 13 | dist/ 14 | .coverage 15 | pdm.lock 16 | .idea/ 17 | .DS_Store 18 | -------------------------------------------------------------------------------- /docs/discussion/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Discussions 3 | =========== 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | backpressure.rst 9 | closing.rst 10 | design_choices.rst 11 | dos_mitigations.rst 12 | flow.rst 13 | http2.rst 14 | workers.rst 15 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.14" 7 | 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | 15 | sphinx: 16 | configuration: docs/conf.py 17 | -------------------------------------------------------------------------------- /docs/tutorials/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | Hypercorn is only compatible with Python 3.10 or higher and can be 7 | installed using pip or your favorite python package manager. 8 | 9 | .. code-block:: sh 10 | 11 | pip install hypercorn 12 | -------------------------------------------------------------------------------- /compliance/autobahn/summarise.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | with open('reports/servers/index.json') as file_: 5 | report = json.load(file_) 6 | 7 | failures = sum(value['behavior'] == 'FAILED' for value in report['websockets'].values()) 8 | 9 | if failures > 0: 10 | sys.exit(1) 11 | else: 12 | sys.exit(0) 13 | -------------------------------------------------------------------------------- /compliance/autobahn/fuzzingclient.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": {"failByDrop": false}, 3 | "outdir": "./reports/servers", 4 | 5 | "servers": [{"agent": "websockets", "url": "ws://localhost:8000/", "options": {"version": 18}}], 6 | 7 | "cases": ["*"], 8 | "exclude-cases": ["12.*", "13.*"], 9 | "exclude-agent-cases": {} 10 | } 11 | -------------------------------------------------------------------------------- /docs/how_to_guides/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | How to guides 3 | ============= 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | api_usage.rst 9 | binds.rst 10 | configuring.rst 11 | dispatch_apps.rst 12 | http_https_redirect.rst 13 | logging.rst 14 | proxy_fix.rst 15 | server_names.rst 16 | statsd.rst 17 | wsgi_apps.rst 18 | -------------------------------------------------------------------------------- /docs/discussion/design_choices.rst: -------------------------------------------------------------------------------- 1 | .. _design_choices: 2 | 3 | Design Choices 4 | ============== 5 | 6 | Callbacks or streaming 7 | ---------------------- 8 | 9 | The asyncio callback ``create_server`` approach is faster than the 10 | streaming ``start_server`` approach, and hence is used. This is based 11 | on benchmarking and the `uvloop 12 | `_ research. 13 | -------------------------------------------------------------------------------- /src/hypercorn/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | from dataclasses import dataclass 5 | 6 | 7 | class Event(ABC): 8 | pass 9 | 10 | 11 | @dataclass(frozen=True) 12 | class RawData(Event): 13 | data: bytes 14 | address: tuple[str, int] | None = None 15 | 16 | 17 | @dataclass(frozen=True) 18 | class Closed(Event): 19 | pass 20 | 21 | 22 | @dataclass(frozen=True) 23 | class Updated(Event): 24 | idle: bool 25 | -------------------------------------------------------------------------------- /src/hypercorn/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .dispatcher import DispatcherMiddleware 4 | from .http_to_https import HTTPToHTTPSRedirectMiddleware 5 | from .proxy_fix import ProxyFixMiddleware 6 | from .wsgi import AsyncioWSGIMiddleware, TrioWSGIMiddleware 7 | 8 | __all__ = ( 9 | "AsyncioWSGIMiddleware", 10 | "DispatcherMiddleware", 11 | "HTTPToHTTPSRedirectMiddleware", 12 | "ProxyFixMiddleware", 13 | "TrioWSGIMiddleware", 14 | ) 15 | -------------------------------------------------------------------------------- /src/hypercorn/trio/statsd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import trio 4 | 5 | from ..config import Config 6 | from ..statsd import StatsdLogger as Base 7 | 8 | 9 | class StatsdLogger(Base): 10 | def __init__(self, config: Config) -> None: 11 | super().__init__(config) 12 | self.address = tuple(config.statsd_host.rsplit(":", 1)) 13 | self.socket = trio.socket.socket(trio.socket.AF_INET, trio.socket.SOCK_DGRAM) 14 | 15 | async def _socket_send(self, message: bytes) -> None: 16 | await self.socket.sendto(message, self.address) 17 | -------------------------------------------------------------------------------- /artwork/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal (CC0 1.0) 2 | 3 | The Quart logo is Copyright © 2018 Phil Jones 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | -------------------------------------------------------------------------------- /docs/how_to_guides/server_names.rst: -------------------------------------------------------------------------------- 1 | .. _server_names: 2 | 3 | Server names 4 | ============ 5 | 6 | Hypercorn can be configured to only respond to requests that have a 7 | recognised host header value by adding the recognised hosts to the 8 | ``server_names`` configuration variable. Any requests that have a host 9 | value not in this list will be responded to with a 404. 10 | 11 | DNS rebinding attacks 12 | --------------------- 13 | 14 | Setting the ``server_names`` configuration variable helps mitigate 15 | `DNS rebinding attacks `_ 16 | and hence is recommended. 17 | -------------------------------------------------------------------------------- /docs/tutorials/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ===== 5 | 6 | Hypercorn is invoked via the command line script ``hypercorn`` 7 | 8 | .. code-block:: shell 9 | 10 | $ hypercorn [OPTIONS] MODULE_APP 11 | 12 | where ``MODULE_APP`` has the pattern 13 | ``$(MODULE_NAME):$(VARIABLE_NAME)`` with the module name as a full 14 | (dotted) path to a python module containing a named variable that 15 | conforms to the ASGI or WSGI framework specifications. 16 | 17 | The ``MODULE_APP`` can be prefixed with ``asgi:`` or ``wsgi:`` to 18 | ensure that the loaded app is treated as either an asgi or wsgi app. 19 | 20 | See :ref:`how_to_configure` for the full list of command line 21 | arguments. 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = Hypercorn 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/discussion/backpressure.rst: -------------------------------------------------------------------------------- 1 | .. _backpressure: 2 | 3 | Managing backpressure 4 | ===================== 5 | 6 | The connection between Hypercorn and a client can be paused by either 7 | party to allow that party time to process the information it has 8 | received, i.e. to catch up. When the connection is paused the sender 9 | effectively receives pressure to stop sending data. This is commonly 10 | termed back pressure. 11 | 12 | Hypercorn will respond to client backpressure by pausing the sending 13 | of data. This back pressure will propogate back to any ASGI framework 14 | via a blocked (without blocking the event loop) ASGI send 15 | awaitable. In other words any ``await send(message)`` calls will block 16 | the coroutine till the client backpressure has abated or the 17 | connection is closed. 18 | -------------------------------------------------------------------------------- /compliance/autobahn/server.py: -------------------------------------------------------------------------------- 1 | async def app(scope, receive, send): 2 | while True: 3 | event = await receive() 4 | if event['type'] == 'websocket.disconnect': 5 | break 6 | elif event['type'] == 'websocket.connect': 7 | await send({'type': 'websocket.accept'}) 8 | elif event['type'] == 'websocket.receive': 9 | await send({ 10 | 'type': 'websocket.send', 11 | 'bytes': event['bytes'], 12 | 'text': event['text'], 13 | }) 14 | elif event['type'] == 'lifespan.startup': 15 | await send({'type': 'lifespan.startup.complete'}) 16 | elif event['type'] == 'lifespan.shutdown': 17 | await send({'type': 'lifespan.shutdown.complete'}) 18 | break 19 | -------------------------------------------------------------------------------- /docs/discussion/workers.rst: -------------------------------------------------------------------------------- 1 | .. _workers: 2 | 3 | Workers 4 | ======= 5 | 6 | Hypercorn supports asyncio, uvloop, or trio worker classes thereby 7 | allowing ASGI applications writen with these in mind to be used. 8 | 9 | Asyncio 10 | ------- 11 | 12 | Asyncio is the default event loop implementation that is part of the 13 | standard library. It is relatively well supported by third party 14 | libraries. 15 | 16 | Uvloop 17 | ------ 18 | 19 | Uvloop is a different event loop policy for asyncio. It is used as it 20 | is quicker than the asyncio default, however it does not work on 21 | Windows. 22 | 23 | Trio 24 | ---- 25 | 26 | Trio is a third party event loop implementation that is not compatible 27 | with asyncio. It is less supported, however the API is much nicer to 28 | use and it is harder to make mistakes. 29 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/statsd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | from ..config import Config 6 | from ..statsd import StatsdLogger as Base 7 | 8 | 9 | class _DummyProto(asyncio.DatagramProtocol): 10 | pass 11 | 12 | 13 | class StatsdLogger(Base): 14 | def __init__(self, config: Config) -> None: 15 | super().__init__(config) 16 | self.address = config.statsd_host.rsplit(":", 1) 17 | self.transport: asyncio.BaseTransport | None = None 18 | 19 | async def _socket_send(self, message: bytes) -> None: 20 | if self.transport is None: 21 | self.transport, _ = await asyncio.get_event_loop().create_datagram_endpoint( 22 | _DummyProto, remote_addr=(self.address[0], int(self.address[1])) 23 | ) 24 | 25 | self.transport.sendto(message) # type: ignore 26 | -------------------------------------------------------------------------------- /compliance/h2spec/server.py: -------------------------------------------------------------------------------- 1 | async def app(scope, receive, send): 2 | while True: 3 | event = await receive() 4 | if event['type'] == 'http.disconnect': 5 | break 6 | elif event['type'] == 'http.request' and not event.get('more_body', False): 7 | await send_data(send) 8 | break 9 | elif event['type'] == 'lifespan.startup': 10 | await send({'type': 'lifespan.startup.complete'}) 11 | elif event['type'] == 'lifespan.shutdown': 12 | await send({'type': 'lifespan.shutdown.complete'}) 13 | break 14 | 15 | async def send_data(send): 16 | await send({ 17 | 'type': 'http.response.start', 18 | 'status': 200, 19 | 'headers': [(b'content-length', b'5')], 20 | }) 21 | await send({ 22 | 'type': 'http.response.body', 23 | 'body': b'Hello', 24 | 'more_body': False, 25 | }) 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: read 15 | 16 | steps: 17 | - uses: pgjones/actions/build@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 18 | 19 | pypi-publish: 20 | needs: ['build'] 21 | environment: 'publish' 22 | 23 | name: upload release to PyPI 24 | runs-on: ubuntu-latest 25 | permissions: 26 | # IMPORTANT: this permission is mandatory for trusted publishing 27 | id-token: write 28 | steps: 29 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 30 | 31 | - name: Publish package distributions to PyPI 32 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 33 | with: 34 | packages_dir: artifact/ 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Hypercorn 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/tutorials/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | Hello World 7 | ----------- 8 | 9 | A very simple ASGI app that simply returns a response containing 10 | ``hello`` is, (file ``hello_world.py``) 11 | 12 | .. code-block:: python 13 | 14 | async def app(scope, receive, send): 15 | if scope["type"] != "http": 16 | raise Exception("Only the HTTP protocol is supported") 17 | 18 | await send({ 19 | 'type': 'http.response.start', 20 | 'status': 200, 21 | 'headers': [ 22 | (b'content-type', b'text/plain'), 23 | (b'content-length', b'5'), 24 | ], 25 | }) 26 | await send({ 27 | 'type': 'http.response.body', 28 | 'body': b'hello', 29 | }) 30 | 31 | and is simply run via 32 | 33 | .. code-block:: console 34 | 35 | hypercorn hello_world:app 36 | 37 | and tested by 38 | 39 | .. code-block:: sh 40 | 41 | curl localhost:8000 42 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from _pytest.monkeypatch import MonkeyPatch 5 | 6 | import hypercorn.config 7 | from hypercorn.typing import ConnectionState, HTTPScope 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def _time(monkeypatch: MonkeyPatch) -> None: 12 | monkeypatch.setattr(hypercorn.config, "time", lambda: 5000) 13 | 14 | 15 | @pytest.fixture(name="http_scope") 16 | def _http_scope() -> HTTPScope: 17 | return { 18 | "type": "http", 19 | "asgi": {}, 20 | "http_version": "2", 21 | "method": "GET", 22 | "scheme": "https", 23 | "path": "/", 24 | "raw_path": b"/", 25 | "query_string": b"a=b", 26 | "root_path": "", 27 | "headers": [ 28 | (b"User-Agent", b"Hypercorn"), 29 | (b"X-Hypercorn", b"Hypercorn"), 30 | (b"Referer", b"hypercorn"), 31 | ], 32 | "client": ("127.0.0.1", 80), 33 | "server": None, 34 | "extensions": {}, 35 | "state": ConnectionState({}), 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright P G Jones 2018. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. title:: Hypercorn documentation 4 | 5 | .. image:: _static/logo.png 6 | :width: 300px 7 | :alt: Hypercorn 8 | 9 | Hypercorn is an `ASGI 10 | `_ web 11 | server based on the sans-io hyper, `h11 12 | `_, `h2 13 | `_, and `wsproto 14 | `_ libraries and inspired by 15 | Gunicorn. Hypercorn supports HTTP/1, HTTP/2, WebSockets (over HTTP/1 16 | and HTTP/2), ASGI/2, and ASGI/3 specifications. Hypercorn can utilise 17 | asyncio, uvloop, or trio worker types. 18 | 19 | Hypercorn was initially part of `Quart 20 | `_ before being separated out into a 21 | standalone ASGI server. Hypercorn forked from version 0.5.0 of Quart. 22 | 23 | Hypercorn is developed on `Github 24 | `_. You are very welcome to 25 | open `issues `_ or 26 | propose `pull requests 27 | `_. 28 | 29 | Contents 30 | -------- 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | 35 | tutorials/index.rst 36 | how_to_guides/index.rst 37 | discussion/index.rst 38 | reference/index.rst 39 | -------------------------------------------------------------------------------- /docs/how_to_guides/wsgi_apps.rst: -------------------------------------------------------------------------------- 1 | .. _wsgi_apps: 2 | 3 | Serve WSGI applications 4 | ======================= 5 | 6 | Hypercorn directly serves WSGI applications: 7 | 8 | .. code-block:: shell 9 | 10 | $ hypercorn module:wsgi_app 11 | 12 | WSGI Middleware 13 | --------------- 14 | 15 | If a WSGI application is being combined with ASGI middleware it is 16 | best to use either ``AsyncioWSGIMiddleware`` or ``TrioWSGIMiddleware`` 17 | middleware. To do so simply wrap the WSGI app with the appropriate 18 | middleware for the hypercorn worker, 19 | 20 | .. code-block:: python 21 | 22 | from hypercorn.middleware import AsyncioWSGIMiddleware, TrioWSGIMiddleware 23 | 24 | asyncio_app = AsyncioWSGIMiddleware(wsgi_app) 25 | trio_app = TrioWSGIMiddleware(wsgi_app) 26 | 27 | which can then be passed to other middleware served by hypercorn, 28 | 29 | Limiting the request body size 30 | ------------------------------ 31 | 32 | As the request body is stored in memory before being processed it is 33 | important to limit the max size. This is configured by the 34 | ``wsgi_max_body_size`` configuration attribute. 35 | 36 | When using middleware the ``AsyncioWSGIMiddleware`` and 37 | ``TrioWSGIMiddleware`` have a default max size that can be configured, 38 | 39 | .. code-block:: python 40 | 41 | app = AsyncioWSGIMiddleware(wsgi_app, max_body_size=20) # Bytes 42 | -------------------------------------------------------------------------------- /docs/how_to_guides/statsd.rst: -------------------------------------------------------------------------------- 1 | .. _using_statsd: 2 | 3 | Statsd Logging 4 | ============== 5 | 6 | Hypercorn can optionally log metrics using the `StatsD 7 | `_ or `DogStatsD 8 | `_ protocols. The 9 | metrics logged are, 10 | 11 | - ``hypercorn.requests``: rate of requests 12 | - ``hypercorn.request.duration``: request duration in milliseconds 13 | - ``hypercorn.request.status.[#code]``: rate of responses by status 14 | code 15 | - ``hypercorn.log.critical``: rate of critical log messages 16 | - ``hypercorn.log.error``: rate of error log messages 17 | - ``hypercorn.log.warning``: rate of warning log messages 18 | - ``hypercorn.log.exception``: rate of exceptional log messages 19 | 20 | Usage 21 | ----- 22 | 23 | Setting the config ``statsd_host`` to ``[host]:[port]`` will result in 24 | these metrics being set to that host, port combination. The config 25 | ``statsd_prefix`` can be used to prefix all metrics and 26 | ``dogstatsd_tags`` can be used to add tags to each metric. 27 | 28 | Customising the statsd logger 29 | ----------------------------- 30 | 31 | The statsd logger class can be customised by calling 32 | ``set_statsd_logger_class`` method of the ``Config`` class. This is 33 | only possible when using the python based configuration file. The 34 | ``hypercorn.statsd.StatsdLogger`` class is used by default. 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = docs,format,mypy,py310,py311,py312,py313,py314,package,pep8 3 | isolated_build = true 4 | 5 | [testenv] 6 | deps = 7 | httpx 8 | hypothesis 9 | pytest 10 | pytest-asyncio 11 | pytest-cov 12 | pytest-sugar 13 | pytest-trio 14 | commands = pytest --cov=hypercorn {posargs} 15 | 16 | [testenv:docs] 17 | basepython = python3.14 18 | deps = 19 | pydata-sphinx-theme 20 | sphinx 21 | sphinxcontrib-mermaid 22 | trio 23 | commands = 24 | sphinx-apidoc -e -f -o docs/reference/source/ src/hypercorn/ src/hypercorn/protocol/quic.py src/hypercorn/protocol/h3.py 25 | sphinx-build -W --keep-going -b html -d {envtmpdir}/doctrees docs/ docs/_build/html/ 26 | 27 | [testenv:format] 28 | basepython = python3.14 29 | deps = 30 | black 31 | isort 32 | commands = 33 | black --check --diff src/hypercorn/ tests/ 34 | isort --check --diff src/hypercorn tests 35 | 36 | [testenv:pep8] 37 | basepython = python3.14 38 | deps = 39 | flake8 40 | pep8-naming 41 | flake8-print 42 | commands = flake8 src/hypercorn/ tests/ 43 | 44 | [testenv:mypy] 45 | basepython = python3.14 46 | deps = 47 | mypy 48 | pytest 49 | trio 50 | commands = 51 | mypy src/hypercorn/ tests/ 52 | 53 | [testenv:package] 54 | basepython = python3.14 55 | deps = 56 | pdm 57 | twine 58 | commands = 59 | pdm build 60 | twine check dist/* 61 | -------------------------------------------------------------------------------- /src/hypercorn/protocol/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from hypercorn.typing import ConnectionState 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Event: 10 | stream_id: int 11 | 12 | 13 | @dataclass(frozen=True) 14 | class Request(Event): 15 | headers: list[tuple[bytes, bytes]] 16 | http_version: str 17 | method: str 18 | raw_path: bytes 19 | state: ConnectionState 20 | 21 | 22 | @dataclass(frozen=True) 23 | class Body(Event): 24 | data: bytes 25 | 26 | 27 | @dataclass(frozen=True) 28 | class EndBody(Event): 29 | pass 30 | 31 | 32 | @dataclass(frozen=True) 33 | class Trailers(Event): 34 | headers: list[tuple[bytes, bytes]] 35 | 36 | 37 | @dataclass(frozen=True) 38 | class Data(Event): 39 | data: bytes 40 | 41 | 42 | @dataclass(frozen=True) 43 | class EndData(Event): 44 | pass 45 | 46 | 47 | @dataclass(frozen=True) 48 | class Response(Event): 49 | headers: list[tuple[bytes, bytes]] 50 | status_code: int 51 | 52 | 53 | @dataclass(frozen=True) 54 | class InformationalResponse(Event): 55 | headers: list[tuple[bytes, bytes]] 56 | status_code: int 57 | 58 | def __post_init__(self) -> None: 59 | if self.status_code >= 200 or self.status_code < 100: 60 | raise ValueError(f"Status code must be 1XX not {self.status_code}") 61 | 62 | 63 | @dataclass(frozen=True) 64 | class StreamClosed(Event): 65 | pass 66 | -------------------------------------------------------------------------------- /docs/discussion/flow.rst: -------------------------------------------------------------------------------- 1 | Flow 2 | ==== 3 | 4 | These are the expected event flows/sequences. 5 | 6 | H11/H2 7 | ------ 8 | 9 | A typical HTTP/1 or HTTP/2 request with response with the connection 10 | specified to close on response. 11 | 12 | .. mermaid:: 13 | 14 | sequenceDiagram 15 | TCPServer->>H11/H2: RawData 16 | H11/H2->>HTTPStream: Request 17 | H11/H2->>HTTPStream: Body 18 | HTTPStream->>App: http.request[more_body=True] 19 | H11/H2->>HTTPStream: EndBody 20 | HTTPStream->>App: http.request[more_body=False] 21 | App->>HTTPStream: http.response.start 22 | App->>HTTPStream: http.response.body 23 | HTTPStream->>H11/H2: Response 24 | H11/H2->>TCPServer: RawData 25 | HTTPStream->>H11/H2: Body 26 | H11/H2->>TCPServer: RawData 27 | HTTPStream->>H11/H2: EndBody 28 | H11/H2->>TCPServer: RawData 29 | H11/H2->>HTTPStream: StreamClosed 30 | HTTPStream->>App: http.disconnect 31 | H11/H2->>TCPServer: Closed 32 | 33 | 34 | H11 early client cancel 35 | ----------------------- 36 | 37 | The flow as expected if the connection is closed before the server has 38 | the opportunity to respond. 39 | 40 | .. mermaid:: 41 | 42 | sequenceDiagram 43 | TCPServer->>H11/H2: RawData 44 | H11/H2->>HTTPStream: Request 45 | H11/H2->>HTTPStream: Body 46 | HTTPStream->>App: http.request[more_body=True] 47 | TCPServer->>H11/H2: Closed 48 | H11/H2->>HTTPStream: StreamClosed 49 | HTTPStream->>App: http.disconnect 50 | -------------------------------------------------------------------------------- /docs/how_to_guides/binds.rst: -------------------------------------------------------------------------------- 1 | .. _binds: 2 | 3 | Binds 4 | ===== 5 | 6 | Hypercorn serves by binding to sockets, sockets are specified by their 7 | address and can be IPv4, IPv6, a unix domain (on unix) or a file 8 | descriptor. By default Hypercorn will bind to "127.0.0.1:8000". 9 | 10 | 11 | Unix domain 12 | ----------- 13 | 14 | To specify a unix domain socket use a ``unix:`` prefix before 15 | specify an address. For example, 16 | 17 | .. code-block:: sh 18 | 19 | $ hypercorn --bind unix:/tmp/socket.sock 20 | 21 | It is possible to control the permissions and ownership of the created 22 | socket using the ``umask``, ``user``, and ``group`` configurations 23 | respectively. 24 | 25 | File descriptor 26 | --------------- 27 | 28 | To specify a file descriptor to bind too use a ``fd://`` prefix before 29 | the descriptor number. For example, 30 | 31 | .. code-block:: sh 32 | 33 | $ hypercorn --bind fd://2 34 | 35 | 36 | Multiple binds 37 | -------------- 38 | 39 | Hypercorn supports binding to multiple addresses and serving on all of 40 | them at the same time. This allows for example binding to an IPv4 and 41 | an IPv6 address. To do this simply specify multiple binds either on 42 | the command line, or in the configuration file. For example for a dual 43 | stack binding, 44 | 45 | .. code-block:: sh 46 | 47 | $ hypercorn --bind '0.0.0.0:5000' --bind '[::]:5000' ... 48 | 49 | or within the configuration file, 50 | 51 | .. code-block:: python 52 | 53 | bind = ["0.0.0.0:5000", "[::]:5000"] 54 | -------------------------------------------------------------------------------- /docs/how_to_guides/dispatch_apps.rst: -------------------------------------------------------------------------------- 1 | .. _dispatch_apps: 2 | 3 | Dispatch to multiple ASGI applications 4 | ====================================== 5 | 6 | It is often useful serve multiple ASGI applications at once, under 7 | differing root paths. Hypercorn does not support this directly, but 8 | the ``DispatcherMiddleware`` included with Hypercorn can. This 9 | middleware allows multiple applications to be served on different 10 | mounts. 11 | 12 | The ``DispatcherMiddleware`` takes a dictionary of applications keyed 13 | by the root path. The order of entry in this dictionary is important, 14 | as the root paths will be checked in this order. Hence it is important 15 | to add ``/a/b`` before ``/a`` or the latter will match everything 16 | first. Also note that the root path should not include the trailing 17 | slash. 18 | 19 | An example usage is to to serve a graphql application alongside a 20 | static file serving application. Using the graphql app is called 21 | ``graphql_app`` serving everything with the root path ``/graphql`` and 22 | a static file app called ``static_app`` serving everything else i.e. a 23 | root path of ``/`` the ``DispatcherMiddleware`` can be setup as, 24 | 25 | .. code-block:: python 26 | 27 | from hypercorn.middleware import DispatcherMiddleware 28 | 29 | dispatcher_app = DispatcherMiddleware({ 30 | "/graphql": graphql_app, 31 | "/": static_app, 32 | }) 33 | 34 | which can then be served by hypercorn, 35 | 36 | .. code-block:: shell 37 | 38 | $ hypercorn module:dispatcher_app 39 | -------------------------------------------------------------------------------- /docs/discussion/http2.rst: -------------------------------------------------------------------------------- 1 | .. _http2: 2 | 3 | HTTP/2 4 | ====== 5 | 6 | Hypercorn is based on the excellent `hyper-h2 7 | `_ library. 8 | 9 | TLS settings 10 | ------------ 11 | 12 | The recommendations in this documentation for the SSL/TLS ciphers and 13 | version are from `RFC 7540 `_. As 14 | required in the RFC ``ECDHE+AESGCM`` is the minimal cipher set HTTP/2 15 | and TLSv2 the minimal TLS version servers should support. By default 16 | Hypercorn will use this as the cipher set. 17 | 18 | ALPN Protocol 19 | ~~~~~~~~~~~~~ 20 | 21 | The ALPN Protocols should be set to include ``h2`` and ``http/1.1`` as 22 | Hypercorn supports both. It is feasible to omit one to only serve the 23 | other. If these aren't set most clients will assume Hypercorn is a 24 | HTTP/1.1 only server. By default Hypercorn will set h2 and http/1.1 as 25 | the ALPN protocols. 26 | 27 | No-TLS 28 | ~~~~~~ 29 | 30 | Most clients, including all the web browsers only support HTTP/2 over 31 | TLS. Hypercorn, however, supports the h2c HTTP/1.1 to HTTP/2 upgrade 32 | process. This allows a client to send a HTTP/1.1 request with a 33 | ``Upgrade: h2c`` header that results in the connection being upgraded 34 | to HTTP/2. To test this try 35 | 36 | .. code-block:: shell 37 | 38 | $ curl --http2 http://url:port/path 39 | 40 | Note that in the absence of either the upgrade header or an ALPN 41 | protocol Hypercorn will assume and treat the connection as HTTP/1.1. 42 | 43 | HTTP/2 features 44 | --------------- 45 | 46 | Hypercorn supports pipeling, flow control, server push, and 47 | prioritisation. 48 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from collections.abc import Awaitable, Callable 5 | from typing import Literal 6 | 7 | from .run import worker_serve 8 | from ..config import Config 9 | from ..typing import Framework 10 | from ..utils import wrap_app 11 | 12 | 13 | async def serve( 14 | app: Framework, 15 | config: Config, 16 | *, 17 | shutdown_trigger: Callable[..., Awaitable] | None = None, 18 | mode: Literal["asgi", "wsgi"] | None = None, 19 | ) -> None: 20 | """Serve an ASGI or WSGI framework app given the config. 21 | 22 | This allows for a programmatic way to serve an ASGI or WSGI 23 | framework, it can be used via, 24 | 25 | .. code-block:: python 26 | 27 | asyncio.run(serve(app, config)) 28 | 29 | It is assumed that the event-loop is configured before calling 30 | this function, therefore configuration values that relate to loop 31 | setup or process setup are ignored. 32 | 33 | Arguments: 34 | app: The ASGI or WSGI application to serve. 35 | config: A Hypercorn configuration object. 36 | shutdown_trigger: This should return to trigger a graceful 37 | shutdown. 38 | mode: Specify if the app is WSGI or ASGI. 39 | """ 40 | if config.debug: 41 | warnings.warn("The config `debug` has no affect when using serve", Warning) 42 | if config.workers != 1: 43 | warnings.warn("The config `workers` has no affect when using serve", Warning) 44 | 45 | await worker_serve( 46 | wrap_app(app, config.wsgi_max_body_size, mode), config, shutdown_trigger=shutdown_trigger 47 | ) 48 | -------------------------------------------------------------------------------- /docs/how_to_guides/proxy_fix.rst: -------------------------------------------------------------------------------- 1 | Fixing proxy headers 2 | ==================== 3 | 4 | If you are serving Hypercorn behind a proxy e.g. a load balancer the 5 | client-address, scheme, and host-header will match that of the 6 | connection between the proxy and Hypercorn rather than the user-agent 7 | (client). However, most proxies provide headers with the original 8 | user-agent (client) values which can be used to "fix" the headers to 9 | these values. 10 | 11 | Modern proxies should provide this information via a ``Forwarded`` 12 | header from `RFC 7239 13 | `_. However, this is 14 | rare in practice with legacy proxies using a combination of 15 | ``X-Forwarded-For``, ``X-Forwarded-Proto`` and 16 | ``X-Forwarded-Host``. It is important that you chose the correct mode 17 | (legacy, or modern) based on the proxy you use. 18 | 19 | To use the proxy fix middleware behind a single legacy proxy simply 20 | wrap your app and serve the wrapped app, 21 | 22 | .. code-block:: python 23 | 24 | from hypercorn.middleware import ProxyFixMiddleware 25 | 26 | fixed_app = ProxyFixMiddleware(app, mode="legacy", trusted_hops=1) 27 | 28 | .. warning:: 29 | 30 | The mode and number of trusted hops must match your setup or the 31 | user-agent (client) may be trusted and hence able to set 32 | alternative for, proto, and host values. This can, depending on 33 | your usage in the app, lead to security vulnerabilities. 34 | 35 | The ``trusted_hops`` argument should be set to the number of proxies 36 | that are chained in front of Hypercorn. You should set this to how 37 | many proxies are setting the headers so the middleware knows what to 38 | trust. 39 | -------------------------------------------------------------------------------- /tests/asyncio/test_task_group.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | 6 | import pytest 7 | 8 | from hypercorn.app_wrappers import ASGIWrapper 9 | from hypercorn.asyncio.task_group import TaskGroup 10 | from hypercorn.config import Config 11 | from hypercorn.typing import HTTPScope, Scope 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_spawn_app(http_scope: HTTPScope) -> None: 16 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 17 | 18 | async def _echo_app(scope: Scope, receive: Callable, send: Callable) -> None: 19 | while True: 20 | message = await receive() 21 | if message is None: 22 | return 23 | await send(message) 24 | 25 | app_queue: asyncio.Queue = asyncio.Queue() 26 | async with TaskGroup(event_loop) as task_group: 27 | put = await task_group.spawn_app( 28 | ASGIWrapper(_echo_app), Config(), http_scope, app_queue.put 29 | ) 30 | await put({"type": "http.disconnect"}) 31 | assert (await app_queue.get()) == {"type": "http.disconnect"} 32 | await put(None) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_spawn_app_error(http_scope: HTTPScope) -> None: 37 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 38 | 39 | async def _error_app(scope: Scope, receive: Callable, send: Callable) -> None: 40 | raise Exception() 41 | 42 | app_queue: asyncio.Queue = asyncio.Queue() 43 | async with TaskGroup(event_loop) as task_group: 44 | await task_group.spawn_app(ASGIWrapper(_error_app), Config(), http_scope, app_queue.put) 45 | assert (await app_queue.get()) is None 46 | -------------------------------------------------------------------------------- /src/hypercorn/middleware/wsgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Callable, Iterable 5 | from functools import partial 6 | from typing import Any 7 | 8 | from ..app_wrappers import WSGIWrapper 9 | from ..typing import ASGIReceiveCallable, ASGISendCallable, Scope, WSGIFramework 10 | 11 | MAX_BODY_SIZE = 2**16 12 | 13 | WSGICallable = Callable[[dict, Callable], Iterable[bytes]] 14 | 15 | 16 | class InvalidPathError(Exception): 17 | pass 18 | 19 | 20 | class _WSGIMiddleware: 21 | def __init__(self, wsgi_app: WSGIFramework, max_body_size: int = MAX_BODY_SIZE) -> None: 22 | self.wsgi_app = WSGIWrapper(wsgi_app, max_body_size) 23 | self.max_body_size = max_body_size 24 | 25 | async def __call__( 26 | self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 27 | ) -> None: 28 | pass 29 | 30 | 31 | class AsyncioWSGIMiddleware(_WSGIMiddleware): 32 | async def __call__( 33 | self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 34 | ) -> None: 35 | loop = asyncio.get_event_loop() 36 | 37 | def _call_soon(func: Callable, *args: Any) -> Any: 38 | future = asyncio.run_coroutine_threadsafe(func(*args), loop) 39 | return future.result() 40 | 41 | await self.wsgi_app(scope, receive, send, partial(loop.run_in_executor, None), _call_soon) 42 | 43 | 44 | class TrioWSGIMiddleware(_WSGIMiddleware): 45 | async def __call__( 46 | self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 47 | ) -> None: 48 | import trio 49 | 50 | await self.wsgi_app(scope, receive, send, trio.to_thread.run_sync, trio.from_thread.run) 51 | -------------------------------------------------------------------------------- /tests/assets/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEljCCAn4CCQDieGkyts8ORjANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV 3 | SzAeFw0xODA3MDcxMjMwMzBaFw0xOTA3MDcxMjMwMzBaMA0xCzAJBgNVBAYTAlVL 4 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA8yUfZhLb97gXwvoASCei 5 | zgzk8mla/No1Kyo15/O9qxkyRtsIEhg1u/oQO0IEegG9/vRj2SpDwGanzE9HGW7n 6 | 5ekGy7UDYUWphcqE687BT+nSH2fSD4ZYY+nUlPO3ZE/n0eh/wniiImfZlGqOpwnS 7 | po1LwQHnuUXgOEKNCyAXX7GAYbSP7YC93Ytxs49luAJsJ+W+9Pepgz3hN7hMyKsq 8 | 3iqGXgGxhJPZXFLbvm+TKDeqw6ye1n5x8a7LX2xNGjZHgy0FWFK+Iu/s0SFp3o0a 9 | OPOgMGt90C1zD9Nwk1Ytn7coYgmoe0wAlzT9Nzt2B7iYLsjzBmbdA4Y9vCqqS7W6 10 | WZX4Vsnuh304fKnKDA2ATGZsh/o+aEhfNp386mrHx4IvmmxHex8Ga4WHyjF8MnMJ 11 | v50j692g0eiftKTddU1yDYdaQiYKiNYjQJcY+KW0sbZmjvXvmbJLMsfSSll6c0bl 12 | IvSj/E2W6J9LXKr73fZLFYQvF8CNwK2VidsoBNqN5OmhaaAOLe3r9YW2cfTvo84L 13 | i/lGrm2WrNAz9s+CuldAeidmBaqUi21kcWDegzKB3zA1ru2SMnxXpoZTS0fCQ2rk 14 | StlgdvEFeevEiQ2BYZ3meBaYedAxpZS92Eva5Y2glzXPvlzIXm09Keje11osmn/U 15 | saXTAPtpYkyF5pfsrnAWXWUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsOsQk813 16 | Y+H0Sh5T/vdBP6jG+w+svvP1xFlKTyMxzVb0cLrxtQTVgiLOvSTPSuCfgT2nGY13 17 | ZRHCsmH+ymlLtXWl5lWcBnQ1hP9FF6z5ygdJeQS5ZcLnDfqOl8eOXAiFH1xjNZMH 18 | BsDFqjA8XvW9SvFPlnwIGmvj+4Nsm44d6AJszvr6TsMfuumEJqWm8pCkf7M5jF8m 19 | piemGUsD0S+mihaRSQEC99AM4BFoh/W3TuMd2svaMq923yNcveCByF64wddAbrpm 20 | UMPi4GLWVU9LbPTRNgsOlciVUm1kpoB8Vdai+MMiqk6fREYUXyT4VDFAVEKMXSu1 21 | EL6TyTvhSgIja3M4tA579aQsjdBnTreeJRcF3bPlCIJ2dQ3SaR8c6061DCtDoNek 22 | B19LVHrI3C1otcWj+IXhw5Tp24wVUv8LYx3ZTpB7yGiHwGck12VKUKkq75eIR5bb 23 | b3RH89X1JXhQLGNJ+AJGxDzmqds+YZKWFmK7UNTX+MN3GufZrBKA4EHFgQn8mTUE 24 | GnunL6OR2Hj+zUGeA+qiLmul4Aiyh7DB3/sdbd5pDv2+zsLexsx7/DYpA3YazYrg 25 | dv31B8O3bcIOBNx31QCCGH9yLY2dwikImwC9nnKsRPC8TftPSUEToMG5O1JE/Q22 26 | q9gqqhDH1dzGIQZAJDremeU/VkxaI4q0PcQ= 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /compliance/h2spec/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEljCCAn4CCQCri/HA0BA3TDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV 3 | SzAeFw0xNzEyMjgxNjQyMTNaFw0xODEyMjgxNjQyMTNaMA0xCzAJBgNVBAYTAlVL 4 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuwrDLc6o0Ctfc0NYzIUq 5 | xE+D+4Gc5u+Is3/wIjGYdzMdEdu1x/cnFAbLYp9AGC5Api1w4ntHGcZENRI+aJ6D 6 | 8Ev4FlqdVtlbcyNO9MqQw4GUTsidVhoZzU5N9IEDs5ZPbO6blSHoxSxatllqgJs/ 7 | 8ws+E6ED7F6sp3bwrsEEYSvbddJ5L/7T8I88IqpZ0dlNeNl7q1tD2x4ea63RvyL8 8 | MKnnhnBa7G/UnjidDqf/mzVF2fOOqr6PFrQa2TOdKiZEXwnlZSYc/nZD7cC2KVZE 9 | +6gv7QbZA1vD8vXzWGjAhQODr3l7xuAug1kA0CZgnQXIS9UaedS5dNm5Go79d97Q 10 | +fE4u2ZdE1RTpbGTzSsPKMZFvFQrdi0UKFCp0+QxV8wyZ32arSjyQCaSBt6rKCpm 11 | zAfK5GstiKWM21gRwfsuo8oKSNE3VI7zfcSlxwRD9Ns3SwvIKo2Xr0K1K0aIOXgD 12 | P85cjAq3OJsWizZ1BhfN3f5pq0TQAojjj4q71y1t4mkPYShEygBY3wX6av2KtuSz 13 | Q4/ARC6pmnclL5tYFICOFuPCPz5m7/coIhhF21tgeDSLhNJ7PgVtp+n+1XWfCTLD 14 | GpW7nURNIy2fQqA2cBpYIbPlFx+mgJwlqsg55XFqVoUHpY6/HftuI4Zm/LRx8/fe 15 | mYNyIalJ3P+4iMFhd2DGp10CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAbHVdOrc1 16 | XZ8T6kvvbbSYtf6CQsIbTBmIcJ3Ply7LVMs8B5uBSe1pzM2hZGKbrpbs9ZmPRJiH 17 | vl2m3UeDW+N12xkjgY/S6BCj95JotxQVTt5OlbZLuQ4a3BEFgX6oZO7dN7JA0XLx 18 | bfUMCvGFR+Nkx6bTnCzcxPtbBiOL4ZymRaoM8NFd5jTybRlkCOFOg4HewmmZ/kif 19 | cN0qFZekcDsHcqBUz3HVGNc1MGMRGvRcFyMOCq9t5FF2Zoa0AoExFdjotHqc9tLm 20 | VdGey5me38T0PaPIqO870b43ZDdL6QFO5LvEo8ca78fVGnUksLxJ46n8C5ivdztY 21 | +kdH3Lbp8OmA3ZodGq5okSxVFn3eTa19epOuKz5NietCfpsfmXiIvbIrgZO0u114 22 | 5QiJZ5tJtbOPexi8Jzy3vDNK1wAO7RXQ5JrNqogz9emjBL1PAHqNMAfEJnb5/oro 23 | eqj45w/qQmoDqblK8Q8U+lSFpVq82e76m5YItvMe1VmLmPGUoq4JFSpjzmzMlW+w 24 | o3g+4RW9LsvolFMdQbpi1XV1LsPQuaJxczVOR74DJHbGeuE61tCxjx9lpaWAJJVg 25 | PbPHGc1WBSnMb/nCloYUN7UU0wqzOUx3yqvveP8w2Fb8pv3wEqHY/cGu1+pHSn5M 26 | x3Xnu9MXCkttgGltMoWgUuiz55iLqAzkOQY= 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /src/hypercorn/trio/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from collections.abc import Awaitable, Callable 5 | from typing import Literal 6 | 7 | import trio 8 | 9 | from .run import worker_serve 10 | from ..config import Config 11 | from ..typing import Framework 12 | from ..utils import wrap_app 13 | 14 | 15 | async def serve( 16 | app: Framework, 17 | config: Config, 18 | *, 19 | shutdown_trigger: Callable[..., Awaitable[None]] | None = None, 20 | task_status: trio.TaskStatus[list[str]] = trio.TASK_STATUS_IGNORED, 21 | mode: Literal["asgi", "wsgi"] | None = None, 22 | ) -> None: 23 | """Serve an ASGI framework app given the config. 24 | 25 | This allows for a programmatic way to serve an ASGI framework, it 26 | can be used via, 27 | 28 | .. code-block:: python 29 | 30 | trio.run(serve, app, config) 31 | 32 | It is assumed that the event-loop is configured before calling 33 | this function, therefore configuration values that relate to loop 34 | setup or process setup are ignored. 35 | 36 | Arguments: 37 | app: The ASGI application to serve. 38 | config: A Hypercorn configuration object. 39 | shutdown_trigger: This should return to trigger a graceful 40 | shutdown. 41 | mode: Specify if the app is WSGI or ASGI. 42 | """ 43 | if config.debug: 44 | warnings.warn("The config `debug` has no affect when using serve", Warning) 45 | if config.workers != 1: 46 | warnings.warn("The config `workers` has no affect when using serve", Warning) 47 | 48 | await worker_serve( 49 | wrap_app(app, config.wsgi_max_body_size, mode), 50 | config, 51 | shutdown_trigger=shutdown_trigger, 52 | task_status=task_status, 53 | ) 54 | -------------------------------------------------------------------------------- /tests/asyncio/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | from ..helpers import MockSocket 6 | 7 | 8 | class MockSSLObject: 9 | def selected_alpn_protocol(self) -> str: 10 | return "h2" 11 | 12 | 13 | class MemoryReader: 14 | def __init__(self) -> None: 15 | self.data: asyncio.Queue = asyncio.Queue() 16 | self.eof = False 17 | 18 | async def send(self, data: bytes) -> None: 19 | if data != b"": 20 | await self.data.put(data) 21 | 22 | async def read(self, length: int) -> bytes: 23 | return await self.data.get() 24 | 25 | def close(self) -> None: 26 | self.data.put_nowait(b"") 27 | self.eof = True 28 | 29 | def at_eof(self) -> bool: 30 | return self.eof and self.data.empty() 31 | 32 | 33 | class MemoryWriter: 34 | def __init__(self, http2: bool = False) -> None: 35 | self.is_closed = False 36 | self.data: asyncio.Queue = asyncio.Queue() 37 | self.http2 = http2 38 | 39 | def get_extra_info(self, name: str) -> MockSocket | MockSSLObject | None: 40 | if name == "socket": 41 | return MockSocket() 42 | elif self.http2 and name == "ssl_object": 43 | return MockSSLObject() 44 | else: 45 | return None 46 | 47 | def write_eof(self) -> None: 48 | self.data.put_nowait(b"") 49 | 50 | def write(self, data: bytes) -> None: 51 | if self.is_closed: 52 | raise ConnectionError() 53 | self.data.put_nowait(data) 54 | 55 | async def drain(self) -> None: 56 | pass 57 | 58 | def close(self) -> None: 59 | self.is_closed = True 60 | self.data.put_nowait(b"") 61 | 62 | async def wait_closed(self) -> None: 63 | pass 64 | 65 | async def receive(self) -> bytes: 66 | return await self.data.get() 67 | -------------------------------------------------------------------------------- /tests/asyncio/test_tcp_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | import pytest 6 | 7 | from hypercorn.app_wrappers import ASGIWrapper 8 | from hypercorn.asyncio.tcp_server import TCPServer 9 | from hypercorn.asyncio.worker_context import WorkerContext 10 | from hypercorn.config import Config 11 | from .helpers import MemoryReader, MemoryWriter 12 | from ..helpers import echo_framework 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_completes_on_closed() -> None: 17 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 18 | 19 | server = TCPServer( 20 | ASGIWrapper(echo_framework), 21 | event_loop, 22 | Config(), 23 | WorkerContext(None), 24 | {}, 25 | MemoryReader(), # type: ignore 26 | MemoryWriter(), # type: ignore 27 | ) 28 | server.reader.close() # type: ignore 29 | await server.run() 30 | # Key is that this line is reached, rather than the above line 31 | # hanging. 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_complets_on_half_close() -> None: 36 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 37 | 38 | server = TCPServer( 39 | ASGIWrapper(echo_framework), 40 | event_loop, 41 | Config(), 42 | WorkerContext(None), 43 | {}, 44 | MemoryReader(), # type: ignore 45 | MemoryWriter(), # type: ignore 46 | ) 47 | task = event_loop.create_task(server.run()) 48 | await server.reader.send(b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n") # type: ignore 49 | server.reader.close() # type: ignore 50 | await task 51 | data = await server.writer.receive() # type: ignore 52 | assert ( 53 | data 54 | == b"HTTP/1.1 200 \r\ncontent-length: 348\r\ndate: Thu, 01 Jan 1970 01:23:20 GMT\r\nserver: hypercorn-h11\r\n\r\n" # noqa: E501 55 | ) 56 | -------------------------------------------------------------------------------- /tests/trio/test_lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info < (3, 11): 6 | from exceptiongroup import ExceptionGroup 7 | 8 | import pytest 9 | import trio 10 | 11 | from hypercorn.app_wrappers import ASGIWrapper 12 | from hypercorn.config import Config 13 | from hypercorn.trio.lifespan import Lifespan 14 | from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope 15 | from hypercorn.utils import LifespanFailureError, LifespanTimeoutError 16 | from ..helpers import SlowLifespanFramework 17 | 18 | 19 | @pytest.mark.trio 20 | async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None: 21 | config = Config() 22 | config.startup_timeout = 0.01 23 | lifespan = Lifespan(ASGIWrapper(SlowLifespanFramework(0.02, trio.sleep)), config, {}) 24 | nursery.start_soon(lifespan.handle_lifespan) 25 | with pytest.raises(LifespanTimeoutError) as exc_info: 26 | await lifespan.wait_for_startup() 27 | assert str(exc_info.value).startswith("Timeout whilst awaiting startup") 28 | 29 | 30 | async def _lifespan_failure( 31 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 32 | ) -> None: 33 | async with trio.open_nursery(): 34 | while True: 35 | message = await receive() 36 | if message["type"] == "lifespan.startup": 37 | await send({"type": "lifespan.startup.failed", "message": "Failure"}) 38 | break 39 | 40 | 41 | @pytest.mark.trio 42 | async def test_startup_failure() -> None: 43 | lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), {}) 44 | try: 45 | async with trio.open_nursery() as lifespan_nursery: 46 | await lifespan_nursery.start(lifespan.handle_lifespan) 47 | await lifespan.wait_for_startup() 48 | except ExceptionGroup as error: 49 | assert error.subgroup(LifespanFailureError) is not None 50 | -------------------------------------------------------------------------------- /src/hypercorn/trio/udp_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socket 4 | 5 | import trio 6 | 7 | from .task_group import TaskGroup 8 | from .worker_context import WorkerContext 9 | from ..config import Config 10 | from ..events import Event, RawData 11 | from ..typing import AppWrapper, ConnectionState, LifespanState 12 | from ..utils import parse_socket_addr 13 | 14 | MAX_RECV = 2**16 15 | 16 | 17 | class UDPServer: 18 | def __init__( 19 | self, 20 | app: AppWrapper, 21 | config: Config, 22 | context: WorkerContext, 23 | state: LifespanState, 24 | socket: socket.socket, 25 | ) -> None: 26 | self.app = app 27 | self.config = config 28 | self.context = context 29 | self.socket = trio.socket.from_stdlib_socket(socket) 30 | self.state = state 31 | 32 | async def run(self, task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED) -> None: 33 | from ..protocol.quic import QuicProtocol # h3/Quic is an optional part of Hypercorn 34 | 35 | task_status.started() 36 | server = parse_socket_addr(self.socket.family, self.socket.getsockname()) 37 | async with TaskGroup() as task_group: 38 | self.protocol = QuicProtocol( 39 | self.app, 40 | self.config, 41 | self.context, 42 | task_group, 43 | ConnectionState(self.state.copy()), 44 | server, 45 | self.protocol_send, 46 | ) 47 | 48 | while not self.context.terminated.is_set() or not self.protocol.idle: 49 | data, address = await self.socket.recvfrom(MAX_RECV) 50 | await self.protocol.handle(RawData(data=data, address=address)) 51 | 52 | async def protocol_send(self, event: Event) -> None: 53 | if isinstance(event, RawData): 54 | await self.socket.sendto(event.data, event.address) 55 | -------------------------------------------------------------------------------- /docs/how_to_guides/http_https_redirect.rst: -------------------------------------------------------------------------------- 1 | .. _http_https_redirect: 2 | 3 | HTTP to HTTPS Redirects 4 | ======================= 5 | 6 | When serving over HTTPS it is often desired (and wise) to redirect any 7 | HTTP requests to HTTPS. To do this Hypercorn must listen to requests 8 | on secure and insecure binds. This is possible using the 9 | ``insecure-bind`` option which specifies binds that will be insecure 10 | regardless of the SSL settings. For example, 11 | 12 | .. code-block:: shell 13 | 14 | $ hypercorn --certfile cert.pem --keyfile key.pem --bind localhost:443 --insecure-bind localhost:80 module:app 15 | 16 | will serve on 443 over HTTPS and 80 over HTTP. 17 | 18 | .. warning:: 19 | 20 | Care must be taken when serving over secure and insecure binds to 21 | ensure that only redirects are served over HTTP. Hypercorn will 22 | not and cannot ensure this for you. 23 | 24 | 25 | Middleware 26 | ---------- 27 | 28 | With Hypercorn listening on both secure and insecure binds middleware 29 | such as the one in the hypercorn middleware module, 30 | :class:`~hypercorn.middleware.HTTPToHTTPSRedirectMiddleware`, can be 31 | used to ensure HTTP requests are redirected to HTTPS. Alternatively 32 | you can do this directly in your ASGI application. 33 | 34 | .. warning:: 35 | 36 | Ensure that any redirection middleware is the outermost wrapper of 37 | your app i.e. ensure that only the redirection middleware receives 38 | HTTP requests. 39 | 40 | To use the ``HTTPToHTTPSRedirectMiddleware`` wrap your app and specify 41 | the host the redirects should be aimed at. If you want to redirect 42 | users from ``http://example.com`` to ``https://example.com`` the host should 43 | be ``example.com`` as in the example below, 44 | 45 | .. code-block:: python 46 | 47 | redirected_app = HTTPToHTTPSRedirectMiddleware(app, host="example.com") 48 | 49 | You can then serve the redirect_app over a secure and an insecure bind 50 | as explained above, for example, 51 | 52 | .. code-block:: shell 53 | 54 | $ hypercorn --certfile cert.pem --keyfile key.pem --bind localhost:443 --insecure-bind localhost:80 module:redirected_app 55 | -------------------------------------------------------------------------------- /docs/discussion/closing.rst: -------------------------------------------------------------------------------- 1 | .. _closing: 2 | 3 | Connection closure 4 | ================== 5 | 6 | Connection closure is a difficult part of the connection lifecycle 7 | with choices needing to be made by Hypercorn about how to respond and 8 | what to send to the ASGI application. 9 | 10 | Before a connection is fully closed, it is often 'half-closed' by one 11 | side sending an EOF (empty bytestring b""). If sent by the client 12 | Hypercorn will not expect any further messages, but will allow 13 | messages to be sent to the client. This follows the HTTPWG guidance in 14 | `rfc.section.9.6.p.12 15 | `_. 16 | 17 | Client disconnection 18 | -------------------- 19 | 20 | If the client disconnects unexpectedly, i.e. whilst the server is 21 | still expecting to read or send data, the read/send socket action will 22 | raise an exception. This exception is caught and a Closed event is 23 | sent to the protocol. The protocol should then send each stream a 24 | StreamClosed event and delete the stream. 25 | 26 | Server disconnection 27 | -------------------- 28 | 29 | In the normal course of actions a stream should send a EndBody or 30 | EndData followed by a StreamClosed event to indicate that the stream 31 | has finished and the connection can be closed. However if the 32 | application errors the stream may only be able to send a StreamClosed 33 | event. Therefore the protocol only sends a StreamClosed event back to 34 | the stream on receipt of the StreamClosed from the stream. 35 | 36 | The protocol only sends a Closed event to the server if the connection 37 | must be closed, e.g. HTTP/1 without keep alive or an error. 38 | 39 | ASGI messages 40 | ------------- 41 | 42 | I've chosen to allow ASGI applications to continue to send messages to 43 | the server after the connection has closed and after the server has 44 | sent a disconnect message. Specifically Hypercorn will not error and 45 | instead no-op on receipt. This ensures that there isn't a race 46 | condition after the server has sent the disconnect message. 47 | 48 | Hypercorn guarantees to send the disconnect message, and send it only 49 | once, to each application instance. This message will be sent on 50 | closure of the connection (either on client or server closure). 51 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/worker_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | 6 | from ..typing import Event, SingleTask, TaskGroup 7 | 8 | 9 | class AsyncioSingleTask: 10 | def __init__(self) -> None: 11 | self._handle: asyncio.Task | None = None 12 | self._lock = asyncio.Lock() 13 | 14 | async def restart(self, task_group: TaskGroup, action: Callable) -> None: 15 | async with self._lock: 16 | if self._handle is not None: 17 | self._handle.cancel() 18 | try: 19 | await self._handle 20 | except asyncio.CancelledError: 21 | pass 22 | 23 | self._handle = task_group._task_group.create_task(action()) # type: ignore 24 | 25 | async def stop(self) -> None: 26 | async with self._lock: 27 | if self._handle is not None: 28 | self._handle.cancel() 29 | try: 30 | await self._handle 31 | except asyncio.CancelledError: 32 | pass 33 | 34 | self._handle = None 35 | 36 | 37 | class EventWrapper: 38 | def __init__(self) -> None: 39 | self._event = asyncio.Event() 40 | 41 | async def clear(self) -> None: 42 | self._event.clear() 43 | 44 | async def wait(self) -> None: 45 | await self._event.wait() 46 | 47 | async def set(self) -> None: 48 | self._event.set() 49 | 50 | def is_set(self) -> bool: 51 | return self._event.is_set() 52 | 53 | 54 | class WorkerContext: 55 | event_class: type[Event] = EventWrapper 56 | single_task_class: type[SingleTask] = AsyncioSingleTask 57 | 58 | def __init__(self, max_requests: int | None) -> None: 59 | self.max_requests = max_requests 60 | self.requests = 0 61 | self.terminate = self.event_class() 62 | self.terminated = self.event_class() 63 | 64 | async def mark_request(self) -> None: 65 | if self.max_requests is None: 66 | return 67 | 68 | self.requests += 1 69 | if self.requests > self.max_requests: 70 | await self.terminate.set() 71 | 72 | @staticmethod 73 | async def sleep(wait: float | int) -> None: 74 | return await asyncio.sleep(wait) 75 | 76 | @staticmethod 77 | def time() -> float: 78 | return asyncio.get_event_loop().time() 79 | -------------------------------------------------------------------------------- /tests/middleware/test_proxy_fix.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | 7 | from hypercorn.middleware import ProxyFixMiddleware 8 | from hypercorn.typing import ConnectionState, HTTPScope 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_proxy_fix_legacy() -> None: 13 | mock = AsyncMock() 14 | app = ProxyFixMiddleware(mock) 15 | scope: HTTPScope = { 16 | "type": "http", 17 | "asgi": {}, 18 | "http_version": "2", 19 | "method": "GET", 20 | "scheme": "http", 21 | "path": "/", 22 | "raw_path": b"/", 23 | "query_string": b"", 24 | "root_path": "", 25 | "headers": [ 26 | (b"x-forwarded-for", b"127.0.0.1"), 27 | (b"x-forwarded-for", b"127.0.0.2"), 28 | (b"x-forwarded-proto", b"http,https"), 29 | (b"x-forwarded-host", b"example.com"), 30 | ], 31 | "client": ("127.0.0.3", 80), 32 | "server": None, 33 | "extensions": {}, 34 | "state": ConnectionState({}), 35 | } 36 | await app(scope, None, None) 37 | mock.assert_called() 38 | scope = mock.call_args[0][0] 39 | assert scope["client"] == ("127.0.0.2", 0) 40 | assert scope["scheme"] == "https" 41 | host_headers = [h for h in scope["headers"] if h[0].lower() == b"host"] 42 | assert host_headers == [(b"host", b"example.com")] 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_proxy_fix_modern() -> None: 47 | mock = AsyncMock() 48 | app = ProxyFixMiddleware(mock, mode="modern") 49 | scope: HTTPScope = { 50 | "type": "http", 51 | "asgi": {}, 52 | "http_version": "2", 53 | "method": "GET", 54 | "scheme": "http", 55 | "path": "/", 56 | "raw_path": b"/", 57 | "query_string": b"", 58 | "root_path": "", 59 | "headers": [ 60 | (b"forwarded", b"for=127.0.0.1;proto=http,for=127.0.0.2;proto=https;host=example.com"), 61 | ], 62 | "client": ("127.0.0.3", 80), 63 | "server": None, 64 | "extensions": {}, 65 | "state": ConnectionState({}), 66 | } 67 | await app(scope, None, None) 68 | mock.assert_called() 69 | scope = mock.call_args[0][0] 70 | assert scope["client"] == ("127.0.0.2", 0) 71 | assert scope["scheme"] == "https" 72 | host_headers = [h for h in scope["headers"] if h[0].lower() == b"host"] 73 | assert host_headers == [(b"host", b"example.com")] 74 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Iterable 4 | from typing import Any 5 | 6 | import pytest 7 | 8 | from hypercorn.typing import Scope 9 | from hypercorn.utils import ( 10 | build_and_validate_headers, 11 | filter_pseudo_headers, 12 | is_asgi, 13 | suppress_body, 14 | ) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "method, status, expected", [("HEAD", 200, True), ("GET", 200, False), ("GET", 101, True)] 19 | ) 20 | def test_suppress_body(method: str, status: int, expected: bool) -> None: 21 | assert suppress_body(method, status) is expected 22 | 23 | 24 | class ASGIClassInstance: 25 | def __init__(self) -> None: 26 | pass 27 | 28 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 29 | pass 30 | 31 | 32 | async def asgi_callable(scope: Scope, receive: Callable, send: Callable) -> None: 33 | pass 34 | 35 | 36 | class WSGIClassInstance: 37 | def __init__(self) -> None: 38 | pass 39 | 40 | def __call__(self, environ: dict, start_response: Callable) -> Iterable[bytes]: 41 | pass 42 | 43 | 44 | def wsgi_callable(environ: dict, start_response: Callable) -> Iterable[bytes]: 45 | pass 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "app, expected", 50 | [ 51 | (WSGIClassInstance(), False), 52 | (ASGIClassInstance(), True), 53 | (wsgi_callable, False), 54 | (asgi_callable, True), 55 | ], 56 | ) 57 | def test_is_asgi(app: Any, expected: bool) -> None: 58 | assert is_asgi(app) == expected 59 | 60 | 61 | def test_build_and_validate_headers_validate() -> None: 62 | with pytest.raises(TypeError): 63 | build_and_validate_headers([("string", "string")]) # type: ignore 64 | 65 | 66 | def test_build_and_validate_headers_pseudo() -> None: 67 | with pytest.raises(ValueError): 68 | build_and_validate_headers([(b":authority", b"quart")]) 69 | 70 | 71 | def test_filter_pseudo_headers() -> None: 72 | result = filter_pseudo_headers( 73 | [(b":authority", b"quart"), (b":path", b"/"), (b"user-agent", b"something")] 74 | ) 75 | assert result == [(b"host", b"quart"), (b"user-agent", b"something")] 76 | 77 | 78 | def test_filter_pseudo_headers_no_authority() -> None: 79 | result = filter_pseudo_headers( 80 | [(b"host", b"quart"), (b":path", b"/"), (b"user-agent", b"something")] 81 | ) 82 | assert result == [(b"host", b"quart"), (b"user-agent", b"something")] 83 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/task_group.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Awaitable, Callable 5 | from functools import partial 6 | from types import TracebackType 7 | from typing import Any 8 | 9 | from ..config import Config 10 | from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope 11 | 12 | try: 13 | from asyncio import TaskGroup as AsyncioTaskGroup 14 | except ImportError: 15 | from taskgroup import TaskGroup as AsyncioTaskGroup # type: ignore 16 | 17 | 18 | async def _handle( 19 | app: AppWrapper, 20 | config: Config, 21 | scope: Scope, 22 | receive: ASGIReceiveCallable, 23 | send: Callable[[ASGISendEvent | None], Awaitable[None]], 24 | sync_spawn: Callable, 25 | call_soon: Callable, 26 | ) -> None: 27 | try: 28 | await app(scope, receive, send, sync_spawn, call_soon) 29 | except asyncio.CancelledError: 30 | raise 31 | except Exception: 32 | await config.log.exception("Error in ASGI Framework") 33 | finally: 34 | await send(None) 35 | 36 | 37 | class TaskGroup: 38 | def __init__(self, loop: asyncio.AbstractEventLoop) -> None: 39 | self._loop = loop 40 | self._task_group = AsyncioTaskGroup() 41 | 42 | async def spawn_app( 43 | self, 44 | app: AppWrapper, 45 | config: Config, 46 | scope: Scope, 47 | send: Callable[[ASGISendEvent | None], Awaitable[None]], 48 | ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: 49 | app_queue: asyncio.Queue[ASGIReceiveEvent] = asyncio.Queue(config.max_app_queue_size) 50 | 51 | def _call_soon(func: Callable, *args: Any) -> Any: 52 | future = asyncio.run_coroutine_threadsafe(func(*args), self._loop) 53 | return future.result() 54 | 55 | self.spawn( 56 | _handle, 57 | app, 58 | config, 59 | scope, 60 | app_queue.get, 61 | send, 62 | partial(self._loop.run_in_executor, None), 63 | _call_soon, 64 | ) 65 | return app_queue.put 66 | 67 | def spawn(self, func: Callable, *args: Any) -> None: 68 | self._task_group.create_task(func(*args)) 69 | 70 | async def __aenter__(self) -> TaskGroup: 71 | await self._task_group.__aenter__() 72 | return self 73 | 74 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 75 | await self._task_group.__aexit__(exc_type, exc_value, tb) 76 | -------------------------------------------------------------------------------- /src/hypercorn/trio/worker_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable 4 | from functools import wraps 5 | 6 | import trio 7 | 8 | from ..typing import Event, SingleTask, TaskGroup 9 | 10 | 11 | def _cancel_wrapper(func: Callable[[], Awaitable[None]]) -> Callable[[], Awaitable[None]]: 12 | @wraps(func) 13 | async def wrapper( 14 | task_status: trio.TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED, 15 | ) -> None: 16 | cancel_scope = trio.CancelScope() 17 | task_status.started(cancel_scope) 18 | with cancel_scope: 19 | await func() 20 | 21 | return wrapper 22 | 23 | 24 | class TrioSingleTask: 25 | def __init__(self) -> None: 26 | self._handle: trio.CancelScope | None = None 27 | self._lock = trio.Lock() 28 | 29 | async def restart(self, task_group: TaskGroup, action: Callable) -> None: 30 | async with self._lock: 31 | if self._handle is not None: 32 | self._handle.cancel() 33 | self._handle = await task_group._nursery.start(_cancel_wrapper(action)) # type: ignore 34 | 35 | async def stop(self) -> None: 36 | async with self._lock: 37 | if self._handle is not None: 38 | self._handle.cancel() 39 | self._handle = None 40 | 41 | 42 | class EventWrapper: 43 | def __init__(self) -> None: 44 | self._event = trio.Event() 45 | 46 | async def clear(self) -> None: 47 | self._event = trio.Event() 48 | 49 | async def wait(self) -> None: 50 | await self._event.wait() 51 | 52 | async def set(self) -> None: 53 | self._event.set() 54 | 55 | def is_set(self) -> bool: 56 | return self._event.is_set() 57 | 58 | 59 | class WorkerContext: 60 | event_class: type[Event] = EventWrapper 61 | single_task_class: type[SingleTask] = TrioSingleTask 62 | 63 | def __init__(self, max_requests: int | None) -> None: 64 | self.max_requests = max_requests 65 | self.requests = 0 66 | self.terminate = self.event_class() 67 | self.terminated = self.event_class() 68 | 69 | async def mark_request(self) -> None: 70 | if self.max_requests is None: 71 | return 72 | 73 | self.requests += 1 74 | if self.requests > self.max_requests: 75 | await self.terminate.set() 76 | 77 | @staticmethod 78 | async def sleep(wait: float | int) -> None: 79 | return await trio.sleep(wait) 80 | 81 | @staticmethod 82 | def time() -> float: 83 | return trio.current_time() 84 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/udp_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING 5 | 6 | from .task_group import TaskGroup 7 | from .worker_context import WorkerContext 8 | from ..config import Config 9 | from ..events import Event, RawData 10 | from ..typing import AppWrapper, ConnectionState, LifespanState 11 | from ..utils import parse_socket_addr 12 | 13 | if TYPE_CHECKING: 14 | # h3/Quic is an optional part of Hypercorn 15 | from ..protocol.quic import QuicProtocol # noqa: F401 16 | 17 | 18 | class UDPServer(asyncio.DatagramProtocol): 19 | def __init__( 20 | self, 21 | app: AppWrapper, 22 | loop: asyncio.AbstractEventLoop, 23 | config: Config, 24 | context: WorkerContext, 25 | state: LifespanState, 26 | ) -> None: 27 | self.app = app 28 | self.config = config 29 | self.context = context 30 | self.loop = loop 31 | self.protocol: QuicProtocol 32 | self.protocol_queue: asyncio.Queue = asyncio.Queue(10) 33 | self.transport: asyncio.DatagramTransport | None = None 34 | self.state = state 35 | 36 | def connection_made(self, transport: asyncio.DatagramTransport) -> None: # type: ignore 37 | self.transport = transport 38 | 39 | def datagram_received(self, data: bytes, address: tuple[bytes, str]) -> None: # type: ignore 40 | try: 41 | self.protocol_queue.put_nowait(RawData(data=data, address=address)) # type: ignore 42 | except asyncio.QueueFull: 43 | pass # Just throw the data away, is UDP 44 | 45 | async def run(self) -> None: 46 | # h3/Quic is an optional part of Hypercorn 47 | from ..protocol.quic import QuicProtocol # noqa: F811 48 | 49 | socket = self.transport.get_extra_info("socket") 50 | server = parse_socket_addr(socket.family, socket.getsockname()) 51 | async with TaskGroup(self.loop) as task_group: 52 | self.protocol = QuicProtocol( 53 | self.app, 54 | self.config, 55 | self.context, 56 | task_group, 57 | ConnectionState(self.state.copy()), 58 | server, 59 | self.protocol_send, 60 | ) 61 | 62 | while not self.context.terminated.is_set() or not self.protocol.idle: 63 | event = await self.protocol_queue.get() 64 | await self.protocol.handle(event) 65 | 66 | async def protocol_send(self, event: Event) -> None: 67 | if isinstance(event, RawData): 68 | self.transport.sendto(event.data, event.address) 69 | -------------------------------------------------------------------------------- /src/hypercorn/trio/task_group.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import Awaitable, Callable 5 | from contextlib import AbstractAsyncContextManager 6 | from types import TracebackType 7 | from typing import Any 8 | 9 | import trio 10 | 11 | from ..config import Config 12 | from ..typing import AppWrapper, ASGIReceiveCallable, ASGIReceiveEvent, ASGISendEvent, Scope 13 | 14 | if sys.version_info < (3, 11): 15 | from exceptiongroup import BaseExceptionGroup 16 | 17 | 18 | async def _handle( 19 | app: AppWrapper, 20 | config: Config, 21 | scope: Scope, 22 | receive: ASGIReceiveCallable, 23 | send: Callable[[ASGISendEvent | None], Awaitable[None]], 24 | sync_spawn: Callable, 25 | call_soon: Callable, 26 | ) -> None: 27 | try: 28 | await app(scope, receive, send, sync_spawn, call_soon) 29 | except trio.Cancelled: 30 | raise 31 | except BaseExceptionGroup as error: 32 | _, other_errors = error.split(trio.Cancelled) 33 | if other_errors is not None: 34 | await config.log.exception("Error in ASGI Framework") 35 | await send(None) 36 | else: 37 | raise 38 | except Exception: 39 | await config.log.exception("Error in ASGI Framework") 40 | finally: 41 | await send(None) 42 | 43 | 44 | class TaskGroup: 45 | def __init__(self) -> None: 46 | self._nursery: trio.Nursery | None = None 47 | self._nursery_manager: AbstractAsyncContextManager[trio.Nursery] | None = None 48 | 49 | async def spawn_app( 50 | self, 51 | app: AppWrapper, 52 | config: Config, 53 | scope: Scope, 54 | send: Callable[[ASGISendEvent | None], Awaitable[None]], 55 | ) -> Callable[[ASGIReceiveEvent], Awaitable[None]]: 56 | app_send_channel, app_receive_channel = trio.open_memory_channel[ASGIReceiveEvent]( 57 | config.max_app_queue_size 58 | ) 59 | self._nursery.start_soon( 60 | _handle, 61 | app, 62 | config, 63 | scope, 64 | app_receive_channel.receive, 65 | send, 66 | trio.to_thread.run_sync, 67 | trio.from_thread.run, 68 | ) 69 | return app_send_channel.send 70 | 71 | def spawn(self, func: Callable, *args: Any) -> None: 72 | self._nursery.start_soon(func, *args) 73 | 74 | async def __aenter__(self) -> TaskGroup: 75 | self._nursery_manager = trio.open_nursery() 76 | self._nursery = await self._nursery_manager.__aenter__() 77 | return self 78 | 79 | async def __aexit__(self, exc_type: type, exc_value: BaseException, tb: TracebackType) -> None: 80 | await self._nursery_manager.__aexit__(exc_type, exc_value, tb) 81 | self._nursery_manager = None 82 | self._nursery = None 83 | -------------------------------------------------------------------------------- /src/hypercorn/middleware/http_to_https.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from urllib.parse import urlunsplit 5 | 6 | from ..typing import ASGIFramework, HTTPScope, Scope, WebsocketScope, WWWScope 7 | 8 | 9 | class HTTPToHTTPSRedirectMiddleware: 10 | def __init__(self, app: ASGIFramework, host: str | None) -> None: 11 | self.app = app 12 | self.host = host 13 | 14 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 15 | if scope["type"] == "http" and scope["scheme"] == "http": 16 | await self._send_http_redirect(scope, send) 17 | elif scope["type"] == "websocket" and scope["scheme"] == "ws": 18 | # If the server supports the WebSocket Denial Response 19 | # extension we can send a redirection response, if not we 20 | # can only deny the WebSocket connection. 21 | if "websocket.http.response" in scope.get("extensions", {}): 22 | await self._send_websocket_redirect(scope, send) 23 | else: 24 | await send({"type": "websocket.close"}) 25 | else: 26 | return await self.app(scope, receive, send) 27 | 28 | async def _send_http_redirect(self, scope: HTTPScope, send: Callable) -> None: 29 | new_url = self._new_url("https", scope) 30 | await send( 31 | { 32 | "type": "http.response.start", 33 | "status": 307, 34 | "headers": [(b"location", new_url.encode())], 35 | } 36 | ) 37 | await send({"type": "http.response.body"}) 38 | 39 | async def _send_websocket_redirect(self, scope: WebsocketScope, send: Callable) -> None: 40 | # If the HTTP version is 2 we should redirect with a https 41 | # scheme not wss. 42 | scheme = "wss" 43 | if scope.get("http_version", "1.1") == "2": 44 | scheme = "https" 45 | 46 | new_url = self._new_url(scheme, scope) 47 | await send( 48 | { 49 | "type": "websocket.http.response.start", 50 | "status": 307, 51 | "headers": [(b"location", new_url.encode())], 52 | } 53 | ) 54 | await send({"type": "websocket.http.response.body"}) 55 | 56 | def _new_url(self, scheme: str, scope: WWWScope) -> str: 57 | host = self.host 58 | if host is None: 59 | for key, value in scope["headers"]: 60 | if key == b"host": 61 | host = value.decode("latin-1") 62 | break 63 | if host is None: 64 | raise ValueError("Host to redirect to cannot be determined") 65 | 66 | path = scope.get("root_path", "") + scope["raw_path"].decode() 67 | return urlunsplit((scheme, host, path, scope["query_string"].decode(), "")) 68 | -------------------------------------------------------------------------------- /docs/how_to_guides/logging.rst: -------------------------------------------------------------------------------- 1 | .. _how_to_log: 2 | 3 | Logging 4 | ======= 5 | 6 | Hypercorn has two loggers, an access logger and an error logger. By 7 | default neither will actively log. The special value of ``-`` can be 8 | used as the logging target in order to log to stdout and stderr 9 | respectively. Any other value is considered a filepath to target. 10 | 11 | Configuring the Python logger 12 | ----------------------------- 13 | 14 | The Python logger can be configured using the ``logconfig`` or 15 | ``logconfig_dict`` configuration attributes. The latter, 16 | ``logconfig_dict`` will be passed to ``dictConfig`` after the loggers 17 | have been created. 18 | 19 | The ``logconfig`` variable should point at a file to be used by the 20 | ``fileConfig`` function. Alternatively it can point to a JSON or TOML 21 | formatted file which will be loaded and passed to the ``dictConfig`` 22 | function. To use a JSON formatted file prefix the filepath with 23 | ``json:`` and for TOML use ``toml:``. 24 | 25 | Configuring access logs 26 | ----------------------- 27 | 28 | The access log format can be configured by specifying the atoms (see 29 | below) to include in a specific format. By default hypercorn will 30 | choose ``%(h)s %(l)s %(l)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"`` 31 | as the format. The configuration variable ``access_log_format`` 32 | specifies the format used. 33 | 34 | 35 | Access log atoms 36 | ```````````````` 37 | 38 | The following atoms, a superset of those in `Gunicorn 39 | `_, are available for use. 40 | 41 | =========== =========== 42 | Identifier Description 43 | =========== =========== 44 | h remote address 45 | l ``'-'`` 46 | u user name 47 | t date of the request 48 | r status line without query string (e.g. ``GET / h11``) 49 | R status line with query string (e.g. ``GET /?a=b h11``) 50 | m request method 51 | U URL path without query string 52 | Uq URL path with query string 53 | q query string 54 | H protocol 55 | s status 56 | st status phrase (e.g. ``OK``, ``Forbidden``, ``Not Found``) 57 | S scheme {http, https, ws, wss} 58 | B response length 59 | b response length or ``'-'`` (CLF format) 60 | f referer 61 | a user agent 62 | T request time in seconds 63 | D request time in microseconds 64 | L request time in decimal seconds 65 | p process ID 66 | {Header}i request header 67 | {Header}o response header 68 | {Variable}e environment variable 69 | =========== =========== 70 | 71 | Customising the logger 72 | ---------------------- 73 | 74 | The logger class can be customised by changing the ``logger_class`` 75 | attribute of the ``Config`` class. This is only possible when using 76 | the python based configuration file. The 77 | ``hypercorn.logging.Logger`` class is used by default. 78 | -------------------------------------------------------------------------------- /src/hypercorn/middleware/proxy_fix.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Iterable 4 | from copy import deepcopy 5 | from typing import Literal 6 | 7 | from ..typing import ASGIFramework, Scope 8 | 9 | 10 | class ProxyFixMiddleware: 11 | def __init__( 12 | self, 13 | app: ASGIFramework, 14 | mode: Literal["legacy", "modern"] = "legacy", 15 | trusted_hops: int = 1, 16 | ) -> None: 17 | self.app = app 18 | self.mode = mode 19 | self.trusted_hops = trusted_hops 20 | 21 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 22 | # Keep the `or` instead of `in {'http' …}` to allow type narrowing 23 | if scope["type"] == "http" or scope["type"] == "websocket": 24 | scope = deepcopy(scope) 25 | headers = scope["headers"] 26 | client: str | None = None 27 | scheme: str | None = None 28 | host: str | None = None 29 | 30 | if ( 31 | self.mode == "modern" 32 | and (value := _get_trusted_value(b"forwarded", headers, self.trusted_hops)) 33 | is not None 34 | ): 35 | for part in value.split(";"): 36 | if part.startswith("for="): 37 | client = part[4:].strip() 38 | elif part.startswith("host="): 39 | host = part[5:].strip() 40 | elif part.startswith("proto="): 41 | scheme = part[6:].strip() 42 | 43 | else: 44 | client = _get_trusted_value(b"x-forwarded-for", headers, self.trusted_hops) 45 | scheme = _get_trusted_value(b"x-forwarded-proto", headers, self.trusted_hops) 46 | host = _get_trusted_value(b"x-forwarded-host", headers, self.trusted_hops) 47 | 48 | if client is not None: 49 | scope["client"] = (client, 0) 50 | 51 | if scheme is not None: 52 | scope["scheme"] = scheme 53 | 54 | if host is not None: 55 | headers = [ 56 | (name, header_value) 57 | for name, header_value in headers 58 | if name.lower() != b"host" 59 | ] 60 | headers.append((b"host", host.encode())) 61 | scope["headers"] = headers 62 | 63 | await self.app(scope, receive, send) 64 | 65 | 66 | def _get_trusted_value( 67 | name: bytes, headers: Iterable[tuple[bytes, bytes]], trusted_hops: int 68 | ) -> str | None: 69 | if trusted_hops == 0: 70 | return None 71 | 72 | values = [] 73 | for header_name, header_value in headers: 74 | if header_name.lower() == name: 75 | values.extend([value.decode("latin1").strip() for value in header_value.split(b",")]) 76 | 77 | if len(values) >= trusted_hops: 78 | return values[-trusted_hops] 79 | 80 | return None 81 | -------------------------------------------------------------------------------- /tests/test___main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | from _pytest.monkeypatch import MonkeyPatch 9 | 10 | import hypercorn.__main__ 11 | from hypercorn.config import Config 12 | 13 | 14 | def test_load_config_none() -> None: 15 | assert isinstance(hypercorn.__main__._load_config(None), Config) 16 | 17 | 18 | def test_load_config_pyfile(monkeypatch: MonkeyPatch) -> None: 19 | mock_config = Mock() 20 | monkeypatch.setattr(hypercorn.__main__, "Config", mock_config) 21 | hypercorn.__main__._load_config("file:assets/config.py") 22 | mock_config.from_pyfile.assert_called() 23 | 24 | 25 | def test_load_config_pymodule(monkeypatch: MonkeyPatch) -> None: 26 | mock_config = Mock() 27 | monkeypatch.setattr(hypercorn.__main__, "Config", mock_config) 28 | hypercorn.__main__._load_config("python:assets.config") 29 | mock_config.from_object.assert_called() 30 | 31 | 32 | def test_load_config(monkeypatch: MonkeyPatch) -> None: 33 | mock_config = Mock() 34 | monkeypatch.setattr(hypercorn.__main__, "Config", mock_config) 35 | hypercorn.__main__._load_config("assets/config") 36 | mock_config.from_toml.assert_called() 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "flag, set_value, config_key", 41 | [ 42 | ("--access-logformat", "jeff", "access_log_format"), 43 | ("--backlog", 5, "backlog"), 44 | ("--ca-certs", "/path", "ca_certs"), 45 | ("--certfile", "/path", "certfile"), 46 | ("--ciphers", "DHE-RSA-AES128-SHA", "ciphers"), 47 | ("--worker-class", "trio", "worker_class"), 48 | ("--keep-alive", 20, "keep_alive_timeout"), 49 | ("--keyfile", "/path", "keyfile"), 50 | ("--pid", "/path", "pid_path"), 51 | ("--root-path", "/path", "root_path"), 52 | ("--workers", 2, "workers"), 53 | ], 54 | ) 55 | def test_main_cli_override( 56 | flag: str, set_value: str, config_key: str, monkeypatch: MonkeyPatch 57 | ) -> None: 58 | run_multiple = Mock() 59 | monkeypatch.setattr(hypercorn.__main__, "run", run_multiple) 60 | path = os.path.join(os.path.dirname(__file__), "assets/config_ssl.py") 61 | raw_config = Config.from_pyfile(path) 62 | 63 | hypercorn.__main__.main(["--config", f"file:{path}", flag, str(set_value), "asgi:App"]) 64 | run_multiple.assert_called() 65 | config = run_multiple.call_args_list[0][0][0] 66 | 67 | for name, value in inspect.getmembers(raw_config): 68 | if ( 69 | not inspect.ismethod(value) 70 | and not name.startswith("_") 71 | and name not in {"log", config_key} 72 | ): 73 | assert getattr(raw_config, name) == getattr(config, name) 74 | assert getattr(config, config_key) == set_value 75 | 76 | 77 | def test_verify_mode_conversion(monkeypatch: MonkeyPatch) -> None: 78 | run_multiple = Mock() 79 | monkeypatch.setattr(hypercorn.__main__, "run", run_multiple) 80 | 81 | with pytest.raises(SystemExit): 82 | hypercorn.__main__.main(["--verify-mode", "CERT_UNKNOWN", "asgi:App"]) 83 | 84 | hypercorn.__main__.main(["--verify-mode", "CERT_REQUIRED", "asgi:App"]) 85 | run_multiple.assert_called() 86 | -------------------------------------------------------------------------------- /src/hypercorn/protocol/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable 4 | 5 | from .h2 import H2Protocol 6 | from .h11 import H2CProtocolRequiredError, H2ProtocolAssumedError, H11Protocol 7 | from ..config import Config 8 | from ..events import Event, RawData 9 | from ..typing import AppWrapper, ConnectionState, TaskGroup, WorkerContext 10 | 11 | 12 | class ProtocolWrapper: 13 | def __init__( 14 | self, 15 | app: AppWrapper, 16 | config: Config, 17 | context: WorkerContext, 18 | task_group: TaskGroup, 19 | state: ConnectionState, 20 | ssl: bool, 21 | client: tuple[str, int] | None, 22 | server: tuple[str, int] | None, 23 | send: Callable[[Event], Awaitable[None]], 24 | alpn_protocol: str | None = None, 25 | ) -> None: 26 | self.app = app 27 | self.config = config 28 | self.context = context 29 | self.task_group = task_group 30 | self.ssl = ssl 31 | self.client = client 32 | self.server = server 33 | self.send = send 34 | self.state = state 35 | self.protocol: H11Protocol | H2Protocol 36 | if alpn_protocol == "h2": 37 | self.protocol = H2Protocol( 38 | self.app, 39 | self.config, 40 | self.context, 41 | self.task_group, 42 | self.state, 43 | self.ssl, 44 | self.client, 45 | self.server, 46 | self.send, 47 | ) 48 | else: 49 | self.protocol = H11Protocol( 50 | self.app, 51 | self.config, 52 | self.context, 53 | self.task_group, 54 | self.state, 55 | self.ssl, 56 | self.client, 57 | self.server, 58 | self.send, 59 | ) 60 | 61 | async def initiate(self) -> None: 62 | return await self.protocol.initiate() 63 | 64 | async def handle(self, event: Event) -> None: 65 | try: 66 | return await self.protocol.handle(event) 67 | except H2ProtocolAssumedError as error: 68 | self.protocol = H2Protocol( 69 | self.app, 70 | self.config, 71 | self.context, 72 | self.task_group, 73 | self.state, 74 | self.ssl, 75 | self.client, 76 | self.server, 77 | self.send, 78 | ) 79 | await self.protocol.initiate() 80 | if error.data != b"": 81 | return await self.protocol.handle(RawData(data=error.data)) 82 | except H2CProtocolRequiredError as error: 83 | self.protocol = H2Protocol( 84 | self.app, 85 | self.config, 86 | self.context, 87 | self.task_group, 88 | self.state, 89 | self.ssl, 90 | self.client, 91 | self.server, 92 | self.send, 93 | ) 94 | await self.protocol.initiate(error.headers, error.settings) 95 | if error.data != b"": 96 | return await self.protocol.handle(RawData(data=error.data)) 97 | -------------------------------------------------------------------------------- /tests/e2e/test_httpx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import httpx # type: ignore 4 | import pytest 5 | import trio 6 | 7 | import hypercorn.trio 8 | from hypercorn.config import Config 9 | from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope 10 | 11 | 12 | async def app(scope, receive, send) -> None: # type: ignore 13 | assert scope["type"] == "http" 14 | 15 | await send( 16 | { 17 | "type": "http.response.start", 18 | "status": 200, 19 | "headers": [ 20 | [b"content-type", b"text/plain"], 21 | ], 22 | } 23 | ) 24 | await send( 25 | { 26 | "type": "http.response.body", 27 | "body": b"Hello, world!", 28 | } 29 | ) 30 | 31 | 32 | @pytest.mark.trio 33 | async def test_keep_alive_max_requests_regression() -> None: 34 | config = Config() 35 | config.bind = ["0.0.0.0:1234"] 36 | config.accesslog = "-" # Log to stdout/err 37 | config.errorlog = "-" 38 | config.keep_alive_max_requests = 2 39 | 40 | async with trio.open_nursery() as nursery: 41 | shutdown = trio.Event() 42 | 43 | async def serve() -> None: 44 | await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) 45 | 46 | nursery.start_soon(serve) 47 | 48 | await trio.testing.wait_all_tasks_blocked() 49 | 50 | client = httpx.AsyncClient() 51 | 52 | # Make sure that we properly clean up connections when `keep_alive_max_requests` 53 | # is hit such that the client stays good over multiple hangups. 54 | for _ in range(10): 55 | result = await client.post("http://0.0.0.0:1234/test", json={"key": "value"}) 56 | result.raise_for_status() 57 | 58 | shutdown.set() 59 | 60 | 61 | @pytest.mark.trio 62 | async def test_handle_isolate_state() -> None: 63 | config = Config() 64 | config.bind = ["0.0.0.0:1234"] 65 | config.accesslog = "-" # Log to stdout/err 66 | config.errorlog = "-" 67 | 68 | async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: 69 | assert scope["type"] == "http" 70 | 71 | await send( 72 | { 73 | "type": "http.response.start", 74 | "status": 200, 75 | "headers": [(b"content-type", b"text/plain")], 76 | } 77 | ) 78 | await send( 79 | { 80 | "type": "http.response.body", 81 | "body": scope["state"].get("key", b""), 82 | "more_body": False, 83 | } 84 | ) 85 | scope["state"]["key"] = b"one" 86 | 87 | async with trio.open_nursery() as nursery: 88 | shutdown = trio.Event() 89 | 90 | async def serve() -> None: 91 | await hypercorn.trio.serve(app, config, shutdown_trigger=shutdown.wait) 92 | 93 | nursery.start_soon(serve) 94 | 95 | await trio.testing.wait_all_tasks_blocked() 96 | 97 | client = httpx.AsyncClient() 98 | 99 | result = await client.get("http://0.0.0.0:1234/") 100 | assert result.content == b"" 101 | 102 | result = await client.get("http://0.0.0.0:1234/") 103 | assert result.content == b"" 104 | 105 | shutdown.set() 106 | -------------------------------------------------------------------------------- /tests/assets/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDzJR9mEtv3uBfC 3 | +gBIJ6LODOTyaVr82jUrKjXn872rGTJG2wgSGDW7+hA7QgR6Ab3+9GPZKkPAZqfM 4 | T0cZbufl6QbLtQNhRamFyoTrzsFP6dIfZ9IPhlhj6dSU87dkT+fR6H/CeKIiZ9mU 5 | ao6nCdKmjUvBAee5ReA4Qo0LIBdfsYBhtI/tgL3di3Gzj2W4Amwn5b7096mDPeE3 6 | uEzIqyreKoZeAbGEk9lcUtu+b5MoN6rDrJ7WfnHxrstfbE0aNkeDLQVYUr4i7+zR 7 | IWnejRo486Awa33QLXMP03CTVi2ftyhiCah7TACXNP03O3YHuJguyPMGZt0Dhj28 8 | KqpLtbpZlfhWye6HfTh8qcoMDYBMZmyH+j5oSF82nfzqasfHgi+abEd7HwZrhYfK 9 | MXwycwm/nSPr3aDR6J+0pN11TXINh1pCJgqI1iNAlxj4pbSxtmaO9e+Zsksyx9JK 10 | WXpzRuUi9KP8TZbon0tcqvvd9ksVhC8XwI3ArZWJ2ygE2o3k6aFpoA4t7ev1hbZx 11 | 9O+jzguL+UaubZas0DP2z4K6V0B6J2YFqpSLbWRxYN6DMoHfMDWu7ZIyfFemhlNL 12 | R8JDauRK2WB28QV568SJDYFhneZ4Fph50DGllL3YS9rljaCXNc++XMhebT0p6N7X 13 | Wiyaf9SxpdMA+2liTIXml+yucBZdZQIDAQABAoICACnSw+Dh85ZbwzKVoEDJGJcK 14 | 3sLX3n/J5QVkwFsCsShiMCTB/lRmd6+65tnalDyMWislzJsJSxgoUEqzhE5apmcE 15 | u1eE7mzn96381Ppe2R+u36bpS9fByyh8i0WH2o7Vs9GGhZtk9ramWGXQInOXG/Xs 16 | LhCoDDzxSQ1EXVCBl6OtO6ES1wMKdx5Joyg4zU1mlUYTndIzW6Qom7ni6MpHrxsC 17 | A5TeA7QDXosj8YqDVLPBR41a/wN0QpNI9tCWJ3kPxyNINjgoG26VCI48iiJu8QjE 18 | 11Qc2Upa1wTs4NtnInfroHWkpad3vk5EHh5HCxlu5jZ9+Fesj+3QRIQ+boaRXtk+ 19 | Pg+h0693zeN8clw6py2rjwYzLDQYzGQ+sy/Ru+dz15NhhbGKJfV/z32HNEKHyosu 20 | H8A66Rym9eCzhfKQWouEgDXT4dsmFwwfVJr5HASoAkxFgO7d0HnhmqYNvhMrK88K 21 | Vqyfz1RK1lqpFYIZHFi6sICi4IV+vP87RciFFOn8s4h0zJlyU7zjNGollPoSr5tQ 22 | 6UefyNfvNbS4rG38gxiFB+cYamZMLdTjK5r8c7ibs7nDQOakkIViI3nbelGgifgS 23 | qXwwWwIbkAehF62pJ1qHMsiKlB+j7Hd4NHMwz5a6oC37euio0RuYch5IbwmdZONq 24 | uZZlWj46Z0xiUuRRj/vBAoIBAQD75cGpbBFywn9iKd5ZkRGaa9qx0U0+HgQ8fhnE 25 | AXC4mCdx2adO/uxhZtPWrpeHuj5C2cTR1GSWeQ10cEH+y4fr64iiXnXISfPiEH/r 26 | +j/K3TfwntpiSvE90BUz6E3bViAuxAodwXBJvDTQ/l352v80AjeD8iv5aF8go51V 27 | vQz+G5hmP8dzHR2Et0ehyaTCiem9lQbHkdFLsnwy10SpCJgYfT4nXog3aWj07Rr9 28 | b6miYNryCuaAblYCNGpIeOv+qMr5hxMxwS23Xhh7VkqlSz68qWEL6SJiZBCKtqOS 29 | m+IDvr4jBbfaQ3TrixgnQisPoYXv/z5uJYb7ihgoT6Uzn8V1AoIBAQD3Gt/MEeKf 30 | 8m3F7/aKxrthxrqKYxW0D/wx3VGSrveS6EnfW6ifmiMydV1NV2kA3B7wWmd6xTFY 31 | LfkdVg7Oi7ph010zG3wVxRVssCBNeV2vhG2qkKpEUhtLi+acFj9HvzHeSygcKzen 32 | o1UxE27Nzk+bhNf8Fmf1/7/UoeKyOWULE5wIklQ+9xooKcJ53VuMAHyjR4uxLmYc 33 | Nf7Q3+cdLFiY1b8I6T1PdRH7bkfvs45h7VIWOChpNOoVPDJMBEIItR2e0f1mlUpl 34 | z+WFK/VR68fQ2scqJCxD22Ui3F/WTYBBdxwqY4Tbd96aUmP2urMaB6/i2/pZeUcx 35 | Vn0fiOa1PwoxAoIBADViNs2yAmygvaBPITk4HlPsoZdntQgCEoHDc7BvYbUtQcbG 36 | CsgaDHyD70cjDygLl2BRiH2zlnGxS+GuXL4j4jVkYDuQ60M8MPxq5MFc8qIKie1r 37 | rPqByWiBLc0nYUCnmwBuOXqe4S4vPb5A+ieWetlJ0vwamaksrmRbaF+gRh2gOYcJ 38 | 4zoJJJVYxkyKUGmOEsRDzgEDbSiutdWMe5ebI6ik+kQbq6CarUyi50JopLmt7xi2 39 | qKz1NTMYaqHbRqBco0+Iic/Ukdy3i1awLfej37LZ7qA4kzno3PyYwkey045ZoTAI 40 | 6TLPcvrsKn0/b6LLZ3g6Tr/HIjkyxfXdEzTCmnUCggEAZ3Q66kc6qFhpGQvEHonh 41 | fagkBThCp+ZhYccVFeJnCHx0IS1QxbFUtxVoAK9t6Mw/r8VJuZ7Bb/efambTQCpD 42 | 2B0T0gfZxYuD0sNSYt1DGe7JszVp87ykbNafsA2oZLNpf3Xbzx9Q58B8NFW8eDG+ 43 | JpBRlNsUn2t5tt4n+RIKeb61/ui0mL//lX0WTMsePtkdVYbotz+DxJ/elTiInDAq 44 | z6H9nw93ecK7ypZ7S6HTJLClQ2QzlwhuUIGpVSYbN2YMhqfH/aDXSxTlNQIYbTnX 45 | qFtQMxZ96dL63sOA5EoCPmZNxnlv8CqZaebAr1WvEmDRhJswjzE1WzSoogFBBfTk 46 | oQKCAQB7L0lMAtf9W9hyFMqEHvCah8eG7JuX2m2JpCQ5e5xkw/cswGwOX3+JxOYi 47 | joTmVSatkHoeKzBtNUCAkHTjXggIi6bKCXe3Lfon35+/QgAV9c5sNjj2m0K6Jw8A 48 | VR2gN2/VZG+DVaPSrTnglrGvVhs7gSixaHRtvQh+cP3P1LZnYEoOupIVokau54SU 49 | Ur4aIMKo01+r7L38jyvWlPpMqJg8Ev3qopDh0phs+n1n79IJmoswUhGkoKx+tXi2 50 | 1W1/OXfTiU6EoHo/iZRcFYFteWq1pWOvReKI/cT1epM6jyo+GwhOc4Uy0lrSVPZ/ 51 | zuB7aTwj2sz0b8VigqwV8lYW0ZUh 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /tests/asyncio/test_lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | from time import sleep 6 | 7 | import pytest 8 | 9 | from hypercorn.app_wrappers import ASGIWrapper 10 | from hypercorn.asyncio.lifespan import Lifespan 11 | from hypercorn.config import Config 12 | from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope 13 | from hypercorn.utils import LifespanFailureError, LifespanTimeoutError 14 | from ..helpers import SlowLifespanFramework 15 | 16 | try: 17 | from asyncio import TaskGroup 18 | except ImportError: 19 | from taskgroup import TaskGroup # type: ignore 20 | 21 | 22 | async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> None: 23 | sleep(0.1) # Block purposefully 24 | raise Exception() 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_ensure_no_race_condition() -> None: 29 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 30 | 31 | config = Config() 32 | config.startup_timeout = 0.2 33 | lifespan = Lifespan(ASGIWrapper(no_lifespan_app), config, event_loop, {}) 34 | task = event_loop.create_task(lifespan.handle_lifespan()) 35 | await lifespan.wait_for_startup() # Raises if there is a race condition 36 | await task 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_startup_timeout_error() -> None: 41 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 42 | 43 | config = Config() 44 | config.startup_timeout = 0.01 45 | lifespan = Lifespan( 46 | ASGIWrapper(SlowLifespanFramework(0.02, asyncio.sleep)), config, event_loop, {} 47 | ) 48 | task = event_loop.create_task(lifespan.handle_lifespan()) 49 | with pytest.raises(LifespanTimeoutError) as exc_info: 50 | await lifespan.wait_for_startup() 51 | assert str(exc_info.value).startswith("Timeout whilst awaiting startup") 52 | await task 53 | 54 | 55 | async def _lifespan_failure( 56 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 57 | ) -> None: 58 | async with TaskGroup(): 59 | while True: 60 | message = await receive() 61 | if message["type"] == "lifespan.startup": 62 | await send({"type": "lifespan.startup.failed", "message": "Failure"}) 63 | break 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_startup_failure() -> None: 68 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 69 | 70 | lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), event_loop, {}) 71 | lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) 72 | await lifespan.wait_for_startup() 73 | assert lifespan_task.done() 74 | exception = lifespan_task.exception() 75 | assert exception.subgroup(LifespanFailureError) is not None # type: ignore 76 | 77 | 78 | async def return_app(scope: Scope, receive: Callable, send: Callable) -> None: 79 | return 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_lifespan_return() -> None: 84 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 85 | 86 | lifespan = Lifespan(ASGIWrapper(return_app), Config(), event_loop, {}) 87 | lifespan_task = event_loop.create_task(lifespan.handle_lifespan()) 88 | await lifespan.wait_for_startup() 89 | await lifespan.wait_for_shutdown() 90 | # Should complete (not hang) 91 | assert lifespan_task.done() 92 | -------------------------------------------------------------------------------- /compliance/h2spec/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC7CsMtzqjQK19z 3 | Q1jMhSrET4P7gZzm74izf/AiMZh3Mx0R27XH9ycUBstin0AYLkCmLXDie0cZxkQ1 4 | Ej5onoPwS/gWWp1W2VtzI070ypDDgZROyJ1WGhnNTk30gQOzlk9s7puVIejFLFq2 5 | WWqAmz/zCz4ToQPsXqyndvCuwQRhK9t10nkv/tPwjzwiqlnR2U142XurW0PbHh5r 6 | rdG/IvwwqeeGcFrsb9SeOJ0Op/+bNUXZ846qvo8WtBrZM50qJkRfCeVlJhz+dkPt 7 | wLYpVkT7qC/tBtkDW8Py9fNYaMCFA4OveXvG4C6DWQDQJmCdBchL1Rp51Ll02bka 8 | jv133tD58Ti7Zl0TVFOlsZPNKw8oxkW8VCt2LRQoUKnT5DFXzDJnfZqtKPJAJpIG 9 | 3qsoKmbMB8rkay2IpYzbWBHB+y6jygpI0TdUjvN9xKXHBEP02zdLC8gqjZevQrUr 10 | Rog5eAM/zlyMCrc4mxaLNnUGF83d/mmrRNACiOOPirvXLW3iaQ9hKETKAFjfBfpq 11 | /Yq25LNDj8BELqmadyUvm1gUgI4W48I/Pmbv9ygiGEXbW2B4NIuE0ns+BW2n6f7V 12 | dZ8JMsMalbudRE0jLZ9CoDZwGlghs+UXH6aAnCWqyDnlcWpWhQeljr8d+24jhmb8 13 | tHHz996Zg3IhqUnc/7iIwWF3YManXQIDAQABAoICABYKl6OPRe96HP5tQkqfqsGF 14 | iU0bIg1IzvgwLHErHQd2+4b+ODa/VliS0Gbn01rGIJI0qqfV1TQhXCpQ4w/bFjs8 15 | CJlBxmbUqGUyFPzd3h9b5sk99OSPoNjD0IXuqiwAm41/tM/nNhH+PxZcBSPwp6GR 16 | gpg3kknJglkduBEv5783tt30lplkUz928aQ4JOuIywthvaQc1is9KmKQEjaO/d8S 17 | NpluJhjUuN6IV2HBxGpa5cdgX0CZwizvvnY4Ed5Esivs855u1l3aO/kJi63lX620 18 | TSmGdA5kQvwfpbSWa5GBL4R/MWnnQzPxSho9W4dFhiwBieQvgEdX3OtXTGFS3ZdT 19 | Fa5ZESvxgUBkHwfLBS0Cy4RIfGdZUr2ZrMsH/pVcqnZpBJ9qYgYT6p/14VZh4O8l 20 | ZTnckMMR/WTKM0BOFsNDKYSpF7XneKBLe47mWbFdaxEjPxU833j8YFSs+267oGh9 21 | AOOPebe22+qLyv6wVHx8ckse1RxL3CMcdxr7QgXJilGcQGyrl+yGrhiIlxgUfPYW 22 | KSnDlK+dz4+ihZGx/X6zrbT8BcOvq2TPgp9a+OqMRsOhDNgqXnxgOnR5TnFf+S8s 23 | v3l0zXx9V2zZQNREudf+f+a0Qri996CgLu/x/LRwQj/YFRzzSXvdqsSWxQkboEX9 24 | lZ6gzqpst2EVR6xdPCi1AoIBAQDceFLSBd/EbUfBxIEoAGjtE2J9pm9NJO69mSZA 25 | hUJUiHOmVnINnllFcizY31SVgno+g1lmeuVRF8lwXMs4QIweYQyHZv1DXq8JV2YJ 26 | ysr+qe0GYcOi412vBwS6GtR26qDVOvyXlUDd7j31wYiQHxqL1M4qcZ7nzlbe4ulS 27 | 8n5uKUSlCFEo5+4DPVecl6Y0aEwPeC3veqpJmrzL29PpdYaTUbH67G3p+MvqRLbt 28 | KEmsq/FgoGnNuvIT5eP5jA2d2fAMWntHrrlNV3W3FINlPjpoVKxDrBVFoxgqP/24 29 | 6/eC7R3IzesCtsXycPTDBQg0YEEN0bxptxXuZafnNkDmn1Q3AoIBAQDZL1b0BVv6 30 | 3HWPCZtMtoJTwnEKkmyMREBbIcJUoKtR8pOhVYUGqYHY2OtokQv97TPG0G9H1Z72 31 | b1e6SwpGMV7oVCawXFLyZXJ6V2pcvJhwT37Cu4riMeuNmo78mH79engkvbNeq9sl 32 | fFFfaUGwBIXUApBcn5Yluc0FRdZ45RjhvHkKzy/ADWUS9dnHQXh0dbVVi75yajMn 33 | K6oQpaWKKXqyNkGQKFgr6QOtm+Fi1z2SzT+WTTb1JiOhkFnw7DXf8FI3e6TpQy5q 34 | LYqGwr18lKAukDY3HtC/Rx6Bv7GG0RgByFIQGFhyyPZAX+V8GEx//fogL0FVzkUm 35 | JaNoWPfuL78LAoIBAQC3uy6KCIsqz0d1m6VnCLBoojb6L7GhwJ2VNARE0MkuWWjH 36 | vlLeNpB+51+ofLWow0vMvPnMBa8FnaUqFqrk/iXHS2l9jb6SXl3Qkx1eG7p/8Gyv 37 | XNoE7SYtrtOppKJbV70g9j96s8+TI/BO1jJQqRseXQJTLM0YsUSECuYXUi867vld 38 | 70hzppUb7gsNXPQNyL1aRvVBFiDDpkigO1qmvGKicvq3+kC/M6/8U7d+fIypccF+ 39 | nTCPWrRTEMqkNKtEWVNLeDw0yM90PObE5Dt8LBfQyn+lBcvUdM62pw1zBnMGkUS5 40 | C6JGaLseCDRyMcdcnrqYIam7D/Ee82ixruz3ROCRAoIBAFTCpfPcN5aC/ZpSTHq2 41 | 68wWoZlXpedkJ52pYjc28UWtHzKitqTv+I4RsmX/3ac/MKrR4+wsEbrpn1pEOQFF 42 | +V1AokzH61NQhkn63bbNn8yNKdKD8OLwSpbcEBvCxCTW7BaitmMnPQK3LubGpG02 43 | hqhES+TqH2YfykTZiadq+bf3n2G5lFAmqiCpNFIQWhtRaPC29h+fFNGft+KBU0bF 44 | g24TwKirJiYU7WuO33p8uDoXwk49Wkp4lQVT2dYtyaTZHK0soyKqJm4n0d1gGSWK 45 | t60UeSQv8ZYFAoHutzD/X5gqfuRrK/G4PmrHQj+ZGBoHm9t9tcjwFIqbu9dYiYI6 46 | vhsCggEAc2SFaHYadaasUm48iJe463x1IwUb7tibiX9DN1cBm6lbwOhCcSqyFwKj 47 | 5BXeBNPwFRNCAI+nxs1gMGUHMtcd234Fdg6GySWSnMsavgP4sn9gfZMC2OctNeHg 48 | F1JIPpf8i/X3wC6+9It8RlKHvStQXMnlJxsgJq4yJ6IfenLpzWl4f/YoX+01sLYh 49 | lAOfYRnMMScRIfOErOC28l4qKcPsGwFyJmB7TiVgxz83B12hnjA9bH8mk7OVpYJR 50 | p9LY/FGdPXo4JvKE4G1AEHaRjdgPqicFYDfXKIPQ1dy4hwQMcYlApDvOax/gaKrY 51 | /h39p3qBT+YHuuM7DwN83zVLIgyBzw== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Hypercorn" 3 | version = "0.18.0" 4 | description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" 5 | authors = [ 6 | {name = "pgjones", email = "philip.graham.jones@googlemail.com"}, 7 | ] 8 | classifiers = [ 9 | "Development Status :: 4 - Beta", 10 | "Environment :: Web Environment", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3.14", 21 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 22 | "Topic :: Software Development :: Libraries :: Python Modules", 23 | ] 24 | include = ["src/hypercorn/py.typed"] 25 | license = {text = "MIT"} 26 | readme = "README.rst" 27 | repository = "https://github.com/pgjones/hypercorn/" 28 | dependencies = [ 29 | "exceptiongroup >= 1.1.0; python_version < '3.11'", 30 | "h11", 31 | "h2 >= 4.3.0", 32 | "priority", 33 | "taskgroup; python_version < '3.11'", 34 | "tomli; python_version < '3.11'", 35 | "typing_extensions; python_version < '3.11'", 36 | "wsproto >= 0.14.0" 37 | ] 38 | documentation = "https://hypercorn.readthedocs.io" 39 | requires-python = ">=3.10" 40 | 41 | [project.optional-dependencies] 42 | docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] 43 | h3 = ["aioquic >= 0.9.0"] 44 | trio = ["trio"] 45 | uvloop = ["uvloop"] 46 | 47 | [dependency-groups] 48 | dev = [ 49 | "httpx", 50 | "hypothesis", 51 | "mock", 52 | "pytest", 53 | "pytest-asyncio", 54 | "pytest-trio", 55 | "trio" 56 | ] 57 | 58 | [project.scripts] 59 | hypercorn = "hypercorn.__main__:main" 60 | 61 | [tool.black] 62 | line-length = 100 63 | target-version = ["py310"] 64 | 65 | [tool.isort] 66 | combine_as_imports = true 67 | force_grid_wrap = 0 68 | include_trailing_comma = true 69 | known_first_party = "hypercorn, tests" 70 | line_length = 100 71 | multi_line_output = 3 72 | no_lines_before = "LOCALFOLDER" 73 | order_by_type = false 74 | reverse_relative = true 75 | 76 | [tool.mypy] 77 | allow_redefinition = true 78 | disallow_any_generics = false 79 | disallow_subclassing_any = true 80 | disallow_untyped_calls = false 81 | disallow_untyped_defs = true 82 | implicit_reexport = true 83 | no_implicit_optional = true 84 | show_error_codes = true 85 | strict = true 86 | strict_equality = true 87 | strict_optional = false 88 | warn_redundant_casts = true 89 | warn_return_any = false 90 | warn_unused_configs = true 91 | warn_unused_ignores = true 92 | 93 | [[tool.mypy.overrides]] 94 | module =["aioquic.*", "cryptography.*", "h11.*", "h2.*", "priority.*", "pytest_asyncio.*", "uvloop.*"] 95 | ignore_missing_imports = true 96 | 97 | [[tool.mypy.overrides]] 98 | module = ["trio.*", "tests.trio.*"] 99 | disallow_any_generics = true 100 | disallow_untyped_calls = true 101 | strict_optional = true 102 | warn_return_any = true 103 | 104 | [tool.pytest.ini_options] 105 | addopts = "--no-cov-on-fail --showlocals --strict-markers" 106 | asyncio_mode = "strict" 107 | testpaths = ["tests"] 108 | 109 | [build-system] 110 | requires = ["pdm-backend"] 111 | build-backend = "pdm.backend" 112 | -------------------------------------------------------------------------------- /tests/middleware/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | from hypercorn.middleware.dispatcher import AsyncioDispatcherMiddleware, TrioDispatcherMiddleware 9 | from hypercorn.typing import HTTPScope, Scope 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_dispatcher_middleware(http_scope: HTTPScope) -> None: 14 | class EchoFramework: 15 | def __init__(self, name: str) -> None: 16 | self.name = name 17 | 18 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 19 | scope = cast(HTTPScope, scope) 20 | response = f"{self.name}-{scope['path']}" 21 | await send( 22 | { 23 | "type": "http.response.start", 24 | "status": 200, 25 | "headers": [(b"content-length", b"%d" % len(response))], 26 | } 27 | ) 28 | await send({"type": "http.response.body", "body": response.encode()}) 29 | 30 | app = AsyncioDispatcherMiddleware( 31 | {"/api/x": EchoFramework("apix"), "/api": EchoFramework("api")} 32 | ) 33 | 34 | sent_events = [] 35 | 36 | async def send(message: dict) -> None: 37 | sent_events.append(message) 38 | 39 | await app({**http_scope, **{"path": "/api/x/b"}}, None, send) # type: ignore 40 | await app({**http_scope, **{"path": "/api/b"}}, None, send) # type: ignore 41 | await app({**http_scope, **{"path": "/"}}, None, send) # type: ignore 42 | assert sent_events == [ 43 | {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"13")]}, 44 | {"type": "http.response.body", "body": b"apix-/api/x/b"}, 45 | {"type": "http.response.start", "status": 200, "headers": [(b"content-length", b"10")]}, 46 | {"type": "http.response.body", "body": b"api-/api/b"}, 47 | {"type": "http.response.start", "status": 404, "headers": [(b"content-length", b"0")]}, 48 | {"type": "http.response.body"}, 49 | ] 50 | 51 | 52 | class ScopeFramework: 53 | def __init__(self, name: str) -> None: 54 | self.name = name 55 | 56 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 57 | await send({"type": "lifespan.startup.complete"}) 58 | 59 | 60 | @pytest.mark.asyncio 61 | async def test_asyncio_dispatcher_lifespan() -> None: 62 | app = AsyncioDispatcherMiddleware( 63 | {"/apix": ScopeFramework("apix"), "/api": ScopeFramework("api")} 64 | ) 65 | 66 | sent_events = [] 67 | 68 | async def send(message: dict) -> None: 69 | sent_events.append(message) 70 | 71 | async def receive() -> dict: 72 | return {"type": "lifespan.shutdown"} 73 | 74 | await app({"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}, receive, send) 75 | assert sent_events == [{"type": "lifespan.startup.complete"}] 76 | 77 | 78 | @pytest.mark.trio 79 | async def test_trio_dispatcher_lifespan() -> None: 80 | app = TrioDispatcherMiddleware({"/apix": ScopeFramework("apix"), "/api": ScopeFramework("api")}) 81 | 82 | sent_events = [] 83 | 84 | async def send(message: dict) -> None: 85 | sent_events.append(message) 86 | 87 | async def receive() -> dict: 88 | return {"type": "lifespan.shutdown"} 89 | 90 | await app({"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}, receive, send) 91 | assert sent_events == [{"type": "lifespan.startup.complete"}] 92 | -------------------------------------------------------------------------------- /docs/how_to_guides/api_usage.rst: -------------------------------------------------------------------------------- 1 | .. _api_usage: 2 | 3 | API Usage 4 | ========= 5 | 6 | Most usage of Hypercorn is expected to be via the command line, as 7 | explained in the :ref:`usage` documentation. Alternatively it is 8 | possible to use Hypercorn programmatically via the ``serve`` function 9 | available for either the asyncio or trio :ref:`workers` (note the 10 | asyncio ``serve`` can be used with uvloop). This can be done as 11 | follows, first you need to create a Hypercorn Config instance, 12 | 13 | .. code-block:: python 14 | 15 | from hypercorn.config import Config 16 | 17 | config = Config() 18 | config.bind = ["localhost:8080"] # As an example configuration setting 19 | 20 | Then assuming you have an ASGI or WSGI framework instance called 21 | ``app``, using asyncio, 22 | 23 | .. code-block:: python 24 | 25 | import asyncio 26 | from hypercorn.asyncio import serve 27 | 28 | asyncio.run(serve(app, config)) 29 | 30 | The same for Trio, 31 | 32 | .. code-block:: python 33 | 34 | import trio 35 | from hypercorn.trio import serve 36 | 37 | trio.run(serve, app, config) 38 | 39 | The same for uvloop, 40 | 41 | .. code-block:: python 42 | 43 | import asyncio 44 | 45 | import uvloop 46 | from hypercorn.asyncio import serve 47 | 48 | uvloop.install() 49 | asyncio.run(serve(app, config)) 50 | 51 | Features caveat 52 | --------------- 53 | 54 | The API usage assumes that you wish to control how the event loop is 55 | configured and where the event loop runs. Therefore the configuration 56 | options to change the worker class and number of workers have no 57 | affect when using serve. 58 | 59 | Graceful shutdown 60 | ----------------- 61 | 62 | To shutdown the app the ``serve`` function takes an additional 63 | ``shutdown_trigger`` argument that will be awaited by Hypercorn. If 64 | the ``shutdown_trigger`` returns it will trigger a graceful 65 | shutdown. An example use of this functionality is to shutdown on 66 | receipt of a TERM signal, 67 | 68 | .. code-block:: python 69 | 70 | import asyncio 71 | import signal 72 | 73 | shutdown_event = asyncio.Event() 74 | 75 | def _signal_handler(*_: Any) -> None: 76 | shutdown_event.set() 77 | 78 | loop = asyncio.get_event_loop() 79 | loop.add_signal_handler(signal.SIGTERM, _signal_handler) 80 | loop.run_until_complete( 81 | serve(app, config, shutdown_trigger=shutdown_event.wait) 82 | ) 83 | 84 | No signal handling 85 | ------------------ 86 | 87 | If you don't want any signal handling you can set the 88 | ``shutdown_trigger`` to return an awaitable that doesn't complete, for 89 | example returning an empty Future, 90 | 91 | .. code-block:: python 92 | 93 | loop.run_until_complete( 94 | serve(app, config, shutdown_trigger=lambda: asyncio.Future()) 95 | ) 96 | 97 | SSL Error reporting 98 | ------------------- 99 | 100 | SSLErrors can be raised during the SSL handshake with the connecting 101 | client. These errors are handled by the event loop and reported via 102 | the loop's exception handler. Using Hypercorn via the command line 103 | will mean that these errors are ignored. To ignore (or otherwise 104 | handle) these errors when using the API configure the event loop 105 | exception handler, 106 | 107 | .. code-block:: python 108 | 109 | def _exception_handler(loop, context): 110 | exception = context.get("exception") 111 | if isinstance(exception, ssl.SSLError): 112 | pass # Handshake failure 113 | else: 114 | loop.default_exception_handler(context) 115 | 116 | loop.set_exception_handler(_exception_handler) 117 | 118 | Forcing ASGI or WSGI mode 119 | ------------------------- 120 | 121 | The ``serve`` function takes a ``mode`` argument that can be 122 | ``"asgi"`` or ``"wsgi"`` to force the app to be considered ASGI or 123 | WSGI as required. 124 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | workflow_dispatch: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | tox: 13 | name: ${{ matrix.name }} 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: read 18 | 19 | container: python:${{ matrix.python }} 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | include: 25 | - {name: '3.14', python: '3.14', tox: py314} 26 | - {name: '3.13', python: '3.13', tox: py313} 27 | - {name: '3.12', python: '3.12', tox: py312} 28 | - {name: '3.11', python: '3.11', tox: py311} 29 | - {name: '3.10', python: '3.10', tox: py310} 30 | - {name: 'format', python: '3.14', tox: format} 31 | - {name: 'mypy', python: '3.14', tox: mypy} 32 | - {name: 'pep8', python: '3.14', tox: pep8} 33 | - {name: 'package', python: '3.14', tox: package} 34 | 35 | steps: 36 | - uses: pgjones/actions/tox@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 37 | with: 38 | environment: ${{ matrix.tox }} 39 | 40 | 41 | h2spec: 42 | name: ${{ matrix.name }} 43 | runs-on: ubuntu-latest 44 | 45 | permissions: 46 | contents: read 47 | 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | include: 52 | - {name: 'asyncio', worker: 'asyncio'} 53 | - {name: 'trio', worker: 'trio'} 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | with: 58 | persist-credentials: false 59 | 60 | - uses: actions/setup-python@v5 61 | with: 62 | python-version: "3.14" 63 | 64 | - name: update pip 65 | run: | 66 | pip install -U wheel 67 | pip install -U setuptools 68 | python -m pip install -U pip 69 | - run: pip install trio . 70 | 71 | - name: Run server 72 | working-directory: compliance/h2spec 73 | run: nohup hypercorn --keyfile key.pem --certfile cert.pem -k ${{ matrix.worker }} server:app & 74 | 75 | - name: Download h2spec 76 | run: | 77 | wget https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz 78 | tar -xvf h2spec_linux_amd64.tar.gz 79 | 80 | - name: Run h2spec 81 | run: ./h2spec -tk -h 127.0.0.1 -p 8000 -o 10 82 | 83 | autobahn: 84 | name: ${{ matrix.name }} 85 | runs-on: ubuntu-latest 86 | 87 | permissions: 88 | contents: read 89 | 90 | strategy: 91 | fail-fast: false 92 | matrix: 93 | include: 94 | - {name: 'asyncio', worker: 'asyncio'} 95 | - {name: 'trio', worker: 'trio'} 96 | 97 | steps: 98 | - uses: actions/checkout@v4 99 | with: 100 | persist-credentials: false 101 | 102 | - uses: actions/setup-python@v5 103 | with: 104 | python-version: "3.14" 105 | 106 | - name: update pip 107 | run: | 108 | pip install -U wheel 109 | pip install -U setuptools 110 | python -m pip install -U pip 111 | - run: python3 -m pip install trio . 112 | - name: Run server 113 | working-directory: compliance/autobahn 114 | run: nohup hypercorn -k ${{ matrix.worker }} server:app & 115 | 116 | - name: Run Unit Tests 117 | working-directory: compliance/autobahn 118 | run: docker run --rm --network=host -v "${PWD}/:/config" -v "${PWD}/reports:/reports" --name fuzzingclient crossbario/autobahn-testsuite wstest -m fuzzingclient -s /config/fuzzingclient.json && python3 summarise.py 119 | 120 | zizmor: 121 | name: Zizmor 122 | runs-on: ubuntu-latest 123 | 124 | permissions: 125 | contents: read 126 | 127 | steps: 128 | - uses: pgjones/actions/zizmor@dbbee601c084d000c4fc711d4b27cb306e15ead1 # v1 129 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Hypercorn 2 | ========= 3 | 4 | .. image:: https://github.com/pgjones/hypercorn/raw/main/artwork/logo.png 5 | :alt: Hypercorn logo 6 | 7 | |Build Status| |docs| |pypi| |http| |python| |license| 8 | 9 | Hypercorn is an `ASGI 10 | `_ and 11 | WSGI web server based on the sans-io hyper, `h11 12 | `_, `h2 13 | `_, and `wsproto 14 | `_ libraries and inspired by 15 | Gunicorn. Hypercorn supports HTTP/1, HTTP/2, WebSockets (over HTTP/1 16 | and HTTP/2), ASGI, and WSGI specifications. Hypercorn can utilise 17 | asyncio, uvloop, or trio worker types. 18 | 19 | Hypercorn can optionally serve the current draft of the HTTP/3 20 | specification using the `aioquic 21 | `_ library. To enable this install 22 | the ``h3`` optional extra, ``pip install hypercorn[h3]`` and then 23 | choose a quic binding e.g. ``hypercorn --quic-bind localhost:4433 24 | ...``. 25 | 26 | Hypercorn was initially part of `Quart 27 | `_ before being separated out into a 28 | standalone server. Hypercorn forked from version 0.5.0 of Quart. 29 | 30 | Quickstart 31 | ---------- 32 | 33 | Hypercorn can be installed via `pip 34 | `_, 35 | 36 | .. code-block:: console 37 | 38 | $ pip install hypercorn 39 | 40 | With hypercorn installed ASGI frameworks (or apps) can be served via 41 | Hypercorn via the command line, 42 | 43 | .. code-block:: console 44 | 45 | $ hypercorn module:app 46 | 47 | Alternatively Hypercorn can be used programatically, 48 | 49 | .. code-block:: python 50 | 51 | import asyncio 52 | from hypercorn.config import Config 53 | from hypercorn.asyncio import serve 54 | 55 | from module import app 56 | 57 | asyncio.run(serve(app, Config())) 58 | 59 | learn more (including a Trio example of the above) in the `API usage 60 | `_ 61 | docs. 62 | 63 | Contributing 64 | ------------ 65 | 66 | Hypercorn is developed on `Github 67 | `_. If you come across an issue, 68 | or have a feature request please open an `issue 69 | `_. If you want to 70 | contribute a fix or the feature-implementation please do (typo fixes 71 | welcome), by proposing a `pull request 72 | `_. 73 | 74 | Testing 75 | ~~~~~~~ 76 | 77 | The best way to test Hypercorn is with `Tox 78 | `_, 79 | 80 | .. code-block:: console 81 | 82 | $ pipenv install tox 83 | $ tox 84 | 85 | this will check the code style and run the tests. 86 | 87 | Help 88 | ---- 89 | 90 | The Hypercorn `documentation `_ is 91 | the best place to start, after that try searching stack overflow, if 92 | you still can't find an answer please `open an issue 93 | `_. 94 | 95 | 96 | .. |Build Status| image:: https://github.com/pgjones/hypercorn/actions/workflows/ci.yml/badge.svg 97 | :target: https://github.com/pgjones/hypercorn/commits/main 98 | 99 | .. |docs| image:: https://img.shields.io/badge/docs-passing-brightgreen.svg 100 | :target: https://hypercorn.readthedocs.io 101 | 102 | .. |pypi| image:: https://img.shields.io/pypi/v/hypercorn.svg 103 | :target: https://pypi.python.org/pypi/Hypercorn/ 104 | 105 | .. |http| image:: https://img.shields.io/badge/http-1.0,1.1,2-orange.svg 106 | :target: https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol 107 | 108 | .. |python| image:: https://img.shields.io/pypi/pyversions/hypercorn.svg 109 | :target: https://pypi.python.org/pypi/Hypercorn/ 110 | 111 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg 112 | :target: https://github.com/pgjones/hypercorn/blob/main/LICENSE 113 | -------------------------------------------------------------------------------- /tests/protocol/test_h2.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from unittest.mock import AsyncMock, call, Mock 5 | 6 | import pytest 7 | from h2.connection import H2Connection 8 | from h2.events import ConnectionTerminated 9 | 10 | from hypercorn.asyncio.worker_context import EventWrapper, WorkerContext 11 | from hypercorn.config import Config 12 | from hypercorn.events import Closed, RawData 13 | from hypercorn.protocol.h2 import BUFFER_HIGH_WATER, BufferCompleteError, H2Protocol, StreamBuffer 14 | from hypercorn.typing import ConnectionState 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_stream_buffer_push_and_pop() -> None: 19 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 20 | 21 | stream_buffer = StreamBuffer(EventWrapper) 22 | 23 | async def _push_over_limit() -> bool: 24 | await stream_buffer.push(b"a" * (BUFFER_HIGH_WATER + 1)) 25 | return True 26 | 27 | task = event_loop.create_task(_push_over_limit()) 28 | assert not task.done() # Blocked as over high water 29 | await stream_buffer.pop(BUFFER_HIGH_WATER // 4) 30 | assert not task.done() # Blocked as over low water 31 | await stream_buffer.pop(BUFFER_HIGH_WATER // 4) 32 | assert (await task) is True 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_stream_buffer_drain() -> None: 37 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 38 | 39 | stream_buffer = StreamBuffer(EventWrapper) 40 | await stream_buffer.push(b"a" * 10) 41 | 42 | async def _drain() -> bool: 43 | await stream_buffer.drain() 44 | return True 45 | 46 | task = event_loop.create_task(_drain()) 47 | assert not task.done() # Blocked 48 | await stream_buffer.pop(20) 49 | assert (await task) is True 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_stream_buffer_closed() -> None: 54 | stream_buffer = StreamBuffer(EventWrapper) 55 | await stream_buffer.close() 56 | await stream_buffer._is_empty.wait() 57 | await stream_buffer._paused.wait() 58 | assert True 59 | with pytest.raises(BufferCompleteError): 60 | await stream_buffer.push(b"a") 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_stream_buffer_complete() -> None: 65 | stream_buffer = StreamBuffer(EventWrapper) 66 | await stream_buffer.push(b"a" * 10) 67 | assert not stream_buffer.complete 68 | stream_buffer.set_complete() 69 | assert not stream_buffer.complete 70 | await stream_buffer.pop(20) 71 | assert stream_buffer.complete 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_protocol_handle_protocol_error() -> None: 76 | protocol = H2Protocol( 77 | Mock(), 78 | Config(), 79 | WorkerContext(None), 80 | AsyncMock(), 81 | ConnectionState({}), 82 | False, 83 | None, 84 | None, 85 | AsyncMock(), 86 | ) 87 | await protocol.handle(RawData(data=b"broken nonsense\r\n\r\n")) 88 | protocol.send.assert_awaited() # type: ignore 89 | assert protocol.send.call_args_list == [call(Closed())] # type: ignore 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_protocol_keep_alive_max_requests() -> None: 94 | protocol = H2Protocol( 95 | Mock(), 96 | Config(), 97 | WorkerContext(None), 98 | AsyncMock(), 99 | ConnectionState({}), 100 | False, 101 | None, 102 | None, 103 | AsyncMock(), 104 | ) 105 | protocol.config.keep_alive_max_requests = 0 106 | client = H2Connection() 107 | client.initiate_connection() 108 | headers = [ 109 | (":method", "GET"), 110 | (":path", "/reqinfo"), 111 | (":authority", "hypercorn"), 112 | (":scheme", "https"), 113 | ] 114 | client.send_headers(1, headers, end_stream=True) 115 | await protocol.handle(RawData(data=client.data_to_send())) 116 | protocol.send.assert_awaited() # type: ignore 117 | events = client.receive_data(protocol.send.call_args_list[1].args[0].data) # type: ignore 118 | assert isinstance(events[-1], ConnectionTerminated) 119 | -------------------------------------------------------------------------------- /src/hypercorn/statsd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TYPE_CHECKING 4 | 5 | from .logging import Logger 6 | 7 | if TYPE_CHECKING: 8 | from .config import Config 9 | from .typing import ResponseSummary, WWWScope 10 | 11 | METRIC_VAR = "metric" 12 | VALUE_VAR = "value" 13 | MTYPE_VAR = "mtype" 14 | GAUGE_TYPE = "gauge" 15 | COUNTER_TYPE = "counter" 16 | HISTOGRAM_TYPE = "histogram" 17 | 18 | 19 | class StatsdLogger(Logger): 20 | def __init__(self, config: Config) -> None: 21 | super().__init__(config) 22 | self.dogstatsd_tags = config.dogstatsd_tags 23 | self.prefix = config.statsd_prefix 24 | if len(self.prefix) and self.prefix[-1] != ".": 25 | self.prefix += "." 26 | 27 | async def critical(self, message: str, *args: Any, **kwargs: Any) -> None: 28 | await super().critical(message, *args, **kwargs) 29 | await self.increment("hypercorn.log.critical", 1) 30 | 31 | async def error(self, message: str, *args: Any, **kwargs: Any) -> None: 32 | await super().error(message, *args, **kwargs) 33 | await self.increment("hypercorn.log.error", 1) 34 | 35 | async def warning(self, message: str, *args: Any, **kwargs: Any) -> None: 36 | await super().warning(message, *args, **kwargs) 37 | await self.increment("hypercorn.log.warning", 1) 38 | 39 | async def info(self, message: str, *args: Any, **kwargs: Any) -> None: 40 | await super().info(message, *args, **kwargs) 41 | 42 | async def debug(self, message: str, *args: Any, **kwargs: Any) -> None: 43 | await super().debug(message, *args, **kwargs) 44 | 45 | async def exception(self, message: str, *args: Any, **kwargs: Any) -> None: 46 | await super().exception(message, *args, **kwargs) 47 | await self.increment("hypercorn.log.exception", 1) 48 | 49 | async def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None: 50 | try: 51 | extra = kwargs.get("extra", None) 52 | if extra is not None: 53 | metric = extra.get(METRIC_VAR, None) 54 | value = extra.get(VALUE_VAR, None) 55 | type_ = extra.get(MTYPE_VAR, None) 56 | if metric and value and type_: 57 | if type_ == GAUGE_TYPE: 58 | await self.gauge(metric, value) 59 | elif type_ == COUNTER_TYPE: 60 | await self.increment(metric, value) 61 | elif type_ == HISTOGRAM_TYPE: 62 | await self.histogram(metric, value) 63 | 64 | if message: 65 | await super().log(level, message, *args, **kwargs) 66 | except Exception: 67 | await super().warning("Failed to log to statsd", exc_info=True) 68 | 69 | async def access( 70 | self, request: WWWScope, response: ResponseSummary | None, request_time: float 71 | ) -> None: 72 | await super().access(request, response, request_time) 73 | await self.histogram("hypercorn.request.duration", request_time * 1_000) 74 | await self.increment("hypercorn.requests", 1) 75 | if response is not None: 76 | await self.increment(f"hypercorn.request.status.{response['status']}", 1) 77 | 78 | async def gauge(self, name: str, value: int) -> None: 79 | await self._send(f"{self.prefix}{name}:{value}|g") 80 | 81 | async def increment(self, name: str, value: int, sampling_rate: float = 1.0) -> None: 82 | await self._send(f"{self.prefix}{name}:{value}|c|@{sampling_rate}") 83 | 84 | async def decrement(self, name: str, value: int, sampling_rate: float = 1.0) -> None: 85 | await self._send(f"{self.prefix}{name}:-{value}|c|@{sampling_rate}") 86 | 87 | async def histogram(self, name: str, value: float) -> None: 88 | await self._send(f"{self.prefix}{name}:{value}|ms") 89 | 90 | async def _send(self, message: str) -> None: 91 | if self.dogstatsd_tags: 92 | message = f"{message}|#{self.dogstatsd_tags}" 93 | await self._socket_send(message.encode("ascii")) 94 | 95 | async def _socket_send(self, message: bytes) -> None: 96 | raise NotImplementedError() 97 | -------------------------------------------------------------------------------- /src/hypercorn/trio/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | import trio 6 | 7 | from ..config import Config 8 | from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState 9 | from ..utils import LifespanFailureError, LifespanTimeoutError 10 | 11 | if sys.version_info < (3, 11): 12 | from exceptiongroup import BaseExceptionGroup 13 | 14 | 15 | class UnexpectedMessageError(Exception): 16 | pass 17 | 18 | 19 | class Lifespan: 20 | def __init__(self, app: AppWrapper, config: Config, state: LifespanState) -> None: 21 | self.app = app 22 | self.config = config 23 | self.startup = trio.Event() 24 | self.shutdown = trio.Event() 25 | self.app_send_channel, self.app_receive_channel = trio.open_memory_channel[ 26 | ASGIReceiveEvent 27 | ](config.max_app_queue_size) 28 | self.state = state 29 | self.supported = True 30 | 31 | async def handle_lifespan( 32 | self, *, task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED 33 | ) -> None: 34 | task_status.started() 35 | scope: LifespanScope = { 36 | "type": "lifespan", 37 | "asgi": {"spec_version": "2.0", "version": "3.0"}, 38 | "state": self.state, 39 | } 40 | try: 41 | await self.app( 42 | scope, 43 | self.asgi_receive, 44 | self.asgi_send, 45 | trio.to_thread.run_sync, 46 | trio.from_thread.run, 47 | ) 48 | except (LifespanFailureError, trio.Cancelled): 49 | raise 50 | except (BaseExceptionGroup, Exception) as error: 51 | if isinstance(error, BaseExceptionGroup): 52 | reraise_error = error.subgroup((LifespanFailureError, trio.Cancelled)) 53 | if reraise_error is not None: 54 | raise reraise_error 55 | 56 | self.supported = False 57 | if not self.startup.is_set(): 58 | await self.config.log.warning( 59 | "ASGI Framework Lifespan error, continuing without Lifespan support" 60 | ) 61 | elif not self.shutdown.is_set(): 62 | await self.config.log.exception( 63 | "ASGI Framework Lifespan error, shutdown without Lifespan support" 64 | ) 65 | else: 66 | await self.config.log.exception("ASGI Framework Lifespan errored after shutdown.") 67 | finally: 68 | self.startup.set() 69 | self.shutdown.set() 70 | await self.app_send_channel.aclose() 71 | await self.app_receive_channel.aclose() 72 | 73 | async def wait_for_startup(self) -> None: 74 | if not self.supported: 75 | return 76 | 77 | await self.app_send_channel.send({"type": "lifespan.startup"}) 78 | try: 79 | with trio.fail_after(self.config.startup_timeout): 80 | await self.startup.wait() 81 | except trio.TooSlowError as error: 82 | raise LifespanTimeoutError("startup") from error 83 | 84 | async def wait_for_shutdown(self) -> None: 85 | if not self.supported: 86 | return 87 | 88 | await self.app_send_channel.send({"type": "lifespan.shutdown"}) 89 | try: 90 | with trio.fail_after(self.config.shutdown_timeout): 91 | await self.shutdown.wait() 92 | except trio.TooSlowError as error: 93 | raise LifespanTimeoutError("startup") from error 94 | 95 | async def asgi_receive(self) -> ASGIReceiveEvent: 96 | return await self.app_receive_channel.receive() 97 | 98 | async def asgi_send(self, message: ASGISendEvent) -> None: 99 | if message["type"] == "lifespan.startup.complete": 100 | self.startup.set() 101 | elif message["type"] == "lifespan.shutdown.complete": 102 | self.shutdown.set() 103 | elif message["type"] == "lifespan.startup.failed": 104 | raise LifespanFailureError("startup", message.get("message", "")) 105 | elif message["type"] == "lifespan.shutdown.failed": 106 | raise LifespanFailureError("shutdown", message.get("message", "")) 107 | else: 108 | raise UnexpectedMessageError(message["type"]) 109 | -------------------------------------------------------------------------------- /tests/asyncio/test_keep_alive.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import AsyncGenerator 5 | 6 | import h11 7 | import pytest 8 | import pytest_asyncio 9 | 10 | from hypercorn.app_wrappers import ASGIWrapper 11 | from hypercorn.asyncio.tcp_server import TCPServer 12 | from hypercorn.asyncio.worker_context import WorkerContext 13 | from hypercorn.config import Config 14 | from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope 15 | from .helpers import MemoryReader, MemoryWriter 16 | 17 | KEEP_ALIVE_TIMEOUT = 0.01 18 | REQUEST = h11.Request(method="GET", target="/", headers=[(b"host", b"hypercorn")]) 19 | 20 | 21 | async def slow_framework( 22 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 23 | ) -> None: 24 | while True: 25 | event = await receive() 26 | if event["type"] == "http.disconnect": 27 | break 28 | elif event["type"] == "lifespan.startup": 29 | await send({"type": "lifspan.startup.complete"}) # type: ignore 30 | elif event["type"] == "lifespan.shutdown": 31 | await send({"type": "lifspan.shutdown.complete"}) # type: ignore 32 | elif event["type"] == "http.request" and not event.get("more_body", False): 33 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 34 | await send( 35 | { 36 | "type": "http.response.start", 37 | "status": 200, 38 | "headers": [(b"content-length", b"0")], 39 | } 40 | ) 41 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 42 | break 43 | 44 | 45 | @pytest_asyncio.fixture(name="server", scope="function") # type: ignore[misc] 46 | async def _server() -> AsyncGenerator[TCPServer]: 47 | event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() 48 | 49 | config = Config() 50 | config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT 51 | server = TCPServer( 52 | ASGIWrapper(slow_framework), 53 | event_loop, 54 | config, 55 | WorkerContext(None), 56 | {}, 57 | MemoryReader(), # type: ignore 58 | MemoryWriter(), # type: ignore 59 | ) 60 | task = event_loop.create_task(server.run()) 61 | yield server 62 | server.reader.close() # type: ignore 63 | await task 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_http1_keep_alive_pre_request(server: TCPServer) -> None: 68 | await server.reader.send(b"GET") # type: ignore 69 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 70 | assert server.writer.is_closed # type: ignore 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_http1_keep_alive_during(server: TCPServer) -> None: 75 | client = h11.Connection(h11.CLIENT) 76 | await server.reader.send(client.send(REQUEST)) # type: ignore 77 | await server.reader.send(client.send(h11.EndOfMessage())) # type: ignore 78 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 79 | assert not server.writer.is_closed # type: ignore 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_http1_keep_alive(server: TCPServer) -> None: 84 | client = h11.Connection(h11.CLIENT) 85 | await server.reader.send(client.send(REQUEST)) # type: ignore 86 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 87 | assert not server.writer.is_closed # type: ignore 88 | await server.reader.send(client.send(h11.EndOfMessage())) # type: ignore 89 | while True: 90 | event = client.next_event() 91 | if event == h11.NEED_DATA: 92 | data = await server.writer.receive() # type: ignore 93 | client.receive_data(data) 94 | elif isinstance(event, h11.EndOfMessage): 95 | break 96 | client.start_next_cycle() 97 | await server.reader.send(client.send(REQUEST)) # type: ignore 98 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 99 | assert not server.writer.is_closed # type: ignore 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_http1_keep_alive_pipelining(server: TCPServer) -> None: 104 | await server.reader.send( # type: ignore 105 | b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\nGET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" 106 | ) 107 | await server.writer.receive() # type: ignore 108 | await asyncio.sleep(2 * KEEP_ALIVE_TIMEOUT) 109 | assert not server.writer.is_closed # type: ignore 110 | -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import time 6 | 7 | import pytest 8 | 9 | from hypercorn.config import Config 10 | from hypercorn.logging import AccessLogAtoms, Logger 11 | from hypercorn.typing import HTTPScope, ResponseSummary 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "target, expected_name, expected_handler_type", 16 | [ 17 | ("-", "hypercorn.access", logging.StreamHandler), 18 | ("/tmp/path", "hypercorn.access", logging.FileHandler), 19 | (logging.getLogger("test_special"), "test_special", None), 20 | (None, None, None), 21 | ], 22 | ) 23 | def test_access_logger_init( 24 | target: logging.Logger | str | None, 25 | expected_name: str | None, 26 | expected_handler_type: type[logging.Handler] | None, 27 | ) -> None: 28 | config = Config() 29 | config.accesslog = target 30 | config.access_log_format = "%h" 31 | logger = Logger(config) 32 | assert logger.access_log_format == "%h" 33 | assert logger.getEffectiveLevel() == logging.INFO 34 | if target is None: 35 | assert logger.access_logger is None 36 | elif expected_name is None: 37 | assert logger.access_logger.handlers == [] 38 | else: 39 | assert logger.access_logger.name == expected_name 40 | if expected_handler_type is None: 41 | assert logger.access_logger.handlers == [] 42 | else: 43 | assert isinstance(logger.access_logger.handlers[0], expected_handler_type) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "level, expected", 48 | [ 49 | (logging.getLevelName(level_name), level_name) 50 | for level_name in range(logging.DEBUG, logging.CRITICAL + 1, 10) 51 | ], 52 | ) 53 | def test_loglevel_option(level: str | None, expected: int) -> None: 54 | config = Config() 55 | config.loglevel = level 56 | logger = Logger(config) 57 | assert logger.error_logger.getEffectiveLevel() == expected 58 | 59 | 60 | @pytest.fixture(name="response") 61 | def _response_scope() -> dict: 62 | return {"status": 200, "headers": [(b"Content-Length", b"5"), (b"X-Hypercorn", b"Hypercorn")]} 63 | 64 | 65 | def test_access_log_standard_atoms(http_scope: HTTPScope, response: ResponseSummary) -> None: 66 | atoms = AccessLogAtoms(http_scope, response, 0.000_023) 67 | assert atoms["h"] == "127.0.0.1:80" 68 | assert atoms["l"] == "-" 69 | assert time.strptime(atoms["t"], "[%d/%b/%Y:%H:%M:%S %z]") 70 | assert int(atoms["s"]) == 200 71 | assert atoms["m"] == "GET" 72 | assert atoms["U"] == "/" 73 | assert atoms["q"] == "a=b" 74 | assert atoms["H"] == "2" 75 | assert int(atoms["b"]) == 5 76 | assert int(atoms["B"]) == 5 77 | assert atoms["f"] == "hypercorn" 78 | assert atoms["a"] == "Hypercorn" 79 | assert atoms["p"] == f"<{os.getpid()}>" 80 | assert atoms["not-atom"] == "-" 81 | assert int(atoms["T"]) == 0 82 | assert int(atoms["D"]) == 23 83 | assert atoms["L"] == "0.000023" 84 | assert atoms["r"] == "GET / 2" 85 | assert atoms["R"] == "GET /?a=b 2" 86 | assert atoms["Uq"] == "/?a=b" 87 | assert atoms["st"] == "OK" 88 | 89 | 90 | def test_access_log_header_atoms(http_scope: HTTPScope, response: ResponseSummary) -> None: 91 | atoms = AccessLogAtoms(http_scope, response, 0) 92 | assert atoms["{X-Hypercorn}i"] == "Hypercorn" 93 | assert atoms["{X-HYPERCORN}i"] == "Hypercorn" 94 | assert atoms["{not-atom}i"] == "-" 95 | assert atoms["{X-Hypercorn}o"] == "Hypercorn" 96 | assert atoms["{X-HYPERCORN}o"] == "Hypercorn" 97 | 98 | 99 | def test_access_no_log_header_atoms(http_scope: HTTPScope) -> None: 100 | atoms = AccessLogAtoms(http_scope, {"status": 200, "headers": []}, 0) 101 | assert atoms["{X-Hypercorn}i"] == "Hypercorn" 102 | assert atoms["{X-HYPERCORN}i"] == "Hypercorn" 103 | assert atoms["{not-atom}i"] == "-" 104 | assert not any(key.startswith("{") and key.endswith("}o") for key in atoms.keys()) 105 | 106 | 107 | def test_access_log_environ_atoms(http_scope: HTTPScope, response: ResponseSummary) -> None: 108 | os.environ["Random"] = "Environ" 109 | atoms = AccessLogAtoms(http_scope, response, 0) 110 | assert atoms["{random}e"] == "Environ" 111 | 112 | 113 | def test_nonstandard_status_code(http_scope: HTTPScope) -> None: 114 | atoms = AccessLogAtoms(http_scope, {"status": 441, "headers": []}, 0) 115 | assert atoms["st"] == "" 116 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from copy import deepcopy 5 | from json import dumps 6 | from socket import AF_INET 7 | from typing import cast 8 | 9 | from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope, WWWScope 10 | 11 | SANITY_BODY = b"Hello Hypercorn" 12 | 13 | 14 | class MockSocket: 15 | family = AF_INET 16 | 17 | def getsockname(self) -> tuple[str, int]: 18 | return ("162.1.1.1", 80) 19 | 20 | def getpeername(self) -> tuple[str, int]: 21 | return ("127.0.0.1", 80) 22 | 23 | 24 | async def empty_framework(scope: Scope, receive: Callable, send: Callable) -> None: 25 | pass 26 | 27 | 28 | class SlowLifespanFramework: 29 | def __init__(self, delay: float, sleep: Callable) -> None: 30 | self.delay = delay 31 | self.sleep = sleep 32 | 33 | async def __call__( 34 | self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 35 | ) -> None: 36 | await self.sleep(self.delay) 37 | 38 | 39 | async def echo_framework( 40 | input_scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 41 | ) -> None: 42 | input_scope = cast(WWWScope, input_scope) 43 | scope = deepcopy(input_scope) 44 | scope["query_string"] = scope["query_string"].decode() # type: ignore 45 | scope["raw_path"] = scope["raw_path"].decode() # type: ignore 46 | scope["headers"] = [ 47 | (name.decode(), value.decode()) for name, value in scope["headers"] # type: ignore 48 | ] 49 | 50 | body = bytearray() 51 | while True: 52 | event = await receive() 53 | if event["type"] in {"http.disconnect", "websocket.disconnect"}: 54 | break 55 | elif event["type"] == "http.request": 56 | body.extend(event.get("body", b"")) 57 | if not event.get("more_body", False): 58 | response = dumps({"scope": scope, "request_body": body.decode()}).encode() 59 | content_length = len(response) 60 | await send( 61 | { 62 | "type": "http.response.start", 63 | "status": 200, 64 | "headers": [(b"content-length", str(content_length).encode())], 65 | } 66 | ) 67 | await send({"type": "http.response.body", "body": response, "more_body": False}) 68 | break 69 | elif event["type"] == "websocket.connect": 70 | await send({"type": "websocket.accept"}) # type: ignore 71 | elif event["type"] == "websocket.receive": 72 | await send({"type": "websocket.send", "text": event["text"], "bytes": event["bytes"]}) 73 | 74 | 75 | async def sanity_framework( 76 | scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable 77 | ) -> None: 78 | body = b"" 79 | if scope["type"] == "websocket": 80 | await send({"type": "websocket.accept"}) # type: ignore 81 | 82 | while True: 83 | event = await receive() 84 | if event["type"] in {"http.disconnect", "websocket.disconnect"}: 85 | break 86 | elif event["type"] == "lifespan.startup": 87 | assert "state" in scope 88 | await send({"type": "lifspan.startup.complete"}) # type: ignore 89 | elif event["type"] == "lifespan.shutdown": 90 | await send({"type": "lifspan.shutdown.complete"}) # type: ignore 91 | elif event["type"] == "http.request" and event.get("more_body", False): 92 | body += event["body"] 93 | elif event["type"] == "http.request" and not event.get("more_body", False): 94 | body += event["body"] 95 | assert body == SANITY_BODY 96 | response = b"Hello & Goodbye" 97 | content_length = len(response) 98 | await send( 99 | { 100 | "type": "http.response.start", 101 | "status": 200, 102 | "headers": [(b"content-length", str(content_length).encode())], 103 | } 104 | ) 105 | await send({"type": "http.response.body", "body": response, "more_body": False}) 106 | break 107 | elif event["type"] == "websocket.receive": 108 | assert event["bytes"] == SANITY_BODY 109 | await send({"type": "websocket.send", "text": "Hello & Goodbye"}) # type: ignore 110 | -------------------------------------------------------------------------------- /tests/trio/test_keep_alive.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable, Generator 4 | from typing import cast 5 | 6 | import h11 7 | import pytest 8 | import trio 9 | 10 | from hypercorn.app_wrappers import ASGIWrapper 11 | from hypercorn.config import Config 12 | from hypercorn.trio.tcp_server import TCPServer 13 | from hypercorn.trio.worker_context import WorkerContext 14 | from hypercorn.typing import ASGIReceiveEvent, ASGISendEvent, Scope 15 | from ..helpers import MockSocket 16 | 17 | try: 18 | from typing import TypeAlias 19 | except ImportError: 20 | from typing import TypeAlias 21 | 22 | 23 | KEEP_ALIVE_TIMEOUT = 0.01 24 | REQUEST = h11.Request(method="GET", target="/", headers=[(b"host", b"hypercorn")]) 25 | 26 | ClientStream: TypeAlias = trio.StapledStream[ 27 | trio.testing.MemorySendStream, trio.testing.MemoryReceiveStream 28 | ] 29 | 30 | 31 | async def slow_framework( 32 | scope: Scope, 33 | receive: Callable[[], Awaitable[ASGIReceiveEvent]], 34 | send: Callable[[ASGISendEvent], Awaitable[None]], 35 | ) -> None: 36 | while True: 37 | event = await receive() 38 | if event["type"] == "http.disconnect": 39 | break 40 | elif event["type"] == "lifespan.startup": 41 | await send({"type": "lifespan.startup.complete"}) 42 | elif event["type"] == "lifespan.shutdown": 43 | await send({"type": "lifespan.shutdown.complete"}) 44 | elif event["type"] == "http.request" and not event.get("more_body", False): 45 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 46 | await send( 47 | { 48 | "type": "http.response.start", 49 | "status": 200, 50 | "headers": [(b"content-length", b"0")], 51 | } 52 | ) 53 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 54 | break 55 | 56 | 57 | @pytest.fixture(name="client_stream", scope="function") 58 | def _client_stream( 59 | nursery: trio.Nursery, 60 | ) -> Generator[ClientStream]: 61 | config = Config() 62 | config.keep_alive_timeout = KEEP_ALIVE_TIMEOUT 63 | client_stream, server_stream = trio.testing.memory_stream_pair() 64 | server_stream = cast("trio.SSLStream[trio.SocketStream]", server_stream) 65 | server_stream.socket = MockSocket() 66 | server = TCPServer(ASGIWrapper(slow_framework), config, WorkerContext(None), {}, server_stream) 67 | nursery.start_soon(server.run) 68 | yield client_stream 69 | 70 | 71 | @pytest.mark.trio 72 | async def test_http1_keep_alive_pre_request(client_stream: ClientStream) -> None: 73 | await client_stream.send_all(b"GET") 74 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 75 | # Only way to confirm closure is to invoke an error 76 | with pytest.raises(trio.BrokenResourceError): 77 | await client_stream.send_all(b"a") 78 | 79 | 80 | @pytest.mark.trio 81 | async def test_http1_keep_alive_during( 82 | client_stream: ClientStream, 83 | ) -> None: 84 | client = h11.Connection(h11.CLIENT) 85 | await client_stream.send_all(client.send(REQUEST)) 86 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 87 | # Key is that this doesn't error 88 | await client_stream.send_all(client.send(h11.EndOfMessage())) 89 | 90 | 91 | @pytest.mark.trio 92 | async def test_http1_keep_alive( 93 | client_stream: ClientStream, 94 | ) -> None: 95 | client = h11.Connection(h11.CLIENT) 96 | await client_stream.send_all(client.send(REQUEST)) 97 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 98 | await client_stream.send_all(client.send(h11.EndOfMessage())) 99 | while True: 100 | event = client.next_event() 101 | if event == h11.NEED_DATA: 102 | data = await client_stream.receive_some(2**16) 103 | client.receive_data(data) 104 | elif isinstance(event, h11.EndOfMessage): 105 | break 106 | client.start_next_cycle() 107 | await client_stream.send_all(client.send(REQUEST)) 108 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 109 | # Key is that this doesn't error 110 | await client_stream.send_all(client.send(h11.EndOfMessage())) 111 | 112 | 113 | @pytest.mark.trio 114 | async def test_http1_keep_alive_pipelining( 115 | client_stream: ClientStream, 116 | ) -> None: 117 | await client_stream.send_all( 118 | b"GET / HTTP/1.1\r\nHost: hypercorn\r\n\r\nGET / HTTP/1.1\r\nHost: hypercorn\r\n\r\n" 119 | ) 120 | await client_stream.receive_some(2**16) 121 | await trio.sleep(2 * KEEP_ALIVE_TIMEOUT) 122 | await client_stream.send_all(b"") 123 | -------------------------------------------------------------------------------- /src/hypercorn/middleware/dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Callable 5 | from functools import partial 6 | 7 | from ..asyncio.task_group import TaskGroup 8 | from ..typing import ASGIFramework, ASGIReceiveEvent, Scope 9 | 10 | MAX_QUEUE_SIZE = 10 11 | 12 | 13 | class _DispatcherMiddleware: 14 | def __init__(self, mounts: dict[str, ASGIFramework]) -> None: 15 | self.mounts = mounts 16 | 17 | async def __call__(self, scope: Scope, receive: Callable, send: Callable) -> None: 18 | if scope["type"] == "lifespan": 19 | await self._handle_lifespan(scope, receive, send) 20 | else: 21 | for path, app in self.mounts.items(): 22 | if scope["path"].startswith(path): 23 | local_scope = scope.copy() 24 | local_scope["root_path"] += path 25 | return await app(local_scope, receive, send) 26 | await send( 27 | { 28 | "type": "http.response.start", 29 | "status": 404, 30 | "headers": [(b"content-length", b"0")], 31 | } 32 | ) 33 | await send({"type": "http.response.body"}) 34 | 35 | async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable) -> None: 36 | pass 37 | 38 | 39 | class AsyncioDispatcherMiddleware(_DispatcherMiddleware): 40 | async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable) -> None: 41 | self.app_queues: dict[str, asyncio.Queue] = { 42 | path: asyncio.Queue(MAX_QUEUE_SIZE) for path in self.mounts 43 | } 44 | self.startup_complete = {path: False for path in self.mounts} 45 | self.shutdown_complete = {path: False for path in self.mounts} 46 | 47 | async with TaskGroup(asyncio.get_event_loop()) as task_group: 48 | for path, app in self.mounts.items(): 49 | task_group.spawn( 50 | app, 51 | scope, 52 | self.app_queues[path].get, 53 | partial(self.send, path, send), 54 | ) 55 | 56 | while True: 57 | message = await receive() 58 | for queue in self.app_queues.values(): 59 | await queue.put(message) 60 | if message["type"] == "lifespan.shutdown": 61 | break 62 | 63 | async def send(self, path: str, send: Callable, message: dict) -> None: 64 | if message["type"] == "lifespan.startup.complete": 65 | self.startup_complete[path] = True 66 | if all(self.startup_complete.values()): 67 | await send({"type": "lifespan.startup.complete"}) 68 | elif message["type"] == "lifespan.shutdown.complete": 69 | self.shutdown_complete[path] = True 70 | if all(self.shutdown_complete.values()): 71 | await send({"type": "lifespan.shutdown.complete"}) 72 | 73 | 74 | class TrioDispatcherMiddleware(_DispatcherMiddleware): 75 | async def _handle_lifespan(self, scope: Scope, receive: Callable, send: Callable) -> None: 76 | import trio 77 | 78 | self.app_queues = { 79 | path: trio.open_memory_channel[ASGIReceiveEvent](MAX_QUEUE_SIZE) for path in self.mounts 80 | } 81 | self.startup_complete = {path: False for path in self.mounts} 82 | self.shutdown_complete = {path: False for path in self.mounts} 83 | 84 | async with trio.open_nursery() as nursery: 85 | for path, app in self.mounts.items(): 86 | nursery.start_soon( 87 | app, 88 | scope, 89 | self.app_queues[path][1].receive, 90 | partial(self.send, path, send), 91 | ) 92 | 93 | while True: 94 | message = await receive() 95 | for channels in self.app_queues.values(): 96 | await channels[0].send(message) 97 | if message["type"] == "lifespan.shutdown": 98 | break 99 | 100 | async def send(self, path: str, send: Callable, message: dict) -> None: 101 | if message["type"] == "lifespan.startup.complete": 102 | self.startup_complete[path] = True 103 | if all(self.startup_complete.values()): 104 | await send({"type": "lifespan.startup.complete"}) 105 | elif message["type"] == "lifespan.shutdown.complete": 106 | self.shutdown_complete[path] = True 107 | if all(self.shutdown_complete.values()): 108 | await send({"type": "lifespan.shutdown.complete"}) 109 | 110 | 111 | DispatcherMiddleware = AsyncioDispatcherMiddleware # Remove with version 0.11 112 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import sys 5 | from collections.abc import Callable 6 | from functools import partial 7 | from typing import Any 8 | 9 | from ..config import Config 10 | from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState 11 | from ..utils import LifespanFailureError, LifespanTimeoutError 12 | 13 | if sys.version_info < (3, 11): 14 | from exceptiongroup import BaseExceptionGroup 15 | 16 | 17 | class UnexpectedMessageError(Exception): 18 | pass 19 | 20 | 21 | class Lifespan: 22 | def __init__( 23 | self, 24 | app: AppWrapper, 25 | config: Config, 26 | loop: asyncio.AbstractEventLoop, 27 | lifespan_state: LifespanState, 28 | ) -> None: 29 | self.app = app 30 | self.config = config 31 | self.startup = asyncio.Event() 32 | self.shutdown = asyncio.Event() 33 | self.app_queue: asyncio.Queue = asyncio.Queue(config.max_app_queue_size) 34 | self.supported = True 35 | self.loop = loop 36 | self.state = lifespan_state 37 | 38 | # This mimics the Trio nursery.start task_status and is 39 | # required to ensure the support has been checked before 40 | # waiting on timeouts. 41 | self._started = asyncio.Event() 42 | 43 | async def handle_lifespan(self) -> None: 44 | self._started.set() 45 | scope: LifespanScope = { 46 | "type": "lifespan", 47 | "asgi": {"spec_version": "2.0", "version": "3.0"}, 48 | "state": self.state, 49 | } 50 | 51 | def _call_soon(func: Callable, *args: Any) -> Any: 52 | future = asyncio.run_coroutine_threadsafe(func(*args), self.loop) 53 | return future.result() 54 | 55 | try: 56 | await self.app( 57 | scope, 58 | self.asgi_receive, 59 | self.asgi_send, 60 | partial(self.loop.run_in_executor, None), 61 | _call_soon, 62 | ) 63 | except (LifespanFailureError, asyncio.CancelledError): 64 | raise 65 | except (BaseExceptionGroup, Exception) as error: 66 | if isinstance(error, BaseExceptionGroup): 67 | reraise_error = error.subgroup((LifespanFailureError, asyncio.CancelledError)) 68 | if reraise_error is not None: 69 | raise reraise_error 70 | 71 | self.supported = False 72 | if not self.startup.is_set(): 73 | await self.config.log.warning( 74 | "ASGI Framework Lifespan error, continuing without Lifespan support" 75 | ) 76 | elif not self.shutdown.is_set(): 77 | await self.config.log.exception( 78 | "ASGI Framework Lifespan error, shutdown without Lifespan support" 79 | ) 80 | else: 81 | await self.config.log.exception("ASGI Framework Lifespan errored after shutdown.") 82 | finally: 83 | self.startup.set() 84 | self.shutdown.set() 85 | 86 | async def wait_for_startup(self) -> None: 87 | await self._started.wait() 88 | if not self.supported: 89 | return 90 | 91 | await self.app_queue.put({"type": "lifespan.startup"}) 92 | try: 93 | await asyncio.wait_for(self.startup.wait(), timeout=self.config.startup_timeout) 94 | except asyncio.TimeoutError as error: 95 | raise LifespanTimeoutError("startup") from error 96 | 97 | async def wait_for_shutdown(self) -> None: 98 | await self._started.wait() 99 | if not self.supported: 100 | return 101 | 102 | await self.app_queue.put({"type": "lifespan.shutdown"}) 103 | try: 104 | await asyncio.wait_for(self.shutdown.wait(), timeout=self.config.shutdown_timeout) 105 | except asyncio.TimeoutError as error: 106 | raise LifespanTimeoutError("shutdown") from error 107 | 108 | async def asgi_receive(self) -> ASGIReceiveEvent: 109 | return await self.app_queue.get() 110 | 111 | async def asgi_send(self, message: ASGISendEvent) -> None: 112 | if message["type"] == "lifespan.startup.complete": 113 | self.startup.set() 114 | elif message["type"] == "lifespan.shutdown.complete": 115 | self.shutdown.set() 116 | elif message["type"] == "lifespan.startup.failed": 117 | self.startup.set() 118 | raise LifespanFailureError("startup", message.get("message", "")) 119 | elif message["type"] == "lifespan.shutdown.failed": 120 | self.shutdown.set() 121 | raise LifespanFailureError("shutdown", message.get("message", "")) 122 | else: 123 | raise UnexpectedMessageError(message["type"]) 124 | -------------------------------------------------------------------------------- /src/hypercorn/asyncio/tcp_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from collections.abc import Generator 5 | from ssl import SSLError 6 | from typing import Any 7 | 8 | from .task_group import TaskGroup 9 | from .worker_context import AsyncioSingleTask, WorkerContext 10 | from ..config import Config 11 | from ..events import Closed, Event, RawData, Updated 12 | from ..protocol import ProtocolWrapper 13 | from ..typing import AppWrapper, ConnectionState, LifespanState 14 | from ..utils import parse_socket_addr 15 | 16 | MAX_RECV = 2**16 17 | 18 | 19 | class TCPServer: 20 | def __init__( 21 | self, 22 | app: AppWrapper, 23 | loop: asyncio.AbstractEventLoop, 24 | config: Config, 25 | context: WorkerContext, 26 | state: LifespanState, 27 | reader: asyncio.StreamReader, 28 | writer: asyncio.StreamWriter, 29 | ) -> None: 30 | self.app = app 31 | self.config = config 32 | self.context = context 33 | self.loop = loop 34 | self.protocol: ProtocolWrapper 35 | self.reader = reader 36 | self.writer = writer 37 | self.send_lock = asyncio.Lock() 38 | self.state = state 39 | self.idle_task = AsyncioSingleTask() 40 | 41 | def __await__(self) -> Generator[Any]: 42 | return self.run().__await__() 43 | 44 | async def run(self) -> None: 45 | socket = self.writer.get_extra_info("socket") 46 | try: 47 | client = parse_socket_addr(socket.family, socket.getpeername()) 48 | server = parse_socket_addr(socket.family, socket.getsockname()) 49 | ssl_object = self.writer.get_extra_info("ssl_object") 50 | if ssl_object is not None: 51 | ssl = True 52 | alpn_protocol = ssl_object.selected_alpn_protocol() 53 | else: 54 | ssl = False 55 | alpn_protocol = "http/1.1" 56 | 57 | async with TaskGroup(self.loop) as task_group: 58 | self._task_group = task_group 59 | self.protocol = ProtocolWrapper( 60 | self.app, 61 | self.config, 62 | self.context, 63 | task_group, 64 | ConnectionState(self.state), 65 | ssl, 66 | client, 67 | server, 68 | self.protocol_send, 69 | alpn_protocol, 70 | ) 71 | await self.protocol.initiate() 72 | await self.idle_task.restart(task_group, self._idle_timeout) 73 | await self._read_data() 74 | except OSError: 75 | pass 76 | finally: 77 | await self._close() 78 | 79 | async def protocol_send(self, event: Event) -> None: 80 | if isinstance(event, RawData): 81 | async with self.send_lock: 82 | try: 83 | self.writer.write(event.data) 84 | await self.writer.drain() 85 | except (ConnectionError, RuntimeError): 86 | await self.protocol.handle(Closed()) 87 | elif isinstance(event, Closed): 88 | await self._close() 89 | elif isinstance(event, Updated): 90 | if event.idle: 91 | await self.idle_task.restart(self._task_group, self._idle_timeout) 92 | else: 93 | await self.idle_task.stop() 94 | 95 | async def _read_data(self) -> None: 96 | while not self.reader.at_eof(): 97 | try: 98 | data = await asyncio.wait_for(self.reader.read(MAX_RECV), self.config.read_timeout) 99 | except ( 100 | ConnectionError, 101 | OSError, 102 | asyncio.TimeoutError, 103 | TimeoutError, 104 | SSLError, 105 | ): 106 | break 107 | else: 108 | await self.protocol.handle(RawData(data)) 109 | 110 | await self.protocol.handle(Closed()) 111 | 112 | async def _close(self) -> None: 113 | try: 114 | self.writer.write_eof() 115 | except (NotImplementedError, OSError, RuntimeError): 116 | pass # Likely SSL connection 117 | 118 | try: 119 | self.writer.close() 120 | await self.writer.wait_closed() 121 | except ( 122 | BrokenPipeError, 123 | ConnectionAbortedError, 124 | ConnectionResetError, 125 | RuntimeError, 126 | asyncio.CancelledError, 127 | ): 128 | pass # Already closed 129 | finally: 130 | await self.idle_task.stop() 131 | 132 | async def _initiate_server_close(self) -> None: 133 | await self.protocol.handle(Closed()) 134 | self.writer.close() 135 | 136 | async def _idle_timeout(self) -> None: 137 | try: 138 | await asyncio.wait_for(self.context.terminated.wait(), self.config.keep_alive_timeout) 139 | except asyncio.TimeoutError: 140 | pass 141 | await asyncio.shield(self._initiate_server_close()) 142 | -------------------------------------------------------------------------------- /src/hypercorn/trio/tcp_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from math import inf 5 | from typing import Any 6 | 7 | import trio 8 | 9 | from .task_group import TaskGroup 10 | from .worker_context import TrioSingleTask, WorkerContext 11 | from ..config import Config 12 | from ..events import Closed, Event, RawData, Updated 13 | from ..protocol import ProtocolWrapper 14 | from ..typing import AppWrapper, ConnectionState, LifespanState 15 | from ..utils import parse_socket_addr 16 | 17 | MAX_RECV = 2**16 18 | 19 | 20 | class TCPServer: 21 | def __init__( 22 | self, 23 | app: AppWrapper, 24 | config: Config, 25 | context: WorkerContext, 26 | state: LifespanState, 27 | stream: trio.SSLStream[trio.SocketStream], 28 | ) -> None: 29 | self.app = app 30 | self.config = config 31 | self.context = context 32 | self.protocol: ProtocolWrapper 33 | self.send_lock = trio.Lock() 34 | self.idle_task = TrioSingleTask() 35 | self.stream = stream 36 | self.state = state 37 | 38 | def __await__(self) -> Generator[Any]: 39 | return self.run().__await__() 40 | 41 | async def run(self) -> None: 42 | try: 43 | try: 44 | with trio.fail_after(self.config.ssl_handshake_timeout): 45 | await self.stream.do_handshake() 46 | except (trio.BrokenResourceError, trio.TooSlowError): 47 | return # Handshake failed 48 | alpn_protocol = self.stream.selected_alpn_protocol() 49 | socket = self.stream.transport_stream.socket 50 | ssl = True 51 | except AttributeError: # Not SSL 52 | alpn_protocol = "http/1.1" 53 | socket = self.stream.socket 54 | ssl = False 55 | 56 | try: 57 | client = parse_socket_addr(socket.family, socket.getpeername()) 58 | server = parse_socket_addr(socket.family, socket.getsockname()) 59 | 60 | async with TaskGroup() as task_group: 61 | self._task_group = task_group 62 | self.protocol = ProtocolWrapper( 63 | self.app, 64 | self.config, 65 | self.context, 66 | task_group, 67 | ConnectionState(self.state), 68 | ssl, 69 | client, 70 | server, 71 | self.protocol_send, 72 | alpn_protocol, 73 | ) 74 | await self.protocol.initiate() 75 | await self.idle_task.restart(self._task_group, self._idle_timeout) 76 | await self._read_data() 77 | except OSError: 78 | pass 79 | finally: 80 | await self._close() 81 | 82 | async def protocol_send(self, event: Event) -> None: 83 | if isinstance(event, RawData): 84 | async with self.send_lock: 85 | try: 86 | with trio.CancelScope() as cancel_scope: 87 | cancel_scope.shield = True 88 | await self.stream.send_all(event.data) 89 | except (trio.BrokenResourceError, trio.ClosedResourceError): 90 | await self.protocol.handle(Closed()) 91 | elif isinstance(event, Closed): 92 | await self._close() 93 | await self.protocol.handle(Closed()) 94 | elif isinstance(event, Updated): 95 | if event.idle: 96 | await self.idle_task.restart(self._task_group, self._idle_timeout) 97 | else: 98 | await self.idle_task.stop() 99 | 100 | async def _read_data(self) -> None: 101 | while True: 102 | try: 103 | with trio.fail_after(self.config.read_timeout or inf): 104 | data = await self.stream.receive_some(MAX_RECV) 105 | except ( 106 | trio.ClosedResourceError, 107 | trio.BrokenResourceError, 108 | trio.TooSlowError, 109 | ): 110 | break 111 | else: 112 | await self.protocol.handle(RawData(bytes(data))) 113 | if data == b"": 114 | break 115 | await self.protocol.handle(Closed()) 116 | 117 | async def _close(self) -> None: 118 | try: 119 | await self.stream.send_eof() 120 | except ( 121 | trio.BrokenResourceError, 122 | AttributeError, 123 | trio.BusyResourceError, 124 | trio.ClosedResourceError, 125 | ): 126 | # They're already gone, nothing to do 127 | # Or it is a SSL stream 128 | pass 129 | await self.stream.aclose() 130 | 131 | async def _idle_timeout(self) -> None: 132 | with trio.move_on_after(self.config.keep_alive_timeout): 133 | await self.context.terminated.wait() 134 | 135 | with trio.CancelScope(shield=True): 136 | await self._initiate_server_close() 137 | 138 | async def _initiate_server_close(self) -> None: 139 | await self.protocol.handle(Closed()) 140 | await self.stream.aclose() 141 | -------------------------------------------------------------------------------- /src/hypercorn/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import signal 5 | import time 6 | from multiprocessing import get_context 7 | from multiprocessing.connection import wait 8 | from multiprocessing.context import BaseContext 9 | from multiprocessing.process import BaseProcess 10 | from multiprocessing.synchronize import Event as EventType 11 | from pickle import PicklingError 12 | from typing import Any 13 | 14 | from .config import Config, Sockets 15 | from .typing import WorkerFunc 16 | from .utils import check_for_updates, files_to_watch, load_application, write_pid_file 17 | 18 | 19 | def run(config: Config) -> int: 20 | if config.pid_path is not None: 21 | write_pid_file(config.pid_path) 22 | 23 | worker_func: WorkerFunc 24 | if config.worker_class == "asyncio": 25 | from .asyncio.run import asyncio_worker 26 | 27 | worker_func = asyncio_worker 28 | elif config.worker_class == "uvloop": 29 | from .asyncio.run import uvloop_worker 30 | 31 | worker_func = uvloop_worker 32 | elif config.worker_class == "trio": 33 | from .trio.run import trio_worker 34 | 35 | worker_func = trio_worker 36 | else: 37 | raise ValueError(f"No worker of class {config.worker_class} exists") 38 | 39 | sockets = config.create_sockets() 40 | 41 | if config.use_reloader and config.workers == 0: 42 | raise RuntimeError("Cannot reload without workers") 43 | 44 | exitcode = 0 45 | if config.workers == 0: 46 | worker_func(config, sockets) 47 | else: 48 | if config.use_reloader: 49 | # Load the application so that the correct paths are checked for 50 | # changes, but only when the reloader is being used. 51 | load_application(config.application_path, config.wsgi_max_body_size) 52 | 53 | ctx = get_context("spawn") 54 | 55 | active = True 56 | shutdown_event = ctx.Event() 57 | 58 | def reload(*args: Any) -> None: 59 | shutdown_event.set() 60 | for process in processes: 61 | process.join() 62 | shutdown_event.clear() 63 | 64 | def shutdown(*args: Any) -> None: 65 | nonlocal active 66 | shutdown_event.set() 67 | active = False 68 | 69 | processes: list[BaseProcess] = [] 70 | while active: 71 | # Ignore SIGINT before creating the processes, so that they 72 | # inherit the signal handling. This means that the shutdown 73 | # function controls the shutdown. 74 | signal.signal(signal.SIGINT, signal.SIG_IGN) 75 | 76 | _populate(processes, config, worker_func, sockets, shutdown_event, ctx) 77 | 78 | for signal_name in {"SIGINT", "SIGTERM", "SIGBREAK"}: 79 | if hasattr(signal, signal_name): 80 | signal.signal(getattr(signal, signal_name), shutdown) 81 | 82 | if hasattr(signal, "SIGHUP"): 83 | signal.signal(signal.SIGHUP, reload) 84 | 85 | if config.use_reloader: 86 | files = files_to_watch() 87 | while True: 88 | finished = wait((process.sentinel for process in processes), timeout=1) 89 | updated = check_for_updates(files) 90 | if updated: 91 | reload() 92 | break 93 | if len(finished) > 0: 94 | break 95 | else: 96 | wait(process.sentinel for process in processes) 97 | 98 | exitcode = _join_exited(processes) 99 | if exitcode != 0: 100 | shutdown_event.set() 101 | active = False 102 | 103 | for process in processes: 104 | process.terminate() 105 | 106 | exitcode = _join_exited(processes) if exitcode != 0 else exitcode 107 | 108 | for sock in sockets.secure_sockets: 109 | sock.close() 110 | 111 | for sock in sockets.insecure_sockets: 112 | sock.close() 113 | 114 | return exitcode 115 | 116 | 117 | def _populate( 118 | processes: list[BaseProcess], 119 | config: Config, 120 | worker_func: WorkerFunc, 121 | sockets: Sockets, 122 | shutdown_event: EventType, 123 | ctx: BaseContext, 124 | ) -> None: 125 | for _ in range(config.workers - len(processes)): 126 | process = ctx.Process( # type: ignore 127 | target=worker_func, 128 | kwargs={"config": config, "shutdown_event": shutdown_event, "sockets": sockets}, 129 | ) 130 | process.daemon = config.daemon 131 | try: 132 | process.start() 133 | except PicklingError as error: 134 | raise RuntimeError( 135 | "Cannot pickle the config, see https://docs.python.org/3/library/pickle.html#pickle-picklable" # noqa: E501 136 | ) from error 137 | processes.append(process) 138 | if platform.system() == "Windows": 139 | time.sleep(0.1) 140 | 141 | 142 | def _join_exited(processes: list[BaseProcess]) -> int: 143 | exitcode = 0 144 | for index in reversed(range(len(processes))): 145 | worker = processes[index] 146 | if worker.exitcode is not None: 147 | worker.join() 148 | exitcode = worker.exitcode if exitcode == 0 else exitcode 149 | del processes[index] 150 | 151 | return exitcode 152 | -------------------------------------------------------------------------------- /tests/middleware/test_http_to_https.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from hypercorn.middleware import HTTPToHTTPSRedirectMiddleware 6 | from hypercorn.typing import ConnectionState, HTTPScope, WebsocketScope 7 | from ..helpers import empty_framework 8 | 9 | 10 | @pytest.mark.asyncio 11 | @pytest.mark.parametrize("raw_path", [b"/abc", b"/abc%3C"]) 12 | async def test_http_to_https_redirect_middleware_http(raw_path: bytes) -> None: 13 | app = HTTPToHTTPSRedirectMiddleware(empty_framework, "localhost") 14 | sent_events = [] 15 | 16 | async def send(message: dict) -> None: 17 | sent_events.append(message) 18 | 19 | scope: HTTPScope = { 20 | "type": "http", 21 | "asgi": {}, 22 | "http_version": "2", 23 | "method": "GET", 24 | "scheme": "http", 25 | "path": raw_path.decode(), 26 | "raw_path": raw_path, 27 | "query_string": b"a=b", 28 | "root_path": "", 29 | "headers": [], 30 | "client": ("127.0.0.1", 80), 31 | "server": None, 32 | "extensions": {}, 33 | "state": ConnectionState({}), 34 | } 35 | 36 | await app(scope, None, send) 37 | 38 | assert sent_events == [ 39 | { 40 | "type": "http.response.start", 41 | "status": 307, 42 | "headers": [(b"location", b"https://localhost%s?a=b" % raw_path)], 43 | }, 44 | {"type": "http.response.body"}, 45 | ] 46 | 47 | 48 | @pytest.mark.asyncio 49 | @pytest.mark.parametrize("raw_path", [b"/abc", b"/abc%3C"]) 50 | async def test_http_to_https_redirect_middleware_websocket(raw_path: bytes) -> None: 51 | app = HTTPToHTTPSRedirectMiddleware(empty_framework, "localhost") 52 | sent_events = [] 53 | 54 | async def send(message: dict) -> None: 55 | sent_events.append(message) 56 | 57 | scope: WebsocketScope = { 58 | "type": "websocket", 59 | "asgi": {}, 60 | "http_version": "1.1", 61 | "scheme": "ws", 62 | "path": raw_path.decode(), 63 | "raw_path": raw_path, 64 | "query_string": b"a=b", 65 | "root_path": "", 66 | "headers": [], 67 | "client": None, 68 | "server": None, 69 | "subprotocols": [], 70 | "extensions": {"websocket.http.response": {}}, 71 | "state": ConnectionState({}), 72 | } 73 | await app(scope, None, send) 74 | 75 | assert sent_events == [ 76 | { 77 | "type": "websocket.http.response.start", 78 | "status": 307, 79 | "headers": [(b"location", b"wss://localhost%s?a=b" % raw_path)], 80 | }, 81 | {"type": "websocket.http.response.body"}, 82 | ] 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_http_to_https_redirect_middleware_websocket_http2() -> None: 87 | app = HTTPToHTTPSRedirectMiddleware(empty_framework, "localhost") 88 | sent_events = [] 89 | 90 | async def send(message: dict) -> None: 91 | sent_events.append(message) 92 | 93 | scope: WebsocketScope = { 94 | "type": "websocket", 95 | "asgi": {}, 96 | "http_version": "2", 97 | "scheme": "ws", 98 | "path": "/abc", 99 | "raw_path": b"/abc", 100 | "query_string": b"a=b", 101 | "root_path": "", 102 | "headers": [], 103 | "client": None, 104 | "server": None, 105 | "subprotocols": [], 106 | "extensions": {"websocket.http.response": {}}, 107 | "state": ConnectionState({}), 108 | } 109 | await app(scope, None, send) 110 | 111 | assert sent_events == [ 112 | { 113 | "type": "websocket.http.response.start", 114 | "status": 307, 115 | "headers": [(b"location", b"https://localhost/abc?a=b")], 116 | }, 117 | {"type": "websocket.http.response.body"}, 118 | ] 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_http_to_https_redirect_middleware_websocket_no_rejection() -> None: 123 | app = HTTPToHTTPSRedirectMiddleware(empty_framework, "localhost") 124 | sent_events = [] 125 | 126 | async def send(message: dict) -> None: 127 | sent_events.append(message) 128 | 129 | scope: WebsocketScope = { 130 | "type": "websocket", 131 | "asgi": {}, 132 | "http_version": "2", 133 | "scheme": "ws", 134 | "path": "/abc", 135 | "raw_path": b"/abc", 136 | "query_string": b"a=b", 137 | "root_path": "", 138 | "headers": [], 139 | "client": None, 140 | "server": None, 141 | "subprotocols": [], 142 | "extensions": {}, 143 | "state": ConnectionState({}), 144 | } 145 | await app(scope, None, send) 146 | 147 | assert sent_events == [{"type": "websocket.close"}] 148 | 149 | 150 | def test_http_to_https_redirect_new_url_header() -> None: 151 | app = HTTPToHTTPSRedirectMiddleware(empty_framework, None) 152 | new_url = app._new_url( 153 | "https", 154 | { 155 | "http_version": "1.1", 156 | "asgi": {}, 157 | "method": "GET", 158 | "headers": [(b"host", b"localhost")], 159 | "path": "/", 160 | "root_path": "", 161 | "query_string": b"", 162 | "raw_path": b"/", 163 | "scheme": "http", 164 | "type": "http", 165 | "client": None, 166 | "server": None, 167 | "extensions": {}, 168 | "state": ConnectionState({}), 169 | }, 170 | ) 171 | assert new_url == "https://localhost/" 172 | -------------------------------------------------------------------------------- /artwork/logo_small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
-------------------------------------------------------------------------------- /src/hypercorn/trio/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import Awaitable, Callable 5 | from functools import partial 6 | from multiprocessing.synchronize import Event as EventType 7 | from random import randint 8 | 9 | import trio 10 | 11 | from .lifespan import Lifespan 12 | from .statsd import StatsdLogger 13 | from .tcp_server import TCPServer 14 | from .udp_server import UDPServer 15 | from .worker_context import WorkerContext 16 | from ..config import Config, Sockets 17 | from ..typing import AppWrapper, ConnectionState, LifespanState 18 | from ..utils import ( 19 | check_multiprocess_shutdown_event, 20 | load_application, 21 | raise_shutdown, 22 | repr_socket_addr, 23 | ShutdownError, 24 | ) 25 | 26 | if sys.version_info < (3, 11): 27 | from exceptiongroup import BaseExceptionGroup 28 | 29 | 30 | async def worker_serve( 31 | app: AppWrapper, 32 | config: Config, 33 | *, 34 | sockets: Sockets | None = None, 35 | shutdown_trigger: Callable[..., Awaitable[None]] | None = None, 36 | task_status: trio.TaskStatus[list[str]] = trio.TASK_STATUS_IGNORED, 37 | ) -> None: 38 | config.set_statsd_logger_class(StatsdLogger) 39 | 40 | lifespan_state: LifespanState = {} 41 | lifespan = Lifespan(app, config, lifespan_state) 42 | max_requests = None 43 | if config.max_requests is not None: 44 | max_requests = config.max_requests + randint(0, config.max_requests_jitter) 45 | context = WorkerContext(max_requests) 46 | 47 | async with trio.open_nursery() as lifespan_nursery: 48 | await lifespan_nursery.start(lifespan.handle_lifespan) 49 | await lifespan.wait_for_startup() 50 | 51 | async with trio.open_nursery() as server_nursery: 52 | if sockets is None: 53 | sockets = config.create_sockets() 54 | for sock in sockets.secure_sockets: 55 | sock.listen(config.backlog) 56 | for sock in sockets.insecure_sockets: 57 | sock.listen(config.backlog) 58 | 59 | ssl_context = config.create_ssl_context() 60 | listeners: list[trio.SSLListener[trio.SocketStream] | trio.SocketListener] = [] 61 | binds = [] 62 | for sock in sockets.secure_sockets: 63 | listeners.append( 64 | trio.SSLListener( 65 | trio.SocketListener(trio.socket.from_stdlib_socket(sock)), 66 | ssl_context, 67 | https_compatible=True, 68 | ) 69 | ) 70 | bind = repr_socket_addr(sock.family, sock.getsockname()) 71 | binds.append(f"https://{bind}") 72 | await config.log.info(f"Running on https://{bind} (CTRL + C to quit)") 73 | 74 | for sock in sockets.insecure_sockets: 75 | listeners.append(trio.SocketListener(trio.socket.from_stdlib_socket(sock))) 76 | bind = repr_socket_addr(sock.family, sock.getsockname()) 77 | binds.append(f"http://{bind}") 78 | await config.log.info(f"Running on http://{bind} (CTRL + C to quit)") 79 | 80 | for sock in sockets.quic_sockets: 81 | await server_nursery.start( 82 | UDPServer( 83 | app, config, context, ConnectionState(lifespan_state.copy()), sock 84 | ).run 85 | ) 86 | bind = repr_socket_addr(sock.family, sock.getsockname()) 87 | await config.log.info(f"Running on https://{bind} (QUIC) (CTRL + C to quit)") 88 | 89 | task_status.started(binds) 90 | try: 91 | async with trio.open_nursery(strict_exception_groups=True) as nursery: 92 | if shutdown_trigger is not None: 93 | nursery.start_soon(raise_shutdown, shutdown_trigger) 94 | nursery.start_soon(raise_shutdown, context.terminate.wait) 95 | 96 | nursery.start_soon( 97 | partial( 98 | trio.serve_listeners, 99 | partial( 100 | TCPServer, 101 | app, 102 | config, 103 | context, 104 | ConnectionState(lifespan_state.copy()), 105 | ), 106 | listeners, 107 | handler_nursery=server_nursery, 108 | ), 109 | ) 110 | 111 | await trio.sleep_forever() 112 | except BaseExceptionGroup as error: 113 | _, other_errors = error.split((ShutdownError, KeyboardInterrupt)) 114 | if other_errors is not None: 115 | raise other_errors 116 | finally: 117 | await context.terminated.set() 118 | server_nursery.cancel_scope.deadline = trio.current_time() + config.graceful_timeout 119 | 120 | await lifespan.wait_for_shutdown() 121 | lifespan_nursery.cancel_scope.cancel() 122 | 123 | 124 | def trio_worker( 125 | config: Config, sockets: Sockets | None = None, shutdown_event: EventType | None = None 126 | ) -> None: 127 | if sockets is not None: 128 | for sock in sockets.secure_sockets: 129 | sock.listen(config.backlog) 130 | for sock in sockets.insecure_sockets: 131 | sock.listen(config.backlog) 132 | app = load_application(config.application_path, config.wsgi_max_body_size) 133 | 134 | shutdown_trigger = None 135 | if shutdown_event is not None: 136 | shutdown_trigger = partial(check_multiprocess_shutdown_event, shutdown_event, trio.sleep) 137 | 138 | trio.run(partial(worker_serve, app, config, sockets=sockets, shutdown_trigger=shutdown_trigger)) 139 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import socket 5 | import ssl 6 | import sys 7 | from unittest.mock import Mock, NonCallableMock 8 | 9 | import pytest 10 | from _pytest.monkeypatch import MonkeyPatch 11 | 12 | import hypercorn.config 13 | from hypercorn.config import Config 14 | 15 | access_log_format = "bob" 16 | h11_max_incomplete_size = 4 17 | 18 | 19 | def _check_standard_config(config: Config) -> None: 20 | assert config.access_log_format == access_log_format 21 | assert config.h11_max_incomplete_size == h11_max_incomplete_size 22 | assert config.bind == ["127.0.0.1:5555"] 23 | 24 | 25 | def test_config_from_pyfile() -> None: 26 | path = os.path.join(os.path.dirname(__file__), "assets/config.py") 27 | config = Config.from_pyfile(path) 28 | _check_standard_config(config) 29 | 30 | 31 | def test_config_from_object() -> None: 32 | sys.path.append(os.path.join(os.path.dirname(__file__))) 33 | 34 | config = Config.from_object("assets.config") 35 | _check_standard_config(config) 36 | 37 | 38 | def test_ssl_config_from_pyfile() -> None: 39 | path = os.path.join(os.path.dirname(__file__), "assets/config_ssl.py") 40 | config = Config.from_pyfile(path) 41 | _check_standard_config(config) 42 | assert config.ssl_enabled 43 | 44 | 45 | def test_config_from_toml() -> None: 46 | path = os.path.join(os.path.dirname(__file__), "assets/config.toml") 47 | config = Config.from_toml(path) 48 | _check_standard_config(config) 49 | 50 | 51 | def test_create_ssl_context() -> None: 52 | path = os.path.join(os.path.dirname(__file__), "assets/config_ssl.py") 53 | config = Config.from_pyfile(path) 54 | context = config.create_ssl_context() 55 | 56 | # NOTE: In earlier versions of python context.options is equal to 57 | # hence the ANDing context.options with the specified ssl options results in 58 | # "", which as a Boolean value, is False. 59 | # 60 | # To overcome this, instead of checking that the result in True, we will check that it is 61 | # equal to "context.options". 62 | assert ( 63 | context.options 64 | & ( 65 | ssl.OP_NO_SSLv2 66 | | ssl.OP_NO_SSLv3 67 | | ssl.OP_NO_TLSv1 68 | | ssl.OP_NO_TLSv1_1 69 | | ssl.OP_NO_COMPRESSION 70 | ) 71 | == context.options 72 | ) 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "bind, expected_family, expected_binding", 77 | [ 78 | ("127.0.0.1:5000", socket.AF_INET, ("127.0.0.1", 5000)), 79 | ("127.0.0.1", socket.AF_INET, ("127.0.0.1", 8000)), 80 | ("[::]:5000", socket.AF_INET6, ("::", 5000)), 81 | ("[::]", socket.AF_INET6, ("::", 8000)), 82 | ], 83 | ) 84 | def test_create_sockets_ip( 85 | bind: str, 86 | expected_family: socket.AddressFamily, 87 | expected_binding: tuple[str, int], 88 | monkeypatch: MonkeyPatch, 89 | ) -> None: 90 | mock_socket = Mock() 91 | monkeypatch.setattr(socket, "socket", mock_socket) 92 | config = Config() 93 | config.bind = [bind] 94 | sockets = config.create_sockets() 95 | sock = sockets.insecure_sockets[0] 96 | mock_socket.assert_called_with(expected_family, socket.SOCK_STREAM) 97 | sock.setsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # type: ignore 98 | sock.bind.assert_called_with(expected_binding) # type: ignore 99 | sock.setblocking.assert_called_with(False) # type: ignore 100 | sock.set_inheritable.assert_called_with(True) # type: ignore 101 | 102 | 103 | def test_create_sockets_unix(monkeypatch: MonkeyPatch) -> None: 104 | mock_socket = Mock() 105 | monkeypatch.setattr(socket, "socket", mock_socket) 106 | monkeypatch.setattr(os, "chown", Mock()) 107 | config = Config() 108 | config.bind = ["unix:/tmp/hypercorn.sock"] 109 | sockets = config.create_sockets() 110 | sock = sockets.insecure_sockets[0] 111 | mock_socket.assert_called_with(socket.AF_UNIX, socket.SOCK_STREAM) 112 | sock.setsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # type: ignore 113 | sock.bind.assert_called_with("/tmp/hypercorn.sock") # type: ignore 114 | sock.setblocking.assert_called_with(False) # type: ignore 115 | sock.set_inheritable.assert_called_with(True) # type: ignore 116 | 117 | 118 | def test_create_sockets_fd(monkeypatch: MonkeyPatch) -> None: 119 | mock_sock_class = Mock( 120 | return_value=NonCallableMock( 121 | **{"getsockopt.return_value": socket.SOCK_STREAM} # type: ignore 122 | ) 123 | ) 124 | monkeypatch.setattr(socket, "socket", mock_sock_class) 125 | config = Config() 126 | config.bind = ["fd://2"] 127 | sockets = config.create_sockets() 128 | sock = sockets.insecure_sockets[0] 129 | mock_sock_class.assert_called_with(fileno=2) 130 | sock.getsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_TYPE) # type: ignore 131 | sock.setsockopt.assert_called_with(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # type: ignore 132 | sock.setblocking.assert_called_with(False) # type: ignore 133 | sock.set_inheritable.assert_called_with(True) # type: ignore 134 | 135 | 136 | def test_create_sockets_multiple(monkeypatch: MonkeyPatch) -> None: 137 | mock_socket = Mock() 138 | monkeypatch.setattr(socket, "socket", mock_socket) 139 | monkeypatch.setattr(os, "chown", Mock()) 140 | config = Config() 141 | config.bind = ["127.0.0.1", "unix:/tmp/hypercorn.sock"] 142 | sockets = config.create_sockets() 143 | assert len(sockets.insecure_sockets) == 2 144 | 145 | 146 | def test_response_headers(monkeypatch: MonkeyPatch) -> None: 147 | monkeypatch.setattr(hypercorn.config, "time", lambda: 1_512_229_395) 148 | config = Config() 149 | assert config.response_headers("test") == [ 150 | (b"date", b"Sat, 02 Dec 2017 15:43:15 GMT"), 151 | (b"server", b"hypercorn-test"), 152 | ] 153 | config.include_server_header = False 154 | assert config.response_headers("test") == [(b"date", b"Sat, 02 Dec 2017 15:43:15 GMT")] 155 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Hypercorn documentation build configuration file, created by 5 | # sphinx-quickstart on Sun May 21 14:18:44 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('../')) 23 | sys.path.insert(0, os.path.abspath('../src')) 24 | 25 | from importlib.metadata import version as meta_version 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinxcontrib.mermaid'] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | # templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'Hypercorn' 52 | copyright = '2018 - 2020 Philip Jones' 53 | author = 'Philip Jones' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = meta_version("hypercorn") 61 | # The full version, including alpha/beta/rc tags. 62 | release = version 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = "en" 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = "pydata_sphinx_theme" 89 | html_logo = "_static/logo_small.png" 90 | 91 | # Theme options are theme-specific and customize the look and feel of a theme 92 | # further. For a list of options available for each theme, see the 93 | # documentation. 94 | # 95 | html_theme_options = { 96 | "external_links": [ 97 | {"name": "Source code", "url": "https://github.com/pgjones/hypercorn"}, 98 | {"name": "Issues", "url": "https://github.com/pgjones/hypercorn/issues"}, 99 | ], 100 | "icon_links": [ 101 | { 102 | "name": "Github", 103 | "url": "https://github.com/pgjones/hypercorn", 104 | "icon": "fab fa-github", 105 | }, 106 | ], 107 | } 108 | 109 | # html_sidebars = {} 110 | 111 | 112 | # Add any paths that contain custom static files (such as style sheets) here, 113 | # relative to this directory. They are copied after the builtin static files, 114 | # so a file named "default.css" will overwrite the builtin "default.css". 115 | html_static_path = ['_static'] 116 | 117 | 118 | # -- Options for HTMLHelp output ------------------------------------------ 119 | 120 | # Output file base name for HTML help builder. 121 | htmlhelp_basename = 'Hypercorndoc' 122 | 123 | 124 | # -- Options for LaTeX output --------------------------------------------- 125 | 126 | latex_elements = { 127 | # The paper size ('letterpaper' or 'a4paper'). 128 | # 129 | # 'papersize': 'letterpaper', 130 | 131 | # The font size ('10pt', '11pt' or '12pt'). 132 | # 133 | # 'pointsize': '10pt', 134 | 135 | # Additional stuff for the LaTeX preamble. 136 | # 137 | # 'preamble': '', 138 | 139 | # Latex figure (float) alignment 140 | # 141 | # 'figure_align': 'htbp', 142 | } 143 | 144 | # Grouping the document tree into LaTeX files. List of tuples 145 | # (source start file, target name, title, 146 | # author, documentclass [howto, manual, or own class]). 147 | latex_documents = [ 148 | (master_doc, 'Hypercorn.tex', 'Hypercorn Documentation', 149 | 'Philip Jones', 'manual'), 150 | ] 151 | 152 | 153 | # -- Options for manual page output --------------------------------------- 154 | 155 | # One entry per manual page. List of tuples 156 | # (source start file, name, description, authors, manual section). 157 | man_pages = [ 158 | (master_doc, 'hypercorn', 'Hypercorn Documentation', 159 | [author], 1) 160 | ] 161 | 162 | 163 | # -- Options for Texinfo output ------------------------------------------- 164 | 165 | # Grouping the document tree into Texinfo files. List of tuples 166 | # (source start file, target name, title, author, 167 | # dir menu entry, description, category) 168 | texinfo_documents = [ 169 | (master_doc, 'Hypercorn', 'Hypercorn Documentation', 170 | author, 'Hypercorn', 'One line description of project.', 171 | 'Miscellaneous'), 172 | ] 173 | 174 | def setup(app): 175 | app.add_css_file('css/hypercorn.css') 176 | 177 | 178 | suppress_warnings = ["ref.python"] 179 | -------------------------------------------------------------------------------- /src/hypercorn/app_wrappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import Callable 5 | from functools import partial 6 | from io import BytesIO 7 | 8 | from .typing import ( 9 | ASGIFramework, 10 | ASGIReceiveCallable, 11 | ASGISendCallable, 12 | HTTPScope, 13 | Scope, 14 | WSGIFramework, 15 | ) 16 | 17 | 18 | class InvalidPathError(Exception): 19 | pass 20 | 21 | 22 | class ASGIWrapper: 23 | def __init__(self, app: ASGIFramework) -> None: 24 | self.app = app 25 | 26 | async def __call__( 27 | self, 28 | scope: Scope, 29 | receive: ASGIReceiveCallable, 30 | send: ASGISendCallable, 31 | sync_spawn: Callable, 32 | call_soon: Callable, 33 | ) -> None: 34 | await self.app(scope, receive, send) 35 | 36 | 37 | class WSGIWrapper: 38 | def __init__(self, app: WSGIFramework, max_body_size: int) -> None: 39 | self.app = app 40 | self.max_body_size = max_body_size 41 | 42 | async def __call__( 43 | self, 44 | scope: Scope, 45 | receive: ASGIReceiveCallable, 46 | send: ASGISendCallable, 47 | sync_spawn: Callable, 48 | call_soon: Callable, 49 | ) -> None: 50 | if scope["type"] == "http": 51 | await self.handle_http(scope, receive, send, sync_spawn, call_soon) 52 | elif scope["type"] == "websocket": 53 | await send({"type": "websocket.close"}) # type: ignore 54 | elif scope["type"] == "lifespan": 55 | return 56 | else: 57 | raise Exception(f"Unknown scope type, {scope['type']}") 58 | 59 | async def handle_http( 60 | self, 61 | scope: HTTPScope, 62 | receive: ASGIReceiveCallable, 63 | send: ASGISendCallable, 64 | sync_spawn: Callable, 65 | call_soon: Callable, 66 | ) -> None: 67 | body = bytearray() 68 | while True: 69 | message = await receive() 70 | body.extend(message.get("body", b"")) # type: ignore 71 | if len(body) > self.max_body_size: 72 | await send({"type": "http.response.start", "status": 400, "headers": []}) 73 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 74 | return 75 | if not message.get("more_body"): 76 | break 77 | 78 | try: 79 | environ = _build_environ(scope, bytes(body)) 80 | except InvalidPathError: 81 | await send({"type": "http.response.start", "status": 404, "headers": []}) 82 | else: 83 | await sync_spawn(self.run_app, environ, partial(call_soon, send)) 84 | await send({"type": "http.response.body", "body": b"", "more_body": False}) 85 | 86 | def run_app(self, environ: dict, send: Callable) -> None: 87 | headers: list[tuple[bytes, bytes]] 88 | response_started = False 89 | status_code: int | None = None 90 | 91 | def start_response( 92 | status: str, 93 | response_headers: list[tuple[str, str]], 94 | exc_info: Exception | None = None, 95 | ) -> None: 96 | nonlocal headers, response_started, status_code 97 | 98 | raw, _ = status.split(" ", 1) 99 | status_code = int(raw) 100 | headers = [ 101 | (name.lower().encode("latin-1"), value.encode("latin-1")) 102 | for name, value in response_headers 103 | ] 104 | response_started = True 105 | 106 | response_body = self.app(environ, start_response) 107 | 108 | try: 109 | first_chunk = True 110 | for output in response_body: 111 | if first_chunk: 112 | if not response_started: 113 | raise RuntimeError("WSGI app did not call start_response") 114 | 115 | send({"type": "http.response.start", "status": status_code, "headers": headers}) 116 | first_chunk = False 117 | 118 | send({"type": "http.response.body", "body": output, "more_body": True}) 119 | finally: 120 | if hasattr(response_body, "close"): 121 | response_body.close() 122 | 123 | 124 | def _build_environ(scope: HTTPScope, body: bytes) -> dict: 125 | server = scope.get("server") or ("localhost", 80) 126 | path = scope["path"] 127 | script_name = scope.get("root_path", "") 128 | if path.startswith(script_name): 129 | path = path[len(script_name) :] 130 | path = path if path != "" else "/" 131 | else: 132 | raise InvalidPathError() 133 | 134 | environ = { 135 | "REQUEST_METHOD": scope["method"], 136 | "SCRIPT_NAME": script_name.encode("utf8").decode("latin1"), 137 | "PATH_INFO": path.encode("utf8").decode("latin1"), 138 | "QUERY_STRING": scope["query_string"].decode("ascii"), 139 | "SERVER_NAME": server[0], 140 | "SERVER_PORT": server[1], 141 | "SERVER_PROTOCOL": "HTTP/%s" % scope["http_version"], 142 | "wsgi.version": (1, 0), 143 | "wsgi.url_scheme": scope.get("scheme", "http"), 144 | "wsgi.input": BytesIO(body), 145 | "wsgi.errors": sys.stdout, 146 | "wsgi.multithread": True, 147 | "wsgi.multiprocess": True, 148 | "wsgi.run_once": False, 149 | } 150 | 151 | if scope.get("client") is not None: 152 | environ["REMOTE_ADDR"] = scope["client"][0] 153 | 154 | for raw_name, raw_value in scope.get("headers", []): 155 | name = raw_name.decode("latin1") 156 | if name == "content-length": 157 | corrected_name = "CONTENT_LENGTH" 158 | elif name == "content-type": 159 | corrected_name = "CONTENT_TYPE" 160 | else: 161 | corrected_name = "HTTP_%s" % name.upper().replace("-", "_") 162 | # HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case 163 | value = raw_value.decode("latin1") 164 | if corrected_name in environ: 165 | value = environ[corrected_name] + "," + value # type: ignore 166 | environ[corrected_name] = value 167 | return environ 168 | -------------------------------------------------------------------------------- /src/hypercorn/protocol/h3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable 4 | 5 | from aioquic.h3.connection import H3Connection 6 | from aioquic.h3.events import DataReceived, HeadersReceived 7 | from aioquic.h3.exceptions import NoAvailablePushIDError 8 | from aioquic.quic.connection import QuicConnection 9 | from aioquic.quic.events import QuicEvent 10 | 11 | from .events import ( 12 | Body, 13 | Data, 14 | EndBody, 15 | EndData, 16 | Event as StreamEvent, 17 | InformationalResponse, 18 | Request, 19 | Response, 20 | StreamClosed, 21 | Trailers, 22 | ) 23 | from .http_stream import HTTPStream 24 | from .ws_stream import WSStream 25 | from ..config import Config 26 | from ..typing import AppWrapper, ConnectionState, TaskGroup, WorkerContext 27 | from ..utils import filter_pseudo_headers 28 | 29 | 30 | class H3Protocol: 31 | def __init__( 32 | self, 33 | app: AppWrapper, 34 | config: Config, 35 | context: WorkerContext, 36 | task_group: TaskGroup, 37 | state: ConnectionState, 38 | client: tuple[str, int] | None, 39 | server: tuple[str, int] | None, 40 | quic: QuicConnection, 41 | send: Callable[[], Awaitable[None]], 42 | ) -> None: 43 | self.app = app 44 | self.client = client 45 | self.config = config 46 | self.context = context 47 | self.connection = H3Connection(quic) 48 | self.send = send 49 | self.server = server 50 | self.streams: dict[int, HTTPStream | WSStream] = {} 51 | self.task_group = task_group 52 | self.state = state 53 | 54 | async def handle(self, quic_event: QuicEvent) -> None: 55 | for event in self.connection.handle_event(quic_event): 56 | if isinstance(event, HeadersReceived): 57 | if not self.context.terminated.is_set(): 58 | await self._create_stream(event) 59 | if event.stream_ended: 60 | await self.streams[event.stream_id].handle( 61 | EndBody(stream_id=event.stream_id) 62 | ) 63 | elif isinstance(event, DataReceived): 64 | await self.streams[event.stream_id].handle( 65 | Body(stream_id=event.stream_id, data=event.data) 66 | ) 67 | if event.stream_ended: 68 | await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) 69 | 70 | async def stream_send(self, event: StreamEvent) -> None: 71 | if isinstance(event, (InformationalResponse, Response)): 72 | self.connection.send_headers( 73 | event.stream_id, 74 | [(b":status", b"%d" % event.status_code)] 75 | + event.headers 76 | + self.config.response_headers("h3"), 77 | ) 78 | await self.send() 79 | elif isinstance(event, (Body, Data)): 80 | self.connection.send_data(event.stream_id, event.data, False) 81 | await self.send() 82 | elif isinstance(event, (EndBody, EndData)): 83 | self.connection.send_data(event.stream_id, b"", True) 84 | await self.send() 85 | elif isinstance(event, Trailers): 86 | self.connection.send_headers(event.stream_id, event.headers) 87 | await self.send() 88 | elif isinstance(event, StreamClosed): 89 | self.streams.pop(event.stream_id, None) 90 | elif isinstance(event, Request): 91 | await self._create_server_push(event.stream_id, event.raw_path, event.headers) 92 | 93 | async def _create_stream(self, request: HeadersReceived) -> None: 94 | for name, value in request.headers: 95 | if name == b":method": 96 | method = value.decode("ascii").upper() 97 | elif name == b":path": 98 | raw_path = value 99 | 100 | if method == "CONNECT": 101 | self.streams[request.stream_id] = WSStream( 102 | self.app, 103 | self.config, 104 | self.context, 105 | self.task_group, 106 | True, 107 | self.client, 108 | self.server, 109 | self.stream_send, 110 | request.stream_id, 111 | ) 112 | else: 113 | self.streams[request.stream_id] = HTTPStream( 114 | self.app, 115 | self.config, 116 | self.context, 117 | self.task_group, 118 | True, 119 | self.client, 120 | self.server, 121 | self.stream_send, 122 | request.stream_id, 123 | ) 124 | 125 | await self.streams[request.stream_id].handle( 126 | Request( 127 | stream_id=request.stream_id, 128 | headers=filter_pseudo_headers(request.headers), 129 | http_version="3", 130 | method=method, 131 | raw_path=raw_path, 132 | state=self.state, 133 | ) 134 | ) 135 | await self.context.mark_request() 136 | 137 | async def _create_server_push( 138 | self, stream_id: int, path: bytes, headers: list[tuple[bytes, bytes]] 139 | ) -> None: 140 | request_headers = [(b":method", b"GET"), (b":path", path)] 141 | request_headers.extend(headers) 142 | request_headers.extend(self.config.response_headers("h3")) 143 | try: 144 | push_stream_id = self.connection.send_push_promise( 145 | stream_id=stream_id, headers=request_headers 146 | ) 147 | except NoAvailablePushIDError: 148 | # Client does not accept push promises or we are trying to 149 | # push on a push promises request. 150 | pass 151 | else: 152 | event = HeadersReceived( 153 | stream_id=push_stream_id, stream_ended=True, headers=request_headers 154 | ) 155 | await self._create_stream(event) 156 | await self.streams[event.stream_id].handle(EndBody(stream_id=event.stream_id)) 157 | --------------------------------------------------------------------------------