Compare commits

...

93 Commits

Author SHA1 Message Date
Marcelo Trylesinski
479a2c0c89
Version 0.47.0 (#2937) 2026-05-14 06:20:53 -07:00
Marcelo Trylesinski
89347fd166
Add 7-day cooldown for dependency resolution via uv exclude-newer (#2936) 2026-05-12 15:48:51 +00:00
Marcelo Trylesinski
767315b38a
Drop unused contents/actions permissions from zizmor workflow (#2935) 2026-05-12 15:08:08 +02:00
dependabot[bot]
f25ee43e68
chore(deps): bump urllib3 from 2.6.3 to 2.7.0 (#2933) 2026-05-12 07:58:53 +02:00
Stefan Wójcik
8782666189
Fix typo in docs/deployment/index.md. (#2932) 2026-05-09 19:12:56 +02:00
Eugene Toder
ad5ff87c86
Treat fd=0 as a valid file descriptor with reload/workers (#2927)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-04-30 20:26:13 +02:00
Marcelo Trylesinski
6761b2c8f9
Remove Hugging Face sponsor block from docs (#2923) 2026-04-28 17:24:46 +02:00
Marcelo Trylesinski
438f64834d
Surface sponsors on welcome page and sidebar (#2921) 2026-04-28 10:14:03 +02:00
Marcelo Trylesinski
10ddc6dd29
Add ssl_context_factory for custom SSLContext configuration (#2920) 2026-04-28 06:24:24 +00:00
Marcelo Trylesinski
b499bc4510
Eagerly import the ASGI app in the parent process (#2919) 2026-04-27 23:56:45 +02:00
Marcelo Trylesinski
b224045f59
Version 0.46.0 (#2918) 2026-04-23 06:33:22 +00:00
Marcelo Trylesinski
7375b5bf66
Use bytearray for incoming WebSocket message buffer in websockets-sansio (#2917) 2026-04-22 20:11:28 +00:00
Marcelo Trylesinski
d438fb16fe
Support ws_ping_interval and ws_ping_timeout in wsproto implementation (#2916) 2026-04-22 18:33:16 +00:00
Marcelo Trylesinski
3e6b964466
Support ws_max_size in wsproto implementation (#2915) 2026-04-22 19:17:00 +02:00
Marcelo Trylesinski
2c423bd82b
Version 0.45.0 (#2914) 2026-04-21 11:42:06 +01:00
Marcelo Trylesinski
7f027f8e25
Revert "Emit http.disconnect on server shutdown for streaming responses" (#2829) (#2913) 2026-04-21 10:22:03 +00:00
Marcelo Trylesinski
73a80c3cc8
Add --reset-contextvars flag to isolate ASGI request context (#2912) 2026-04-21 10:46:10 +01:00
Marcelo Trylesinski
45c0b568d3
Revert empty context for ASGI runs (#2911) 2026-04-21 09:51:18 +01:00
Marcelo Trylesinski
850d92656d
Raise helpful ImportError when PyYAML is missing for YAML log config (#2906)
Co-authored-by: Nuno André <mail@nunoand.re>
2026-04-19 10:06:44 +00:00
Marcelo Trylesinski
fdcacb4b83
Accept log_level strings case-insensitively (#2907)
Co-authored-by: Nuno André <mail@nunoand.re>
2026-04-19 10:00:23 +00:00
Marcelo Trylesinski
70f247f9ee
Accept os.PathLike for log_config (#2905)
Co-authored-by: Nuno André <mail@nunoand.re>
2026-04-19 09:57:19 +00:00
Marcelo Trylesinski
18edfa7012
Preserve forwarded client ports in proxy headers middleware (#2903)
Co-authored-by: takeda <411978+takeda@users.noreply.github.com>
2026-04-14 09:56:40 +00:00
Marcelo Trylesinski
77843e06dc
Stabilize websocket keepalive ping test (#2904) 2026-04-14 09:48:53 +00:00
dependabot[bot]
3703339cdc
chore(deps-dev): bump pytest from 9.0.2 to 9.0.3 (#2902)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 09:03:58 +02:00
Krishna Chaitanya
fda70f37b0
Add logging configuration documentation with examples (#2870) 2026-04-13 20:40:30 +00:00
Marcelo Trylesinski
f05fc928c0
Fix CodSpeed action ref-version mismatch (#2901) 2026-04-13 20:20:59 +00:00
Marcelo Trylesinski
6cdd61d15e
Add cooldown setting to Dependabot configuration (#2898) 2026-04-12 13:05:29 +00:00
Marcelo Trylesinski
12b823eb8a
Bump locked dependencies (#2894) 2026-04-08 09:35:01 +00:00
Marcelo Trylesinski
1f2abe357a
Add Cloudflare Pages docs preview on pull requests (#2893) 2026-04-07 14:51:52 +00:00
Marcelo Trylesinski
edb54c43c0
Version 0.44.0 (#2890) 2026-04-06 11:21:24 +02:00
Marcelo Trylesinski
029be08867
Implement websocket keepalive pings for websockets-sansio (#2888) 2026-04-06 07:52:58 +00:00
Marcelo Trylesinski
8d397c7319
Version 0.43.0 (#2885) 2026-04-03 18:33:18 +00:00
Sebastián Ramírez
587042d68f
🐛 Emit http.disconnect ASGI receive() event on server shutting down for streaming responses (#2829) 2026-04-03 14:23:03 +00:00
dependabot[bot]
c9a75fb67b
chore(deps): bump the github-actions group with 3 updates (#2878) 2026-04-01 04:30:52 -04:00
dependabot[bot]
84fd578224
chore(deps): bump pygments from 2.19.2 to 2.20.0 (#2877)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 09:44:16 -04:00
Harsha Vashisht
cd52d34b55
Use native context parameter for create_task on Python 3.11+ (#2859)
## Summary

Both HTTP protocol implementations (`h11_impl.py` and
`httptools_impl.py`) use
`contextvars.Context().run(loop.create_task, ...)` to start ASGI tasks
with a
fresh context. Python 3.11 added a `context=` parameter to
`create_task()`,
which avoids the extra indirection through `Context.run()`.

This has been a known TODO in the codebase for a while. Under
high-concurrency
workloads, the `Context().run()` wrapper adds a small but measurable
overhead
per request compared to the native kwarg, since it has to set up and
tear down
the context activation around the call.

The change uses `sys.version_info` to branch at runtime — 3.11+ gets the
native
kwarg, older versions keep the existing behavior. Coverage pragmas
follow the
existing convention in `_types.py` (`py-lt-311` / `py-gte-311` on the
branch
lines).

---------

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-03-28 11:45:30 +00:00
Marcelo Trylesinski
5211880320
Drop cast in ASGI types (#2875) 2026-03-28 11:20:47 +01:00
Marcelo Trylesinski
1cb8e747e2
Add websocket 500 fallback header test (#2874)
## Summary
- extend the invalid websocket HTTP response regression test
- assert the 500 fallback includes content-length and connection headers

## Testing
- uv run pytest tests/protocols/test_websocket.py -k invalid_status -q
2026-03-28 10:09:29 +00:00
dependabot[bot]
28efbb24bd
chore(deps-dev): bump cryptography from 46.0.5 to 46.0.6 (#2873)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5
to 46.0.6.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst">cryptography's
changelog</a>.</em></p>
<blockquote>
<p>46.0.6 - 2026-03-25</p>
<pre><code>
* **SECURITY ISSUE**: Fixed a bug where name constraints were not
applied
  to peer names during verification when the leaf certificate contains a
wildcard DNS SAN. Ordinary X.509 topologies are not affected by this
bug,
including those used by the Web PKI. Credit to **Oleh Konko (1seal)**
for
  reporting the issue. **CVE-2026-34073**
<p>.. _v46-0-5:<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="91d728897b"><code>91d7288</code></a>
Cherry-pick <a
href="https://redirect.github.com/pyca/cryptography/issues/14542">#14542</a>
(<a
href="https://redirect.github.com/pyca/cryptography/issues/14543">#14543</a>)</li>
<li>See full diff in <a
href="https://github.com/pyca/cryptography/compare/46.0.5...46.0.6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cryptography&package-manager=uv&previous-version=46.0.5&new-version=46.0.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Kludex/uvicorn/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 11:03:41 +01:00
Marcelo Trylesinski
042ffeb7d6
ci: add zizmor (#2872) 2026-03-28 09:39:39 +00:00
dependabot[bot]
c61f9d4ebd
chore(deps): bump requests from 2.32.5 to 2.33.0 (#2871)
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:42:53 +01:00
Marcelo Trylesinski
02bed6f8c3
Version 0.42.0 (#2852)
* Version 0.42.0

* Remove benchmark entries from release notes

* Remove docs entry from release notes
2026-03-16 06:11:15 +00:00
Marcelo Trylesinski
d8f2501316
chore: pre-create Config objects in benchmarks to measure protocol hot paths (#2851)
Config.__init__ calls dictConfig() on every construction, which
dominated benchmark time (~70% for httptools). Pre-creating configs
at module level removes this setup noise so CodSpeed measures the
actual protocol work.
2026-03-16 05:12:05 +01:00
Marcelo Trylesinski
9dbb7836bb
Add WebSocket protocol benchmarks for wsproto and websockets-sansio (#2849)
Benchmark handshake and text frame sending using the same mock
transport approach as HTTP benchmarks. The legacy websockets
implementation is excluded as it manages its own internal tasks.
2026-03-15 17:07:06 +00:00
Marcelo Trylesinski
b3c69da8c1
Use bytearray for request body accumulation (#2845)
* Use bytearray for request body accumulation

Accumulating request body with bytes += creates a new bytes object on
every chunk, leading to O(n^2) allocation for fragmented bodies.
bytearray extends in-place (amortized O(1)), avoiding the quadratic cost.

* Add fragmented body benchmark for chunked body accumulation

Sends 100KB in 390 x 256-byte chunks to exercise the body += path
that triggers O(n^2) allocation with bytes concatenation.

* Revert "Add fragmented body benchmark for chunked body accumulation"

This reverts commit 47662509c1.
2026-03-15 17:16:56 +01:00
Marcelo Trylesinski
3f3ebee20f
Disable pytest-xdist for CodSpeed benchmark runs (#2847)
CodSpeed instrumentation does not work with parallel test execution.
Pass -n 0 to disable xdist workers.
2026-03-15 16:10:36 +00:00
Marcelo Trylesinski
d072de754f
Add fragmented body benchmark for chunked body accumulation (#2846)
Sends 100KB in 390 x 256-byte chunks to exercise the body += path
that triggers O(n^2) allocation with bytes concatenation.
2026-03-15 16:01:51 +00:00
Marcelo Trylesinski
e300c2c75d
Add CodSpeed benchmark suite for HTTP protocol hot paths (#2844)
* Add CodSpeed benchmark suite for HTTP protocol hot paths

* Suppress mypy operator error on ASGI message body concatenation

* Use OIDC token and pin CodSpeed action to latest commit
2026-03-15 15:37:09 +00:00
Kadir Can Ozden
1fa697651b
Escape brackets and backslash in httptools HEADER_RE regex (#2824)
* Fix broken HEADER_RE regex in httptools HTTP implementation

The character class in HEADER_RE has unescaped [ and ] which causes
the regex to be parsed incorrectly. The ] prematurely closes the
character class after ':', so the remaining characters '={} \t"'
are treated as a literal sequence rather than part of the class.

As a result the regex never matches any invalid header name character
and the validation at line 496 is completely non-functional.

This escapes the brackets and backslash properly inside the
character class so all RFC 7230 header name separators are caught.

* Add tests for invalid HTTP header name validation

* Add comment explaining why no 500 is sent on invalid header name

* Use backticks around response_started in comment

---------

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-03-15 14:00:30 +00:00
Kadir Can Ozden
59ec1de7a4
Fix multiple issues in websockets sansio implementation (#2825) 2026-03-15 11:22:13 +01:00
Marcelo Trylesinski
2fc0efcdd9
Clarify Windows asyncio event loop selection in docs (#2843)
* Fix Windows event loop docs

* Clarify Windows event loop docs note
2026-03-14 10:07:39 +00:00
dependabot[bot]
c825f4eb6e
chore(deps): bump the github-actions group with 3 updates (#2831)
Bumps the github-actions group with 3 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `astral-sh/setup-uv` from 7.3.0 to 7.3.1
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](eac588ad8d...5a095e7a20)

Updates `actions/upload-artifact` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](b7c566a772...bbbca2ddaa)

Updates `actions/download-artifact` from 7.0.0 to 8.0.0
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](37930b1c2a...70fc10c6e5)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 07:54:55 +01:00
dependabot[bot]
38731c31d1
chore(deps): bump the github-actions group with 2 updates (#2827)
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv).


Updates `actions/checkout` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](8e8c483db8...de0fac2e45)

Updates `astral-sh/setup-uv` from 7.1.6 to 7.3.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](681c641aba...eac588ad8d)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: astral-sh/setup-uv
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-22 13:57:51 +01:00
Marcelo Trylesinski
9283c0f15c
Version 0.41.0 (#2821)
* Version 0.41.0

* Add #2776 to release notes
2026-02-16 22:33:52 +00:00
Jonas Haag
a01a33eb8f
Add --limit-max-requests-jitter to stagger worker restarts (#2707)
* feat: max_requests_jitter

* format

* type check

* Fix test

* Add type annotation for `Server.limit_max_requests`

Mypy infers `int` from the first branch and errors on the `None`
assignment in the else branch.

* Use `cached_property` for `Server.limit_max_requests`

Keeps `__init__` clean by moving the jitter computation out.

* Document `--limit-max-requests-jitter` setting

---------

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-02-15 20:52:08 +00:00
Marcelo Trylesinski
2ce65bde15
Ignore permission denied errors in watchfiles reloader (#2817)
Pass `ignore_permission_denied=True` to `watchfiles.watch()` so
that directories the process cannot access are silently skipped
instead of crashing the reloader.

Bump the minimum watchfiles version to 0.20, which is the release
that introduced the `ignore_permission_denied` parameter.

Closes #1785
2026-02-15 19:49:07 +00:00
Marcelo Trylesinski
654f2ed7d7
Ensure lifespan shutdown runs when should_exit is set during startup (#2812) 2026-02-15 19:02:12 +00:00
Shahar Evron
a03d9f6f0e
Reduce the log level of 'request limit exceeded' messages (#2788)
Co-authored-by: Shahar Evron <shahar@DS-shahar-MBP.local>
2026-02-15 18:40:14 +00:00
Marcelo Trylesinski
e377de40d0
Add socket path to scope["server"] (#2561)
* Add socket path to scope["server"]

* fix lint

* Address review feedback and update sansio protocol

- Use `if` instead of `elif` after early returns in `get_local_addr`
- Update `server` type in `WebSocketsSansIOProtocol` to support UDS
- Add missing test case for full coverage
2026-02-15 18:34:14 +00:00
Marcelo Trylesinski
0779f7f8a4
Poll for readiness in test_multiprocess_health_check and run_server (#2816)
* Poll for readiness in `test_multiprocess_health_check` and `run_server`

Apply the same polling pattern to `test_multiprocess_health_check` — wait
for all processes to be alive instead of a fixed 1s sleep.

In `run_server`, wait for `server.started` instead of a fixed 0.1s sleep
to avoid connection races on slow setups (e.g. free-threaded Python 3.14).

* Avoid uncovered branch in health check polling loop

* Add pragma: no cover to health check polling loop body
2026-02-15 18:23:26 +00:00
Marcelo Trylesinski
7e9ce2c974
Poll for PID changes in test_multiprocess_sighup instead of fixed sleep (#2815)
The 1-second sleep wasn't always enough for the supervisor to pick up the
signal and complete `restart_all()`, causing flaky failures on macOS 3.14.
2026-02-15 18:12:12 +00:00
Erik Wienhold
99f0d8734d
Fix grep warning in scripts/sync-version (#2807)
Extended regular expressions (enabled via flag -E) don't support
non-capturing groups under POSIX.  Get rid of them as they don't add any
value since we don't handle the captured groups in the first place.
2026-02-15 18:01:40 +00:00
dependabot[bot]
7ae2e6375a
chore(deps): bump the python-packages group with 18 updates (#2801)
* chore(deps): bump the python-packages group with 18 updates

Bumps the python-packages group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [click](https://github.com/pallets/click) | `8.3.0` | `8.3.1` |
| [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.1.1` | `1.2.1` |
| [watchfiles](https://github.com/samuelcolvin/watchfiles) | `1.1.0` | `1.1.1` |
| [websockets](https://github.com/python-websockets/websockets) | `13.1` | `16.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.11.9` | `0.14.14` |
| [pytest](https://github.com/pytest-dev/pytest) | `8.3.5` | `9.0.2` |
| [pytest-mock](https://github.com/pytest-dev/pytest-mock) | `3.14.0` | `3.15.1` |
| [pytest-xdist[psutil]](https://github.com/pytest-dev/pytest-xdist) | `3.6.1` | `3.8.0` |
| [mypy](https://github.com/python/mypy) | `1.15.0` | `1.19.1` |
| [types-pyyaml](https://github.com/typeshed-internal/stub_uploader) | `6.0.12.20250402` | `6.0.12.20250915` |
| [cryptography](https://github.com/pyca/cryptography) | `46.0.3` | `46.0.4` |
| [coverage](https://github.com/coveragepy/coveragepy) | `7.8.0` | `7.13.2` |
| [twine](https://github.com/pypa/twine) | `6.1.0` | `6.2.0` |
| [a2wsgi](https://github.com/abersheeran/a2wsgi) | `1.10.8` | `1.10.10` |
| [wsproto](https://github.com/python-hyper/wsproto) | `1.2.0` | `1.3.2` |
| [mkdocs-material](https://github.com/squidfunk/mkdocs-material) | `9.6.21` | `9.7.1` |
| [mkdocstrings-python](https://github.com/mkdocstrings/python) | `1.18.2` | `2.0.1` |
| [mkdocs-llmstxt](https://github.com/pawamoy/mkdocs-llmstxt) | `0.4.0` | `0.5.0` |


Updates `click` from 8.3.0 to 8.3.1
- [Release notes](https://github.com/pallets/click/releases)
- [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/click/compare/8.3.0...8.3.1)

Updates `python-dotenv` from 1.1.1 to 1.2.1
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.1)

Updates `watchfiles` from 1.1.0 to 1.1.1
- [Release notes](https://github.com/samuelcolvin/watchfiles/releases)
- [Commits](https://github.com/samuelcolvin/watchfiles/compare/v1.1.0...v1.1.1)

Updates `websockets` from 13.1 to 16.0
- [Release notes](https://github.com/python-websockets/websockets/releases)
- [Commits](https://github.com/python-websockets/websockets/compare/13.1...16.0)

Updates `ruff` from 0.11.9 to 0.14.14
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.11.9...0.14.14)

Updates `pytest` from 8.3.5 to 9.0.2
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...9.0.2)

Updates `pytest-mock` from 3.14.0 to 3.15.1
- [Release notes](https://github.com/pytest-dev/pytest-mock/releases)
- [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.14.0...v3.15.1)

Updates `pytest-xdist[psutil]` from 3.6.1 to 3.8.0
- [Release notes](https://github.com/pytest-dev/pytest-xdist/releases)
- [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v3.6.1...v3.8.0)

Updates `mypy` from 1.15.0 to 1.19.1
- [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/python/mypy/compare/v1.15.0...v1.19.1)

Updates `types-pyyaml` from 6.0.12.20250402 to 6.0.12.20250915
- [Commits](https://github.com/typeshed-internal/stub_uploader/commits)

Updates `cryptography` from 46.0.3 to 46.0.4
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.4)

Updates `coverage` from 7.8.0 to 7.13.2
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.8.0...7.13.2)

Updates `twine` from 6.1.0 to 6.2.0
- [Release notes](https://github.com/pypa/twine/releases)
- [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/twine/compare/6.1.0...6.2.0)

Updates `a2wsgi` from 1.10.8 to 1.10.10
- [Commits](https://github.com/abersheeran/a2wsgi/compare/v1.10.8...v1.10.10)

Updates `wsproto` from 1.2.0 to 1.3.2
- [Changelog](https://github.com/python-hyper/wsproto/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/python-hyper/wsproto/compare/1.2.0...1.3.2)

Updates `mkdocs-material` from 9.6.21 to 9.7.1
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.6.21...9.7.1)

Updates `mkdocstrings-python` from 1.18.2 to 2.0.1
- [Release notes](https://github.com/mkdocstrings/python/releases)
- [Changelog](https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mkdocstrings/python/compare/1.18.2...2.0.1)

Updates `mkdocs-llmstxt` from 0.4.0 to 0.5.0
- [Release notes](https://github.com/pawamoy/mkdocs-llmstxt/releases)
- [Changelog](https://github.com/pawamoy/mkdocs-llmstxt/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pawamoy/mkdocs-llmstxt/compare/0.4.0...0.5.0)

---
updated-dependencies:
- dependency-name: click
  dependency-version: 8.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-packages
- dependency-name: python-dotenv
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: watchfiles
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-packages
- dependency-name: websockets
  dependency-version: '16.0'
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: python-packages
- dependency-name: ruff
  dependency-version: 0.14.14
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: pytest
  dependency-version: 9.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: python-packages
- dependency-name: pytest-mock
  dependency-version: 3.15.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: pytest-xdist[psutil]
  dependency-version: 3.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: mypy
  dependency-version: 1.19.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: types-pyyaml
  dependency-version: 6.0.12.20250915
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-packages
- dependency-name: cryptography
  dependency-version: 46.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-packages
- dependency-name: coverage
  dependency-version: 7.13.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: twine
  dependency-version: 6.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: a2wsgi
  dependency-version: 1.10.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-packages
- dependency-name: wsproto
  dependency-version: 1.3.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: mkdocs-material
  dependency-version: 9.7.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
- dependency-name: mkdocstrings-python
  dependency-version: 2.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: python-packages
- dependency-name: mkdocs-llmstxt
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: python-packages
...

Signed-off-by: dependabot[bot] <support@github.com>

* Keep websockets pinned at 13.1, remove stale type: ignore comments

websockets 16.0 has breaking API changes (removed legacy module
from type stubs, renamed exceptions) that require a separate
migration effort. Keep it at 13.1 for now.

The two removed `type: ignore` comments became unused with mypy 1.19.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-02-15 17:53:17 +00:00
dependabot[bot]
4532a39a67
chore(deps-dev): bump cryptography from 46.0.3 to 46.0.5 (#2814)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-15 14:33:36 +00:00
dependabot[bot]
4aff1b95f4
chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#2803)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.5.0 to 2.6.3.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.5.0...2.6.3)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.6.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-15 14:28:38 +00:00
Fardin Alizadeh
a148fc5a71
fix typos (#2800) 2026-02-02 10:33:31 +00:00
Irfanuddin Shafi Ahmed
422ce367ae
Update sdist include list in pyproject.toml (#2802)
Removed requirements.txt from the sdist include list as the project no longer uses it
2026-02-02 11:29:12 +01:00
dependabot[bot]
918dae6ef9
chore(deps): bump the github-actions group with 4 updates (#2779)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 09:09:13 +01:00
t-kawasumi
ae56d07af7
Fix typo: error_occured -> error_occurred (#2776) 2025-12-25 09:26:05 +01:00
Marcelo Trylesinski
9ff60042a5
Version 0.40.0 (#2773) 2025-12-21 14:04:42 +00:00
Marcelo Trylesinski
19df042c54
Drop Python 3.9 (#2772) 2025-12-21 14:56:01 +01:00
Marcelo Trylesinski
865ce7c0b4
Run strict mypy on test suite (#2771) 2025-12-21 13:35:00 +00:00
Marcelo Trylesinski
4f40b84957
Version 0.39.0 (#2770) 2025-12-21 12:49:56 +01:00
Marcelo Trylesinski
5692dfc416
fix(websockets): Send close frame on ASGI return (#2769) 2025-12-21 12:23:26 +01:00
dependabot[bot]
4194764a26
chore(deps): bump the github-actions group with 2 updates (#2763)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2025-12-21 10:06:02 +00:00
Philip Meier
d94bf28743
explicitly start ASGI run with empty context (#2742)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2025-12-21 10:55:50 +01:00
dependabot[bot]
8ae0bcbecb
chore(deps): bump the github-actions group with 2 updates (#2748)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 07:56:23 +00:00
Marcelo Trylesinski
4744ff9a1a
Add groups configuration for GitHub Actions (#2747) 2025-11-04 07:32:40 +00:00
dependabot[bot]
0391372376
chore(deps): bump astral-sh/setup-uv from 6.8.0 to 7.1.2 (#2746)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 10:28:19 +01:00
Marcelo Trylesinski
69a6ae3198
Improve typing in test_http.py (#2740) 2025-10-23 19:53:31 +00:00
Marcelo Trylesinski
3850ad6520
Version 0.38.0 (#2733) 2025-10-18 13:43:56 +00:00
Marcelo Trylesinski
9b3f17a549
Support Python 3.14 (#2723)
Co-authored-by: Jair Henrique <jair.henrique@gmail.com>
2025-10-18 15:38:06 +02:00
Marcelo Trylesinski
ce79f95d06
Revert "Add Marcelo Trylesinski to the license (#2699)" (#2730) 2025-10-16 22:51:35 +00:00
Marcelo Trylesinski
dbf8797b47
docs: add social icons (#2728) 2025-10-12 21:51:14 +00:00
Marcelo Trylesinski
58f28be98e
Add section about event loop (#2725) 2025-10-12 16:43:26 +01:00
Marcelo Trylesinski
93d9510749
Bump docs dependencies (#2724) 2025-10-11 08:51:24 +00:00
Marcelo Trylesinski
9b1c6c45ed
Move Marcelo Trylesinski to maintainers in pyproject.toml (#2719) 2025-10-02 19:27:29 +00:00
Marcelo Trylesinski
57a61d86f2
Add discord to README (#2718) 2025-10-02 18:49:43 +00:00
dependabot[bot]
7ef5f9f5e7
chore(deps): bump astral-sh/setup-uv from 6.7.0 to 6.8.0 (#2717)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 07:30:12 +02:00
NGANAMODEIJunior
6d26d88970
Update pyproject.toml for PEP639 compliance (#2713)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2025-09-25 13:08:08 +00:00
Marcelo Trylesinski
4098bcac97
Version 0.37.0 (#2712) 2025-09-23 13:32:12 +00:00
Nikita Reznikov
8c057fa3fc
Add os.PathLike[str] type to ssl_ca_certs (#2676)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2025-09-23 13:21:28 +00:00
LincolnPuzey
bbe119e4e8
Add note about --timeout-keep-alive being measured in seconds (#2669)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2025-09-23 13:12:42 +00:00
67 changed files with 3696 additions and 1578 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: Kludex

View File

@ -4,6 +4,8 @@ updates:
directory: "/"
schedule:
interval: "monthly"
cooldown:
default-days: 7
groups:
python-packages:
patterns:
@ -12,3 +14,9 @@ updates:
directory: "/"
schedule:
interval: monthly
cooldown:
default-days: 7
groups:
github-actions:
patterns:
- "*"

36
.github/workflows/benchmark.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: CodSpeed
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
id-token: write
contents: read
jobs:
benchmarks:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: "3.13"
enable-cache: true
- name: Install dependencies
run: scripts/install
shell: bash
- name: Run the benchmarks
uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4
with:
mode: instrumentation
run: uv run pytest tests/benchmarks/ --codspeed -n 0

View File

@ -1,4 +1,3 @@
---
name: Test Suite
on:
@ -13,17 +12,22 @@ jobs:
runs-on: "${{ matrix.os }}"
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- uses: "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" # v5.0.0
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: ${{ matrix.os != 'windows-latest' }}
@ -48,11 +52,62 @@ jobs:
run: scripts/coverage
shell: bash
docs-preview:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
environment:
name: cloudflare
url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: "3.12"
enable-cache: false
- name: Install dependencies
run: scripts/install
- name: Build docs
run: uv run mkdocs build
- name: Deploy preview to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: >
pages deploy ./site
--project-name uvicorn
--commit-hash ${{ github.event.pull_request.head.sha }}
--branch ${{ github.head_ref }}
- name: Comment preview URL on PR
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
with:
message: |
:book: Docs preview: ${{ steps.deploy.outputs.deployment-url }}
comment-tag: docs-preview
# https://github.com/marketplace/actions/alls-green#why
check:
if: always()
needs: [tests]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Decide whether the needed jobs succeeded or failed
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2

View File

@ -10,14 +10,19 @@ jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: "3.11"
enable-cache: true
enable-cache: false
- name: Install dependencies
run: scripts/install
@ -26,13 +31,13 @@ jobs:
run: scripts/build
- name: Upload package distributions
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: package-distributions
path: dist/
- name: Upload documentation
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: documentation
path: site/
@ -51,7 +56,7 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: package-distributions
path: dist/
@ -67,9 +72,11 @@ jobs:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# `mkdocs gh-deploy` pushes the built docs to `gh-pages`, so this job needs
# a real checkout with the authenticated origin remote preserved.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked]
- name: Download artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: documentation
path: site/
@ -80,10 +87,10 @@ jobs:
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Install uv
uses: astral-sh/setup-uv@b75a909f75acd358c2196fb9a5f1299a9a8868a4 # v6.7.0
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: "3.12"
enable-cache: true
enable-cache: false
- name: Install dependencies
run: scripts/install
@ -95,14 +102,19 @@ jobs:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
environment:
name: cloudflare
url: https://uvicorn.dev
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: documentation
path: site/

25
.github/workflows/zizmor.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: GitHub Actions Security Analysis
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ venv/
htmlcov/
site/
dist/
.codspeed/

View File

@ -1,5 +1,4 @@
Copyright © 2017, [Encode OSS Ltd](https://www.encode.io/).
Copyright © 2025, Marcelo Trylesinski
Copyright © 2017-present, [Encode OSS Ltd](https://www.encode.io/).
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@ -11,8 +11,12 @@
[![Build Status](https://github.com/Kludex/uvicorn/workflows/Test%20Suite/badge.svg)](https://github.com/Kludex/uvicorn/actions)
[![Package version](https://badge.fury.io/py/uvicorn.svg)](https://pypi.python.org/pypi/uvicorn)
[![Supported Python Version](https://img.shields.io/pypi/pyversions/uvicorn.svg?color=%2334D058)](https://pypi.org/project/uvicorn)
[![Discord](https://img.shields.io/discord/1051468649518616576?logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/RxKUF5JuHs)
---
**Documentation**: [https://uvicorn.dev](https://uvicorn.dev)
**Source Code**: [https://www.github.com/Kludex/uvicorn](https://www.github.com/Kludex/uvicorn)
---

View File

@ -0,0 +1,75 @@
# Event Loop
Uvicorn provides two event loop implementations that you can choose from using the [`--loop`](../settings.md#implementation) option:
```bash
uvicorn main:app --loop <auto|asyncio|uvloop>
```
By default, Uvicorn uses `--loop auto`, which automatically selects:
1. **uvloop** - If [uvloop](https://github.com/MagicStack/uvloop) is installed, Uvicorn will use it for maximum performance
2. **asyncio** - If uvloop is not available, Uvicorn falls back to Python's built-in asyncio event loop
Since `uvloop` is not compatible with Windows or PyPy, it is not available on these platforms.
On Windows, the asyncio implementation uses the standard [`ProactorEventLoop`][asyncio.ProactorEventLoop] in single-process mode.
When running with `--reload` or multiple workers, it uses [`SelectorEventLoop`][asyncio.SelectorEventLoop] instead.
??? info "Why can `ProactorEventLoop` fail with multiple processes on Windows?"
If you want to know more about it, you can read the issue [#cpython/122240](https://github.com/python/cpython/issues/122240).
## Custom Event Loop
You can use custom event loop implementations by specifying a module path and function name using the colon notation:
```bash
uvicorn main:app --loop <module>:<function>
```
The function should return a callable that creates a new event loop instance.
### rloop
[rloop](https://github.com/gi0baro/rloop) is an experimental AsyncIO event loop implemented in Rust on top of the [mio](https://github.com/tokio-rs/mio) crate. It aims to provide high performance through Rust's systems programming capabilities.
You can install it with:
=== "pip"
```bash
pip install rloop
```
=== "uv"
```bash
uv add rloop
```
You can run `uvicorn` with `rloop` with the following command:
```bash
uvicorn main:app --loop rloop:new_event_loop
```
!!! warning "Experimental"
rloop is currently **experimental** and **not suited for production usage**. It is only available on **Unix systems**.
### Winloop
[Winloop](https://github.com/Vizonex/Winloop) is an alternative library that brings uvloop-like performance to Windows. Since uvloop is based on libuv and doesn't support Windows, Winloop provides a Windows-compatible implementation with significant performance improvements over the standard Windows event loop policies.
You can install it with:
=== "pip"
```bash
pip install winloop
```
=== "uv"
```bash
uv add winloop
```
You can run `uvicorn` with `Winloop` with the following command:
```bash
uvicorn main:app --loop winloop:new_event_loop
```

319
docs/concepts/logging.md Normal file
View File

@ -0,0 +1,319 @@
Uvicorn uses Python's built-in [`logging`](https://docs.python.org/3/library/logging.html)
module, and provides three loggers out of the box:
| Logger name | Purpose |
|------------------|----------------------------------------------------|
| `uvicorn` | Parent logger (rarely used directly) |
| `uvicorn.error` | Server-level messages (startup, shutdown, errors) |
| `uvicorn.access` | Per-request access log lines |
!!! note
Despite its name, `uvicorn.error` is **not** limited to error messages.
It is the general-purpose server logger, similar to how Gunicorn names its
main logger. See [#562](https://github.com/encode/uvicorn/issues/562) for
background.
## Default Configuration
By default, Uvicorn applies the following
[`dictConfig()`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig)
configuration:
```python
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.error": {"level": "INFO"},
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}
```
## Custom Logging Configuration
You can supply a custom logging configuration file with the `--log-config`
option (or `log_config` when calling `uvicorn.run()`).
Uvicorn supports three file formats:
| Extension | Loader | Notes |
|----------------|------------------------------|---------------------------------------------|
| `.json` | `logging.config.dictConfig` | Standard JSON `dictConfig` schema. |
| `.yaml`/`.yml` | `logging.config.dictConfig` | Requires **PyYAML** (`uvicorn[standard]`). |
| Any other | `logging.config.fileConfig` | Classic INI-style format. |
### YAML Example
Create a file named `log_config.yaml`:
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
"()": uvicorn.logging.DefaultFormatter
fmt: "%(asctime)s - %(levelprefix)s %(message)s"
datefmt: "%Y-%m-%d %H:%M:%S"
use_colors: null
access:
"()": uvicorn.logging.AccessFormatter
fmt: '%(asctime)s - %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
datefmt: "%Y-%m-%d %H:%M:%S"
handlers:
default:
formatter: default
class: logging.StreamHandler
stream: ext://sys.stderr
access:
formatter: access
class: logging.StreamHandler
stream: ext://sys.stdout
loggers:
uvicorn:
handlers:
- default
level: INFO
propagate: false
uvicorn.error:
level: INFO
uvicorn.access:
handlers:
- access
level: INFO
propagate: false
```
Then pass it to Uvicorn:
=== "CLI"
```bash
uvicorn main:app --log-config log_config.yaml
```
=== "Programmatic"
```python
uvicorn.run("main:app", log_config="log_config.yaml")
```
### JSON Example
Create a file named `log_config.json`:
```json
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(asctime)s - %(levelprefix)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": null
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(asctime)s - %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s",
"datefmt": "%Y-%m-%d %H:%M:%S"
}
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr"
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": false
},
"uvicorn.error": {
"level": "INFO"
},
"uvicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": false
}
}
}
```
### Programmatic `dictConfig`
You can also pass a dictionary directly when running programmatically:
```python
import uvicorn
log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(asctime)s - %(levelprefix)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.error": {"level": "INFO"},
"uvicorn.access": {"handlers": ["access"], "level": "INFO", "propagate": False},
},
}
uvicorn.run("main:app", log_config=log_config)
```
## Common Recipes
### Writing Logs to a File
To write Uvicorn's server logs to a file in addition to the console, add a `FileHandler` to the `uvicorn` logger:
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
"()": uvicorn.logging.DefaultFormatter
fmt: "%(asctime)s - %(levelprefix)s %(message)s"
datefmt: "%Y-%m-%d %H:%M:%S"
use_colors: false
access:
"()": uvicorn.logging.AccessFormatter
fmt: '%(asctime)s - %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s'
datefmt: "%Y-%m-%d %H:%M:%S"
handlers:
default:
formatter: default
class: logging.StreamHandler
stream: ext://sys.stderr
access:
formatter: access
class: logging.StreamHandler
stream: ext://sys.stdout
file:
formatter: default
class: logging.FileHandler
filename: uvicorn.log
loggers:
uvicorn:
handlers:
- default
- file
level: INFO
propagate: false
uvicorn.error:
level: INFO
uvicorn.access:
handlers:
- access
level: INFO
propagate: false
```
In this example, `uvicorn.access` still writes to stdout only. To write access
logs to the file as well, add `file` to the `uvicorn.access.handlers` list.
### Disabling Access Logs
Use the `--no-access-log` CLI flag, or set `access_log=False` programmatically.
This removes all handlers from `uvicorn.access` without affecting the
`uvicorn.error` logger.
### Disabling Colors
Pass `--no-use-colors` on the command line, or set `use_colors=False`
programmatically. When using a custom `--log-config`, set `use_colors: false`
on each formatter that extends `uvicorn.logging.ColourizedFormatter`.
### Using a Standard Formatter
If you do not need Uvicorn's colorized output, you can use the standard
`logging.Formatter` instead:
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
datefmt: "%Y-%m-%d %H:%M:%S"
handlers:
default:
formatter: default
class: logging.StreamHandler
stream: ext://sys.stderr
loggers:
uvicorn:
handlers:
- default
level: INFO
propagate: false
uvicorn.error:
level: INFO
uvicorn.access:
handlers:
- default
level: INFO
propagate: false
```
!!! warning
When using a standard `logging.Formatter` for the access logger, the
`%(client_addr)s`, `%(request_line)s`, and `%(status_code)s` placeholders
are **not** available. The access log line will be formatted using only the
standard `%(message)s` field.

57
docs/css/extra.css Normal file
View File

@ -0,0 +1,57 @@
.md-nav__sponsors {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin: 1.2rem 0.4rem 0.6rem;
padding: 0.9rem 0.6rem 0.8rem;
background-color: color-mix(in srgb, var(--md-primary-fg-color) 8%, transparent);
border-radius: 0.4rem;
}
.md-nav__sponsors-title {
margin: 0 0 0.1rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--md-default-fg-color--light);
}
.md-nav__sponsor {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.25rem;
border-radius: 0.2rem;
transition: opacity 0.15s;
}
.md-nav__sponsor:hover {
opacity: 0.75;
}
.md-nav__sponsor img {
max-width: 100%;
max-height: 1.6rem;
object-fit: contain;
}
.md-nav__sponsor-cta {
display: inline-block;
margin-top: 0.15rem;
padding: 0.25rem 0.6rem;
font-size: 0.65rem;
font-weight: 600;
color: var(--md-primary-bg-color);
background-color: var(--md-primary-fg-color);
border-radius: 0.2rem;
text-decoration: none;
transition: opacity 0.15s;
}
.md-nav__sponsor-cta:hover {
opacity: 0.85;
color: var(--md-primary-bg-color);
}

View File

@ -82,7 +82,7 @@ The default process manager monitors the status of child processes and automatic
You can also manage child processes by sending specific signals to the main process. (Not supported on Windows.)
- `SIGHUP`: Work processeses are graceful restarted one after another. If you update the code, the new worker process will use the new code.
- `SIGHUP`: Work processes are graceful restarted one after another. If you update the code, the new worker process will use the new code.
- `SIGTTIN`: Increase the number of worker processes by one.
- `SIGTTOU`: Decrease the number of worker processes by one.
@ -225,6 +225,36 @@ It's also possible to use certificates with uvicorn's worker for gunicorn.
$ gunicorn --keyfile=./key.pem --certfile=./cert.pem -k uvicorn.workers.UvicornWorker main:app
```
### Customizing the SSL context
For TLS scenarios that the `--ssl-*` flags don't cover (e.g., mutual TLS, custom `SSLContext.options`, bumping `minimum_version`, loading certificates from memory), pass an `ssl_context_factory` to `uvicorn.run()` or `Config`.
The factory receives the `Config` instance and a `default_ssl_context_factory` callable that builds the standard context from the `ssl_*` settings on `Config`. Use it to start from uvicorn's default and mutate it, or ignore it and build your own context from scratch - the `ssl_*` settings are only consumed by the default factory, so if you don't call it they're effectively unused.
```python
import ssl
from collections.abc import Callable
import uvicorn
from uvicorn.config import Config
def ssl_context_factory(config: Config, default_ssl_context_factory: Callable[[], ssl.SSLContext]) -> ssl.SSLContext:
context = default_ssl_context_factory()
context.minimum_version = ssl.TLSVersion.TLSv1_3
return context
uvicorn.run(
"main:app",
ssl_keyfile="key.pem",
ssl_certfile="cert.pem",
ssl_context_factory=ssl_context_factory,
)
```
The factory is called inside each worker process, so it works with `--reload` and `--workers > 1`. The factory itself must be picklable in those modes (a top-level function is fine; lambdas and local closures are not). The `ssl_*` settings on `Config` are only consumed by `default_ssl_context_factory()`; if you build the context yourself without calling it, those settings are ignored.
## Proxies and Forwarded Headers
When running an application behind one or more proxies, certain information about the request is lost.

BIN
docs/img/fastapi-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -30,7 +30,8 @@
---
**Documentation**: [https://uvicorn.dev](https://uvicorn.dev)<br>
**Documentation**: [https://uvicorn.dev](https://uvicorn.dev)
**Source Code**: [https://www.github.com/Kludex/uvicorn](https://www.github.com/Kludex/uvicorn)
---
@ -43,6 +44,18 @@ and means we're now able to start building a common set of tooling usable across
Uvicorn currently supports **HTTP/1.1** and **WebSockets**.
## Sponsorship
Help us keep Uvicorn maintained and sustainable by [becoming a sponsor](https://github.com/sponsors/Kludex).
**Current sponsors:**
<div style="display: flex; flex-wrap: wrap; gap: 2rem; align-items: center; margin: 1rem 0;">
<a href="https://fastapi.tiangolo.com">
<img src="img/fastapi-logo.png" alt="FastAPI" style="height: 80px;">
</a>
</div>
## Quickstart
**Uvicorn** is available on [PyPI](https://pypi.org/project/uvicorn/) so installation is as simple as:

View File

@ -44,4 +44,15 @@
{{ item.render(nav_item, path, 1) }}
{% endfor %}
</ul>
<!-- Sponsors -->
<div class="md-nav__sponsors">
<p class="md-nav__sponsors-title">Sponsors</p>
<a href="https://fastapi.tiangolo.com" title="FastAPI" class="md-nav__sponsor">
<img src="{{ 'img/fastapi-logo.png' | url }}" alt="FastAPI">
</a>
<a href="https://github.com/sponsors/Kludex" class="md-nav__sponsor-cta">
Become a sponsor! ❤️
</a>
</div>
</nav>

View File

@ -2,6 +2,119 @@
toc_depth: 2
---
## 0.47.0 (May 14, 2026)
### Added
* Add `ssl_context_factory` for custom `SSLContext` configuration (#2920)
### Changed
* Eagerly import the ASGI app in the parent process (#2919)
### Fixed
* Treat `fd=0` as a valid file descriptor with reload/workers (#2927)
## 0.46.0 (April 23, 2026)
### Added
* Support `ws_max_size` in `wsproto` implementation (#2915)
* Support `ws_ping_interval` and `ws_ping_timeout` in `wsproto` implementation (#2916)
### Changed
* Use `bytearray` for incoming WebSocket message buffer in `websockets-sansio` (#2917)
## 0.45.0 (April 21, 2026)
### Added
* Add `--reset-contextvars` flag to isolate ASGI request context (#2912)
* Accept `os.PathLike` for `log_config` (#2905)
* Accept `log_level` strings case-insensitively (#2907)
### Changed
* Revert "Emit `http.disconnect` on server shutdown for streaming responses" (#2913)
* Revert "Explicitly start ASGI run with empty context" (#2911)
### Fixed
* Preserve forwarded client ports in proxy headers middleware (#2903)
* Raise helpful `ImportError` when PyYAML is missing for YAML log config (#2906)
## 0.44.0 (April 6, 2026)
### Added
* Implement websocket keepalive pings for websockets-sansio (#2888)
## 0.43.0 (April 3, 2026)
You can quit Uvicorn now. We heard you, @pamelafox - all 47 of your Ctrl+C's (thanks for flagging it, and thanks to @tiangolo for the fix 🙏). [See the tweet](https://x.com/pamelafox/status/2039097686155227623).
### Changed
* Emit `http.disconnect` ASGI `receive()` event on server shutting down for streaming responses (#2829)
* Use native `context` parameter for `create_task` on Python 3.11+ (#2859)
* Drop cast in ASGI types (#2875)
## 0.42.0 (March 16, 2026)
### Changed
* Use `bytearray` for request body accumulation to avoid O(n^2) allocation on fragmented bodies (#2845)
### Fixed
* Escape brackets and backslash in httptools `HEADER_RE` regex (#2824)
* Fix multiple issues in websockets sans-io implementation (#2825)
## 0.41.0 (February 16, 2026)
### Added
* Add `--limit-max-requests-jitter` to stagger worker restarts (#2707)
* Add socket path to `scope["server"]` (#2561)
### Changed
* Rename `LifespanOn.error_occured` to `error_occurred` (#2776)
### Fixed
* Ignore permission denied errors in watchfiles reloader (#2817)
* Ensure lifespan shutdown runs when `should_exit` is set during startup (#2812)
* Reduce the log level of 'request limit exceeded' messages (#2788)
## 0.40.0 (December 21, 2025)
### Remove
* Drop support for Python 3.9 (#2772)
## 0.39.0 (December 21, 2025)
### Fixed
* Send close frame on ASGI return for WebSockets (#2769)
* Explicitly start ASGI run with empty context (#2742)
## 0.38.0 (October 18, 2025)
### Added
* Support Python 3.14 (#2723)
## 0.37.0 (September 23, 2025)
### Added
* Add `--timeout-worker-healthcheck` option (#2711)
* Add `os.PathLike[str]` type to `ssl_ca_certs` (#2676)
## 0.36.1 (September 23, 2025)
### Fixed
@ -98,7 +211,7 @@ Improve `ProxyHeadersMiddleware` (#2468) and (#2231):
### Fixed
- Don't warn when upgrade is not WebSocket and depedencies are installed (#2360)
- Don't warn when upgrade is not WebSocket and dependencies are installed (#2360)
## 0.30.5 (August 2, 2024)

View File

@ -39,6 +39,7 @@ uvicorn itself.
* `APP` - The ASGI application to run, in the format `"<module>:<attribute>"`.
* `--factory` - Treat `APP` as an application factory, i.e. a `() -> <ASGI app>` callable.
* `--app-dir <path>` - Look for APP in the specified directory by adding it to the PYTHONPATH. **Default:** *Current working directory*.
* `--reset-contextvars` - Run each ASGI request in a fresh `contextvars.Context`. Workaround for a [context leak in asyncio](https://github.com/python/cpython/issues/140947); only relevant when using the `asyncio` event loop (uvloop is not affected). Enabling this hides any context set in the lifespan or by external instrumentation from ASGI handlers. **Default:** *False*.
## Socket Binding
@ -93,10 +94,10 @@ Using Uvicorn with watchfiles will enable the following options (which are other
* `--loop <str>` - Set the event loop implementation. The uvloop implementation provides greater performance, but is not compatible with Windows or PyPy. **Options:** *'auto', 'asyncio', 'uvloop'.* **Default:** *'auto'*.
* `--http <str>` - Set the HTTP protocol implementation. The httptools implementation provides greater performance, but it not compatible with PyPy. **Options:** *'auto', 'h11', 'httptools'.* **Default:** *'auto'*.
* `--ws <str>` - Set the WebSockets protocol implementation. Either of the `websockets` and `wsproto` packages are supported. There are two versions of `websockets` supported: `websockets` and `websockets-sansio`. Use `'none'` to ignore all websocket requests. **Options:** *'auto', 'none', 'websockets', 'websockets-sansio', 'wsproto'.* **Default:** *'auto'*.
* `--ws-max-size <int>` - Set the WebSockets max message size, in bytes. Only available with the `websockets` protocol. **Default:** *16777216* (16 MB).
* `--ws-max-size <int>` - Set the WebSockets max message size, in bytes. **Default:** *16777216* (16 MB).
* `--ws-max-queue <int>` - Set the maximum length of the WebSocket incoming message queue. Only available with the `websockets` protocol. **Default:** *32*.
* `--ws-ping-interval <float>` - Set the WebSockets ping interval, in seconds. Only available with the `websockets` protocol. **Default:** *20.0*.
* `--ws-ping-timeout <float>` - Set the WebSockets ping timeout, in seconds. Only available with the `websockets` protocol. **Default:** *20.0*.
* `--ws-ping-interval <float>` - Set the WebSockets ping interval, in seconds. **Default:** *20.0*.
* `--ws-ping-timeout <float>` - Set the WebSockets ping timeout, in seconds. **Default:** *20.0*.
* `--ws-per-message-deflate <bool>` - Enable/disable WebSocket per-message-deflate compression. Only available with the `websockets` protocol. **Default:** *True*.
* `--lifespan <str>` - Set the Lifespan protocol implementation. **Options:** *'auto', 'on', 'off'.* **Default:** *'auto'*.
* `--h11-max-incomplete-event-size <int>` - Set the maximum number of bytes to buffer of an incomplete event. Only available for `h11` HTTP protocol implementation. **Default:** *16384* (16 KB).
@ -137,13 +138,16 @@ The [SSL context](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) can
To understand more about the SSL context options, please refer to the [Python documentation](https://docs.python.org/3/library/ssl.html).
For advanced TLS scenarios that the flags above don't cover (e.g., mutual TLS, certificate pinning, custom `SSLContext.options`), pass an `ssl_context_factory` to `uvicorn.run()` or `Config`. See [Running with HTTPS](deployment/index.md#customizing-the-ssl-context) for details.
## Resource Limits
* `--limit-concurrency <int>` - Maximum number of concurrent connections or tasks to allow, before issuing HTTP 503 responses. Useful for ensuring known memory usage patterns even under over-resourced loads.
* `--limit-max-requests <int>` - Maximum number of requests to service before terminating the process. Useful when running together with a process manager, for preventing memory leaks from impacting long-running processes.
* `--limit-max-requests-jitter <int>` - Maximum jitter to add to `limit-max-requests`. Each worker adds a random number in the range `[0, jitter]`, staggering restarts to avoid all workers restarting simultaneously. **Default:** *0*.
* `--backlog <int>` - Maximum number of connections to hold in backlog. Relevant for heavy incoming traffic. **Default:** *2048*.
## Timeouts
* `--timeout-keep-alive <int>` - Close Keep-Alive connections if no new data is received within this timeout. **Default:** *5*.
* `--timeout-keep-alive <int>` - Close Keep-Alive connections if no new data is received within this timeout (in seconds). **Default:** *5*.
* `--timeout-graceful-shutdown <int>` - Maximum number of seconds to wait for graceful shutdown. After this timeout, the server will start terminating requests.

View File

@ -1,184 +0,0 @@
# ✨ Sponsor Starlette & Uvicorn ✨
Thank you for your interest in sponsoring Starlette and Uvicorn! ❤️
Your support *directly* contributes to the ongoing development, maintenance, and long-term sustainability of both projects.
<div style="display: flex; justify-content: center; gap: 4rem; margin: 2rem 0; text-align: center;">
<div style="padding: 1rem;">
<h3 style="color: #6e5494; font-size: 2em; margin-bottom: 0.5rem;">67M+</h3>
<p>Starlette Downloads/Month</p>
</div>
<div style="padding: 1rem;">
<h3 style="color: #6e5494; font-size: 2em; margin-bottom: 0.5rem;">57M+</h3>
<p>Uvicorn Downloads/Month</p>
</div>
<div style="padding: 1rem;">
<h3 style="color: #6e5494; font-size: 2em; margin-bottom: 0.5rem;">19K+</h3>
<p>Combined GitHub Stars</p>
</div>
</div>
## Why Sponsor?
While Starlette and Uvicorn are part of the [Encode](https://github.com/encode) organization,
they have been primarily maintained by [**Marcelo Trylesinski (Kludex)**](https://github.com/Kludex)
for the past several years. His dedication and consistent work have been instrumental in keeping
these projects robust, secure, and up-to-date.
This sponsorship page was created to give the community an opportunity to support Marcelo's continued
efforts in maintaining and improving both projects. Your sponsorship directly enables him to
dedicate more time and resources to maintaining and improving these essential tools:
- [x] **Active Development:** Developing new features, enhancing existing ones, and
keeping both projects aligned with the latest developments in the Python and ASGI ecosystems. 💻
- [x] **Community Support:** Providing better support, addressing user issues,
and cultivating a welcoming environment for contributors. 🤝
- [x] **Long-Term Stability:** Ensuring the long-term viability of both projects through strategic
planning and addressing technical debt. 🌳
- [x] **Bug Fixes & Maintenance:** Providing prompt attention to bug reports and
general maintenance to keep the projects reliable. 🔨
- [x] **Security:** Ensuring robust security practices, conducting regular security audits, and
promptly addressing vulnerabilities to protect millions of production deployments. 🔒
- [x] **Documentation:** Creating comprehensive guides, tutorials, and examples to help users of all skill levels. 📖
## How Sponsorship Works
We currently manage sponsorships *exclusively* through **GitHub Sponsors**. This platform integrates seamlessly with the GitHub ecosystem, making it easy for organizations to contribute.
<div style="text-align: center; padding: 2rem; margin: 2rem 0; background: linear-gradient(135deg, #6e5494, #24292e); border-radius: 10px; color: white;">
<h2 style="color: white; margin-bottom: 1rem;">🌟 Become a Sponsor Today! 🌟</h2>
<p style="margin-bottom: 1.5rem; font-size: 1.1em;">Your support helps keep Starlette and Uvicorn growing stronger!</p>
<a href="https://github.com/sponsors/Kludex"
style="display: inline-block; padding: 1rem 2rem; background-color: #238636; color: white; text-decoration: none; border-radius: 6px; font-size: 1.2em; font-weight: bold; transition: all 0.3s ease-in-out;"
onmouseover="this.style.backgroundColor='#2ea043';this.style.transform='translateY(-2px)'"
onmouseout="this.style.backgroundColor='#238636';this.style.transform='translateY(0)'">
❤️ Sponsor on GitHub
</a>
</div>
## Sponsorship Tiers 🎁
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin: 2rem 0;">
<div style="padding: 1.5rem; border: 1px solid #e1e4e8; border-radius: 6px; background: #fff; display: flex; flex-direction: column;">
<h3 style="color: #cd7f32;">🥉 Bronze Sponsor</h3>
<div style="font-size: 1.5em; margin: 1rem 0;">$100<span style="font-size: 0.6em;">/month</span></div>
<ul style="list-style: none; padding: 0; margin-bottom: 1rem; min-height: 90px;">
<li>✓ Company name on Sponsors page</li>
<li>✓ Small logo with link</li>
<li>✓ Our eternal gratitude</li>
</ul>
<div style="text-align: center; margin-top: auto;">
<a href="https://github.com/sponsors/Kludex" style="display: inline-block; padding: 0.5rem 1rem; background-color: #cd7f32; color: white; text-decoration: none; border-radius: 6px; font-weight: bold; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Become a Bronze Sponsor
</a>
</div>
</div>
<div style="padding: 1.5rem; border: 1px solid #e1e4e8; border-radius: 6px; background: #fff; display: flex; flex-direction: column;">
<h3 style="color: #c0c0c0;">🥈 Silver Sponsor</h3>
<div style="font-size: 1.5em; margin: 1rem 0;">$250<span style="font-size: 0.6em;">/month</span></div>
<ul style="list-style: none; padding: 0; margin-bottom: 1rem; min-height: 90px;">
<li>✓ All Bronze benefits</li>
<li>✓ Medium-sized logo</li>
<li>✓ Release notes mention</li>
</ul>
<div style="text-align: center; margin-top: auto;">
<a href="https://github.com/sponsors/Kludex" style="display: inline-block; padding: 0.5rem 1rem; background-color: #c0c0c0; color: white; text-decoration: none; border-radius: 6px; font-weight: bold; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Become a Silver Sponsor
</a>
</div>
</div>
<div style="padding: 1.5rem; border: 1px solid #e1e4e8; border-radius: 6px; background: #fff; position: relative; overflow: hidden; display: flex; flex-direction: column;">
<div style="position: absolute; top: 10px; right: -25px; background: #238636; color: white; padding: 5px 30px; transform: rotate(45deg);">
Popular
</div>
<h3 style="color: #ffd700;">🥇 Gold Sponsor</h3>
<div style="font-size: 1.5em; margin: 1rem 0;">$500<span style="font-size: 0.6em;">/month</span></div>
<ul style="list-style: none; padding: 0; margin-bottom: 1rem; min-height: 90px;">
<li>✓ All Silver benefits</li>
<li>✓ Large logo on main pages</li>
<li>✓ Priority support</li>
</ul>
<div style="text-align: center; margin-top: auto;">
<a href="https://github.com/sponsors/Kludex" style="display: inline-block; padding: 0.5rem 1rem; background-color: #ffd700; color: black; text-decoration: none; border-radius: 6px; font-weight: bold; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.8'" onmouseout="this.style.opacity='1'">
Become a Gold Sponsor
</a>
</div>
</div>
</div>
<div style="text-align: center; margin: 2rem 0;">
<h3>🤝 Custom Sponsor</h3>
<p>Looking for something different? <a href="mailto:marcelotryle@gmail.com">Contact us</a> to discuss custom sponsorship options!</p>
</div>
## Current Sponsors
**Thank you to our generous sponsors!** 🙏
<div style="display: flex; flex-direction: column; gap: 3rem; margin: 2rem 0;">
<div>
<h3 style="text-align: center; color: #ffd700; margin-bottom: 1.5rem;">🏆 Gold Sponsors</h3>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 2rem; align-items: center;">
<a href="https://fastapi.tiangolo.com" style="text-decoration: none;">
<div style="width: 200px; background: #f6f8fa; border-radius: 8px; padding: 1rem; text-align: center;">
<div style="height: 100px; display: flex; align-items: center; justify-content: center; margin-bottom: 0.75rem;">
<img src="https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png" alt="FastAPI" style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<p style="margin: 0; color: #57606a; font-size: 0.9em;">Modern, fast web framework for building APIs with Python 3.8+</p>
</div>
</a>
</div>
</div>
<div>
<h3 style="text-align: center; color: #c0c0c0; margin-bottom: 1.5rem;">🥈 Silver Sponsors</h3>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 2rem; align-items: center;">
<!-- Add Silver Sponsors here -->
</div>
</div>
<div>
<h3 style="text-align: center; color: #cd7f32; margin-bottom: 1.5rem;">🥉 Bronze Sponsors</h3>
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 2rem; align-items: center;">
<!-- Add Bronze Sponsors here -->
</div>
</div>
</div>
## Alternative Sponsorship Platforms
<div style="background: #f6f8fa; padding: 1.5rem; border-radius: 8px; margin: 2rem 0;">
<h3>📢 We Want Your Input!</h3>
<p>We are currently evaluating whether to expand our sponsorship options beyond GitHub Sponsors. If your company would be interested in sponsoring Starlette and Uvicorn but prefers to use a different platform (e.g., Open Collective, direct invoicing), please let us know!</p>
<p>Your feedback is invaluable in helping us make sponsorship as accessible as possible. Share your thoughts by:</p>
<ul>
<li>Opening a discussion on our <a href="https://github.com/Kludex/starlette/discussions">GitHub repository</a></li>
<li>Contacting us directly at <a href="mailto:marcelotryle@gmail.com">marcelotryle@gmail.com</a></li>
</ul>
</div>
<a id="acknowledgments"></a>
## Community & Future Plans 🌟
We want to express our deepest gratitude to all the contributors who have helped shape Starlette and
Uvicorn over the years. These projects wouldn't be what they are today without the incredible work of
every single contributor.
Special thanks to some of our most impactful contributors:
- **Tom Christie** ([@tomchristie](https://github.com/tomchristie)) - The original creator of Starlette and Uvicorn.
- **Adrian Garcia Badaracco** ([@adriangb](https://github.com/adriangb)) - Major contributor to Starlette.
- **Thomas Grainger** ([@graingert](https://github.com/graingert)) - Major contributor to AnyIO, and significant contributions to Starlette and Uvicorn.
- **Alex Grönholm** ([@agronholm](https://github.com/agronholm)) - Creator of AnyIO.
- **Florimond Manca** ([@florimondmanca](https://github.com/florimondmanca)) - Important contributions to Starlette and Uvicorn.
If you want your name removed from the list above, or if I forgot a significant contributor, please let me know.
You can view all contributors on GitHub:
[Starlette Contributors](https://github.com/Kludex/starlette/graphs/contributors) / [Uvicorn Contributors](https://github.com/Kludex/uvicorn/graphs/contributors).
While the current sponsorship program directly supports Marcelo's maintenance work, we are exploring ways
to distribute funding to other key contributors in the future. This initiative is still in early planning
stages, as we want to ensure a fair and sustainable model that recognizes the valuable contributions of
our community members.

View File

@ -1,6 +1,11 @@
site_name: Uvicorn
site_description: The lightning-fast ASGI server.
site_url: https://uvicorn.dev
repo_name: Kludex/uvicorn
repo_url: https://github.com/Kludex/uvicorn
edit_uri: edit/main/docs/
strict: true
theme:
@ -20,23 +25,20 @@ theme:
toggle:
icon: "material/lightbulb-outline"
name: "Switch to light mode"
icon:
repo: fontawesome/brands/github
features:
- search.suggest
- search.highlight
- content.tabs.link
- content.code.annotate
- content.code.copy # https://squidfunk.github.io/mkdocs-material/upgrade/?h=content+copy#contentcodecopy
- content.tabs.link
- navigation.footer # https://squidfunk.github.io/mkdocs-material/upgrade/?h=content+copy#navigationfooter
- navigation.path
- navigation.sections # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation
- navigation.top # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#back-to-top-button
- navigation.tracking
- navigation.footer # https://squidfunk.github.io/mkdocs-material/upgrade/?h=content+copy#navigationfooter
- search.suggest
- search.highlight
- toc.follow # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#anchor-following
- announce.dismiss # https://squidfunk.github.io/mkdocs-material/setup/setting-up-the-header/#mark-as-read
repo_name: Kludex/uvicorn
repo_url: https://github.com/Kludex/uvicorn
edit_uri: edit/main/docs/
# https://www.mkdocs.org/user-guide/configuration/#validation
validation:
@ -52,18 +54,33 @@ nav:
- Concepts:
- ASGI: concepts/asgi.md
- Lifespan: concepts/lifespan.md
- Logging: concepts/logging.md
- WebSockets: concepts/websockets.md
- Event Loop: concepts/event-loop.md
- Deployment:
- Deployment: deployment/index.md
- Docker: deployment/docker.md
- Release Notes: release-notes.md
- Contributing: contributing.md
- Sponsorship: sponsorship.md
extra:
analytics:
provider: google
property: G-KTS6TXPD85
social:
- icon: fontawesome/brands/github-alt
link: https://github.com/Kludex/uvicorn
- icon: fontawesome/brands/discord
link: https://discord.com/invite/RxKUF5JuHs
- icon: fontawesome/brands/twitter
link: https://x.com/marcelotryle
- icon: fontawesome/brands/linkedin
link: https://www.linkedin.com/in/marcelotryle
- icon: fontawesome/solid/globe
link: https://fastapiexpert.com
extra_css:
- css/extra.css
markdown_extensions:
- attr_list
@ -95,11 +112,8 @@ plugins:
- mkdocstrings:
handlers:
python:
import:
- url: https://docs.python.org/3/objects.inv
plugins:
- search
inventories:
- https://docs.python.org/3/objects.inv
- llmstxt:
full_output: llms-full.txt
markdown_description: |-

View File

@ -8,23 +8,25 @@ dynamic = ["version"]
description = "The lightning-fast ASGI server."
readme = "README.md"
license = "BSD-3-Clause"
requires-python = ">=3.9"
license-files = ["LICENSE.md"]
requires-python = ">=3.10"
authors = [
{ name = "Tom Christie", email = "tom@tomchristie.com" },
]
maintainers = [
{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
@ -42,7 +44,7 @@ standard = [
"python-dotenv>=0.13",
"PyYAML>=5.1",
"uvloop>=0.15.1; sys_platform != 'win32' and (sys_platform != 'cygwin' and platform_python_implementation != 'PyPy')",
"watchfiles>=0.13",
"watchfiles>=0.20",
"websockets>=10.4",
]
@ -50,36 +52,38 @@ standard = [
dev = [
# We add uvicorn[standard] so `uv sync` considers the extras.
"uvicorn[standard]",
"ruff==0.11.9",
"pytest==8.3.5",
"pytest-mock==3.14.0",
"pytest-xdist[psutil]==3.6.1",
"mypy==1.15.0",
"ruff==0.15.1",
"pytest==9.0.3",
"pytest-mock==3.15.1",
"pytest-xdist[psutil]==3.8.0",
"pytest-codspeed>=4.1.1",
"mypy==1.19.1",
"types-click==7.1.8",
"types-pyyaml==6.0.12.20250402",
"types-pyyaml==6.0.12.20250915",
"trustme==1.2.1",
"cryptography==44.0.3",
"coverage==7.8.0",
"cryptography>=44.0.3",
"coverage==7.13.4",
"coverage-conditional-plugin==0.9.0",
"coverage-enable-subprocess==1.0",
"httpx==0.28.1",
# check dist
"twine==6.1.0",
"twine==6.2.0",
# Explicit optionals,
"a2wsgi==1.10.8",
"wsproto==1.2.0",
"a2wsgi==1.10.10",
"wsproto==1.3.2",
"websockets==13.1",
]
docs = [
"mkdocs==1.6.1",
"mkdocs-material==9.6.13",
"mkdocstrings-python==1.16.12",
"mkdocs-llmstxt==0.2.0",
"mkdocs-material==9.7.1",
"mkdocstrings-python==2.0.2",
"mkdocs-llmstxt==0.5.0",
]
[tool.uv]
default-groups = ["dev", "docs"]
required-version = ">=0.8.6"
required-version = ">=0.9.17"
exclude-newer = "7 days"
[project.scripts]
uvicorn = "uvicorn.main:main"
@ -94,7 +98,7 @@ Source = "https://github.com/Kludex/uvicorn"
path = "uvicorn/__init__.py"
[tool.hatch.build.targets.sdist]
include = ["/uvicorn", "/tests", "/requirements.txt"]
include = ["/uvicorn", "/tests"]
[tool.ruff]
line-length = 120
@ -110,15 +114,10 @@ combine-as-imports = true
warn_unused_ignores = true
warn_redundant_casts = true
show_error_codes = true
disallow_untyped_defs = true
disallow_untyped_defs = false
ignore_missing_imports = true
follow_imports = "silent"
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
check_untyped_defs = true
[tool.pytest.ini_options]
addopts = "-rxXs --strict-config --strict-markers -n 8"
xfail_strict = true
@ -130,13 +129,15 @@ filterwarnings = [
"ignore: websockets.legacy is deprecated.*:DeprecationWarning",
"ignore: websockets.server.WebSocketServerProtocol is deprecated.*:DeprecationWarning",
"ignore: websockets.client.connect is deprecated.*:DeprecationWarning",
# httptools in Python 3.14t needs the `PYTHON_GIL=0` environment variable, or raises a RuntimeWarning.
"ignore: The global interpreter lock (GIL)*:RuntimeWarning"
]
[tool.coverage.run]
parallel = true
source_pkgs = ["uvicorn", "tests"]
plugins = ["coverage_conditional_plugin"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py", "tests/benchmarks/*"]
[tool.coverage.report]
precision = 2

View File

@ -1,6 +1,6 @@
#!/bin/sh -e
SEMVER_REGEX="([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+)?"
SEMVER_REGEX="([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?(\+[0-9A-Za-z-]+)?"
CHANGELOG_VERSION=$(grep -o -E $SEMVER_REGEX docs/release-notes.md | head -1)
VERSION=$(grep -o -E $SEMVER_REGEX uvicorn/__init__.py | head -1)
if [ "$CHANGELOG_VERSION" != "$VERSION" ]; then

View File

174
tests/benchmarks/http.py Normal file
View File

@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
from uvicorn._types import ASGIApplication, Scope
from uvicorn.config import Config
from uvicorn.lifespan.off import LifespanOff
from uvicorn.protocols.http.h11_impl import H11Protocol
from uvicorn.server import ServerState
if TYPE_CHECKING:
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
WSProtocol: TypeAlias = WebSocketProtocol | _WSProtocol
HTTPProtocol: TypeAlias = H11Protocol | HttpToolsProtocol
SIMPLE_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"", b""])
SIMPLE_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/json",
b"Content-Length: 18",
b"",
b'{"hello": "world"}',
]
)
LARGE_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: text/plain",
b"Content-Length: 100000",
b"",
b"x" * 100000,
]
)
HTTP10_GET_REQUEST = b"\r\n".join([b"GET / HTTP/1.0", b"Host: example.org", b"", b""])
CONNECTION_CLOSE_REQUEST = b"\r\n".join([b"GET / HTTP/1.1", b"Host: example.org", b"Connection: close", b"", b""])
START_POST_REQUEST = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/json",
b"Content-Length: 18",
b"",
b"",
]
)
FINISH_POST_REQUEST = b'{"hello": "world"}'
BODY_CHUNK_SIZE = 256
FRAGMENTED_BODY_SIZE = 100_000
FRAGMENTED_POST_HEADERS = b"\r\n".join(
[
b"POST / HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/octet-stream",
b"Content-Length: " + str(FRAGMENTED_BODY_SIZE).encode(),
b"",
b"",
]
)
FRAGMENTED_BODY_CHUNKS = [b"x" * BODY_CHUNK_SIZE] * (FRAGMENTED_BODY_SIZE // BODY_CHUNK_SIZE)
class MockTransport:
def __init__(self) -> None:
self.buffer = b""
self.closed = False
self.read_paused = False
def get_extra_info(self, key: Any) -> Any:
return {
"sockname": ("127.0.0.1", 8000),
"peername": ("127.0.0.1", 8001),
"sslcontext": False,
}.get(key)
def write(self, data: bytes) -> None:
self.buffer += data
def close(self) -> None:
self.closed = True
def pause_reading(self) -> None:
self.read_paused = True
def resume_reading(self) -> None:
self.read_paused = False
def is_closing(self) -> bool:
return self.closed
def clear_buffer(self) -> None:
self.buffer = b""
def set_protocol(self, protocol: asyncio.Protocol) -> None:
pass
class MockTimerHandle:
def __init__(
self, loop_later_list: list[MockTimerHandle], delay: float, callback: Callable[[], None], args: tuple[Any, ...]
) -> None:
self.loop_later_list = loop_later_list
self.delay = delay
self.callback = callback
self.args = args
self.cancelled = False
def cancel(self) -> None:
if not self.cancelled:
self.cancelled = True
self.loop_later_list.remove(self)
class MockLoop:
def __init__(self) -> None:
self._tasks: list[asyncio.Task[Any]] = []
self._later: list[MockTimerHandle] = []
def create_task(self, coroutine: Any) -> Any:
self._tasks.insert(0, coroutine)
return MockTask()
def call_later(self, delay: float, callback: Callable[[], None], *args: Any) -> MockTimerHandle:
handle = MockTimerHandle(self._later, delay, callback, args)
self._later.insert(0, handle)
return handle
async def run_one(self) -> Any:
return await self._tasks.pop()
class MockTask:
def add_done_callback(self, callback: Callable[[], None]) -> None:
pass
class MockProtocol(asyncio.Protocol):
loop: MockLoop
transport: MockTransport
timeout_keep_alive_task: asyncio.TimerHandle | None
ws_protocol_class: type[WSProtocol] | None
scope: Scope
def make_config(app: ASGIApplication, **kwargs: Any) -> Config:
return Config(app=app, **kwargs)
def get_connected_protocol(
config: Config,
http_protocol_cls: type[HTTPProtocol],
) -> MockProtocol:
loop = MockLoop()
transport = MockTransport()
lifespan = LifespanOff(config)
server_state = ServerState()
protocol = http_protocol_cls(config=config, server_state=server_state, app_state=lifespan.state, _loop=loop) # type: ignore
protocol.connection_made(transport) # type: ignore[arg-type]
return protocol # type: ignore[return-value]

View File

@ -0,0 +1,115 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from tests.benchmarks.http import (
CONNECTION_CLOSE_REQUEST,
FINISH_POST_REQUEST,
FRAGMENTED_BODY_CHUNKS,
FRAGMENTED_POST_HEADERS,
HTTP10_GET_REQUEST,
LARGE_POST_REQUEST,
SIMPLE_GET_REQUEST,
SIMPLE_POST_REQUEST,
START_POST_REQUEST,
get_connected_protocol,
make_config,
)
from tests.response import Response
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
if TYPE_CHECKING:
from tests.benchmarks.http import HTTPProtocol
pytestmark = [pytest.mark.anyio, pytest.mark.benchmark]
_plain_text_app = Response("Hello, world", media_type="text/plain")
_no_content_app = Response(b"", status_code=204)
_chunked_app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"})
_plain_text_config = make_config(_plain_text_app)
_chunked_config = make_config(_chunked_app)
async def _body_echo_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
body = b""
while True:
message = await receive()
body += message.get("body", b"") # type: ignore[operator]
if not message.get("more_body", False):
break
headers = [(b"content-length", str(len(body)).encode())]
await send({"type": "http.response.start", "status": 200, "headers": headers})
await send({"type": "http.response.body", "body": body})
_body_echo_config = make_config(_body_echo_app)
async def test_bench_simple_get(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_simple_post(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(SIMPLE_POST_REQUEST)
await protocol.loop.run_one()
async def test_bench_large_post(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(LARGE_POST_REQUEST)
await protocol.loop.run_one()
async def test_bench_pipelined_requests(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST * 3)
await protocol.loop.run_one()
await protocol.loop.run_one()
await protocol.loop.run_one()
async def test_bench_keepalive_reuse(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_chunked_response(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_chunked_config, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_http10(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(HTTP10_GET_REQUEST)
await protocol.loop.run_one()
async def test_bench_connection_close(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(CONNECTION_CLOSE_REQUEST)
await protocol.loop.run_one()
async def test_bench_fragmented_body(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_plain_text_config, http_protocol_cls)
protocol.data_received(FRAGMENTED_POST_HEADERS)
for chunk in FRAGMENTED_BODY_CHUNKS:
protocol.data_received(chunk)
await protocol.loop.run_one()
async def test_bench_post_body_receive(http_protocol_cls: type[HTTPProtocol]) -> None:
protocol = get_connected_protocol(_body_echo_config, http_protocol_cls)
protocol.data_received(START_POST_REQUEST)
protocol.data_received(FINISH_POST_REQUEST)
await protocol.loop.run_one()

View File

@ -0,0 +1,64 @@
from __future__ import annotations
import importlib.util
from typing import TYPE_CHECKING
import pytest
from tests.benchmarks.http import make_config
from tests.benchmarks.ws import WS_UPGRADE, get_connected_ws_protocol
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
if TYPE_CHECKING:
from tests.benchmarks.ws import WSProtocolClass
pytestmark = [pytest.mark.anyio, pytest.mark.benchmark]
@pytest.fixture(
params=[
pytest.param(
"wsproto",
marks=pytest.mark.skipif(not importlib.util.find_spec("wsproto"), reason="wsproto not installed."),
id="wsproto",
),
pytest.param("websockets-sansio", id="websockets-sansio"),
]
)
def ws_cls(request: pytest.FixtureRequest) -> WSProtocolClass:
if request.param == "wsproto":
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol
return WSProtocol
from uvicorn.protocols.websockets.websockets_sansio_impl import WebSocketsSansIOProtocol
return WebSocketsSansIOProtocol
async def _ws_accept_close_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
await receive()
await send({"type": "websocket.accept"})
await send({"type": "websocket.close", "code": 1000})
async def _ws_send_text_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
await receive()
await send({"type": "websocket.accept"})
await send({"type": "websocket.send", "text": "Hello, world!"})
await send({"type": "websocket.close", "code": 1000})
_ws_accept_close_config = make_config(_ws_accept_close_app, access_log=False)
_ws_send_text_config = make_config(_ws_send_text_app, access_log=False)
async def test_bench_ws_handshake(ws_cls: WSProtocolClass) -> None:
protocol = get_connected_ws_protocol(_ws_accept_close_config, ws_cls)
protocol.data_received(WS_UPGRADE)
await protocol.loop.run_one()
async def test_bench_ws_send_text(ws_cls: WSProtocolClass) -> None:
protocol = get_connected_ws_protocol(_ws_send_text_config, ws_cls)
protocol.data_received(WS_UPGRADE)
await protocol.loop.run_one()

40
tests/benchmarks/ws.py Normal file
View File

@ -0,0 +1,40 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypeAlias
from tests.benchmarks.http import MockLoop, MockTransport
from uvicorn.config import Config
from uvicorn.lifespan.off import LifespanOff
from uvicorn.server import ServerState
if TYPE_CHECKING:
from uvicorn.protocols.websockets.websockets_sansio_impl import WebSocketsSansIOProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol
WSProtocolClass: TypeAlias = type[WSProtocol] | type[WebSocketsSansIOProtocol]
WS_UPGRADE = (
b"GET / HTTP/1.1\r\n"
b"Host: example.org\r\n"
b"Upgrade: websocket\r\n"
b"Connection: Upgrade\r\n"
b"Sec-WebSocket-Key: YmVuY2htYXJra2V5MTIzNA==\r\n"
b"Sec-WebSocket-Version: 13\r\n"
b"\r\n"
)
# Masked text frame: "Hello, world!" (13 bytes) with zero mask key
WS_TEXT_FRAME = b"\x81\x8d\x00\x00\x00\x00Hello, world!"
# Masked close frame: code 1000 with zero mask key
WS_CLOSE_FRAME = b"\x88\x82\x00\x00\x00\x00\x03\xe8"
def get_connected_ws_protocol(config: Config, ws_protocol_cls: WSProtocolClass) -> Any:
loop = MockLoop()
transport = MockTransport()
lifespan = LifespanOff(config)
server_state = ServerState()
protocol = ws_protocol_cls(config=config, server_state=server_state, app_state=lifespan.state, _loop=loop) # type: ignore[arg-type]
protocol.connection_made(transport) # type: ignore[arg-type]
return protocol

View File

@ -5,7 +5,7 @@ import logging
import socket
import sys
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypeAlias
import httpx
import pytest
@ -22,11 +22,6 @@ if TYPE_CHECKING:
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
if sys.version_info >= (3, 10): # pragma: no cover
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
WSProtocol: TypeAlias = "type[WebSocketProtocol | _WSProtocol]"
pytestmark = pytest.mark.anyio

View File

@ -2,13 +2,14 @@ import httpx
import pytest
from tests.middleware.test_logging import caplog_for_logger
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
from uvicorn.logging import TRACE_LOG_LEVEL
from uvicorn.middleware.message_logger import MessageLoggerMiddleware
@pytest.mark.anyio
async def test_message_logger(caplog):
async def app(scope, receive, send):
async def test_message_logger(caplog: pytest.LogCaptureFixture) -> None:
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
await receive()
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"", "more_body": False})
@ -30,8 +31,8 @@ async def test_message_logger(caplog):
@pytest.mark.anyio
async def test_message_logger_exc(caplog):
async def app(scope, receive, send):
async def test_message_logger_exc(caplog: pytest.LogCaptureFixture) -> None:
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
raise RuntimeError()
with caplog_for_logger(caplog, "uvicorn.asgi"):

View File

@ -1,9 +1,10 @@
from __future__ import annotations
import contextlib
import ipaddress
from typing import TYPE_CHECKING
import httpx
import httpx._transports.asgi
import pytest
import websockets.client
@ -30,6 +31,9 @@ async def default_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISend
client_addr = "NONE" # pragma: no cover
else:
host, port = client
with contextlib.suppress(ValueError):
if ipaddress.ip_address(host).version == 6:
host = f"[{host}]"
client_addr = f"{host}:{port}"
response = Response(f"{scheme}://{client_addr}", media_type="text/plain")
@ -426,6 +430,31 @@ async def test_proxy_headers_multiple_proxies(trusted_hosts: str | list[str], ex
assert response.text == expected
@pytest.mark.anyio
@pytest.mark.parametrize(
("trusted_hosts", "expected"),
[
# always trust
("*", "https://1.2.3.4:1234"),
# all proxies are trusted
(["127.0.0.1", "2001:db8::1", "192.168.0.2"], "https://1.2.3.4:1234"),
# should set first untrusted as remote address
(["192.168.0.2", "127.0.0.1"], "https://[2001:db8::1]:8080"),
# Mixed literals and networks
(["127.0.0.1", "2001:db8::/32", "192.168.0.2"], "https://1.2.3.4:1234"),
],
)
async def test_proxy_headers_multiple_proxies_with_ports(trusted_hosts: str | list[str], expected: str) -> None:
async with make_httpx_client(trusted_hosts) as client:
headers = {
X_FORWARDED_FOR: "1.2.3.4:1234, [2001:db8::1]:8080, 192.168.0.2:9000",
X_FORWARDED_PROTO: "https",
}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == expected
@pytest.mark.anyio
async def test_proxy_headers_invalid_x_forwarded_for() -> None:
async with make_httpx_client("*") as client:
@ -441,6 +470,38 @@ async def test_proxy_headers_invalid_x_forwarded_for() -> None:
assert response.text == "https://1.2.3.4:0"
@pytest.mark.anyio
@pytest.mark.parametrize(
("forwarded_for", "expected"),
[
# IPv4 without port
("1.2.3.4", "https://1.2.3.4:0"),
# IPv4 with port
("1.2.3.4:1234", "https://1.2.3.4:1234"),
# Bracketed IPv6 with port
("[2001:db8::1]:443", "https://[2001:db8::1]:443"),
# Bracketed IPv6 without port
("[2001:db8::1]", "https://[2001:db8::1]:0"),
# Bare IPv6 without port
("2001:db8::1", "https://[2001:db8::1]:0"),
# Invalid IPv4 port falls back to the original host value
("1.2.3.4:notaport", "https://1.2.3.4:notaport:0"),
# Invalid bracketed IPv6 port keeps the host and drops the port
("[2001:db8::1]:notaport", "https://[2001:db8::1]:0"),
# Trailing data after a bracketed IPv6 host is left untouched
("[2001:db8::1]extra", "https://[2001:db8::1]extra:0"),
# Malformed bracket is left untouched
("[2001:db8::1", "https://[2001:db8::1:0"),
],
)
async def test_proxy_headers_x_forwarded_for_port_shapes(forwarded_for: str, expected: str) -> None:
async with make_httpx_client("*") as client:
headers = {X_FORWARDED_FOR: forwarded_for, X_FORWARDED_PROTO: "https"}
response = await client.get("/", headers=headers)
assert response.status_code == 200
assert response.text == expected
@pytest.mark.anyio
@pytest.mark.parametrize(
"forwarded_proto,expected",

View File

@ -2,8 +2,7 @@ from __future__ import annotations
import io
import sys
from collections.abc import AsyncGenerator
from typing import Callable
from collections.abc import AsyncGenerator, Callable
import a2wsgi
import httpx

View File

@ -1,10 +1,12 @@
from __future__ import annotations
import asyncio
import logging
import socket
import threading
import time
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
import pytest
@ -25,19 +27,12 @@ except ModuleNotFoundError: # pragma: no cover
skip_if_no_httptools = pytest.mark.skipif(True, reason="httptools is not installed")
if TYPE_CHECKING:
import sys
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
if sys.version_info >= (3, 10): # pragma: no cover
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
HTTPProtocol: TypeAlias = "type[HttpToolsProtocol | H11Protocol]"
WSProtocol: TypeAlias = "type[WebSocketProtocol | _WSProtocol]"
WSProtocol: TypeAlias = WebSocketProtocol | _WSProtocol
HTTPProtocol: TypeAlias = H11Protocol | HttpToolsProtocol
pytestmark = pytest.mark.anyio
@ -173,7 +168,9 @@ UPGRADE_REQUEST_ERROR_FIELD = b"\r\n".join(
class MockTransport:
def __init__(self, sockname=None, peername=None, sslcontext=False):
def __init__(
self, sockname: tuple[str, int] | None = None, peername: tuple[str, int] | None = None, sslcontext: bool = False
):
self.sockname = ("127.0.0.1", 8000) if sockname is None else sockname
self.peername = ("127.0.0.1", 8001) if peername is None else peername
self.sslcontext = sslcontext
@ -181,14 +178,10 @@ class MockTransport:
self.buffer = b""
self.read_paused = False
def get_extra_info(self, key):
return {
"sockname": self.sockname,
"peername": self.peername,
"sslcontext": self.sslcontext,
}.get(key)
def get_extra_info(self, key: Any):
return {"sockname": self.sockname, "peername": self.peername, "sslcontext": self.sslcontext}.get(key)
def write(self, data):
def write(self, data: bytes):
assert not self.closed
self.buffer += data
@ -208,12 +201,14 @@ class MockTransport:
def clear_buffer(self):
self.buffer = b""
def set_protocol(self, protocol):
def set_protocol(self, protocol: asyncio.Protocol):
pass
class MockTimerHandle:
def __init__(self, loop_later_list, delay, callback, args):
def __init__(
self, loop_later_list: list[MockTimerHandle], delay: float, callback: Callable[[], None], args: tuple[Any, ...]
):
self.loop_later_list = loop_later_list
self.delay = delay
self.callback = callback
@ -228,14 +223,14 @@ class MockTimerHandle:
class MockLoop:
def __init__(self):
self._tasks = []
self._later = []
self._tasks: list[asyncio.Task[Any]] = []
self._later: list[MockTimerHandle] = []
def create_task(self, coroutine):
def create_task(self, coroutine: Any) -> Any:
self._tasks.insert(0, coroutine)
return MockTask()
def call_later(self, delay, callback, *args):
def call_later(self, delay: float, callback: Callable[[], None], *args: Any) -> MockTimerHandle:
handle = MockTimerHandle(self._later, delay, callback, args)
self._later.insert(0, handle)
return handle
@ -243,8 +238,8 @@ class MockLoop:
async def run_one(self):
return await self._tasks.pop()
def run_later(self, with_delay):
later = []
def run_later(self, with_delay: float) -> None:
later: list[MockTimerHandle] = []
for timer_handle in self._later:
if with_delay >= timer_handle.delay:
timer_handle.callback(*timer_handle.args)
@ -254,32 +249,35 @@ class MockLoop:
class MockTask:
def add_done_callback(self, callback):
def add_done_callback(self, callback: Callable[[], None]):
pass
class MockProtocol(asyncio.Protocol):
loop: MockLoop
transport: MockTransport
timeout_keep_alive_task: asyncio.TimerHandle | None
ws_protocol_class: type[WSProtocol] | None
scope: Scope
def get_connected_protocol(
app: ASGIApplication,
http_protocol_cls: HTTPProtocol,
http_protocol_cls: type[HTTPProtocol],
lifespan: LifespanOff | LifespanOn | None = None,
**kwargs: Any,
):
) -> MockProtocol:
loop = MockLoop()
transport = MockTransport()
config = Config(app=app, **kwargs)
lifespan = lifespan or LifespanOff(config)
server_state = ServerState()
protocol = http_protocol_cls(
config=config,
server_state=server_state,
app_state=lifespan.state,
_loop=loop, # type: ignore
)
protocol.connection_made(transport) # type: ignore
return protocol
protocol = http_protocol_cls(config=config, server_state=server_state, app_state=lifespan.state, _loop=loop) # type: ignore
protocol.connection_made(transport) # type: ignore[arg-type]
return protocol # type: ignore[return-value]
async def test_get_request(http_protocol_cls: HTTPProtocol):
async def test_get_request(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -298,7 +296,7 @@ async def test_get_request(http_protocol_cls: HTTPProtocol):
pytest.param("µ", id="allow_non_ascii_char"),
],
)
async def test_header_value_allowed_characters(http_protocol_cls: HTTPProtocol, char: str):
async def test_header_value_allowed_characters(http_protocol_cls: type[HTTPProtocol], char: str):
app = Response("Hello, world", media_type="text/plain", headers={"key": f"<{char}>"})
protocol = get_connected_protocol(app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
@ -308,8 +306,44 @@ async def test_header_value_allowed_characters(http_protocol_cls: HTTPProtocol,
assert b"Hello, world" in protocol.transport.buffer
@pytest.mark.parametrize(
"name",
[
pytest.param("bad header", id="reject_space"),
pytest.param("bad\x00header", id="reject_null"),
pytest.param("bad(header", id="reject_open_paren"),
pytest.param("bad)header", id="reject_close_paren"),
pytest.param("bad<header", id="reject_less_than"),
pytest.param("bad>header", id="reject_greater_than"),
pytest.param("bad@header", id="reject_at"),
pytest.param("bad,header", id="reject_comma"),
pytest.param("bad;header", id="reject_semicolon"),
pytest.param("bad:header", id="reject_colon"),
pytest.param("bad[header", id="reject_open_bracket"),
pytest.param("bad]header", id="reject_close_bracket"),
pytest.param("bad{header", id="reject_open_brace"),
pytest.param("bad}header", id="reject_close_brace"),
pytest.param("bad=header", id="reject_equals"),
pytest.param('bad"header', id="reject_double_quote"),
pytest.param("bad\\header", id="reject_backslash"),
pytest.param("bad\theader", id="reject_tab"),
pytest.param("bad\x7fheader", id="reject_del"),
],
)
async def test_invalid_header_name(http_protocol_cls: type[HTTPProtocol], name: str):
app = Response("Hello, world", media_type="text/plain", headers={name: "value"})
protocol = get_connected_protocol(app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
await protocol.loop.run_one()
# No 500 is sent because `response_started` is set before header validation,
# so the error handler just closes the connection.
assert b"HTTP/1.1 500 Internal Server Error" not in protocol.transport.buffer
assert name.encode() not in protocol.transport.buffer
assert protocol.transport.is_closing()
@pytest.mark.parametrize("path", ["/", "/?foo", "/?foo=bar", "/?foo=bar&baz=1"])
async def test_request_logging(path: str, http_protocol_cls: HTTPProtocol, caplog: pytest.LogCaptureFixture):
async def test_request_logging(path: str, http_protocol_cls: type[HTTPProtocol], caplog: pytest.LogCaptureFixture):
get_request_with_query_string = b"\r\n".join(
[f"GET {path} HTTP/1.1".encode("ascii"), b"Host: example.org", b"", b""]
)
@ -324,7 +358,7 @@ async def test_request_logging(path: str, http_protocol_cls: HTTPProtocol, caplo
assert f'"GET {path} HTTP/1.1" 200' in caplog.records[0].message
async def test_head_request(http_protocol_cls: HTTPProtocol):
async def test_head_request(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -334,7 +368,7 @@ async def test_head_request(http_protocol_cls: HTTPProtocol):
assert b"Hello, world" not in protocol.transport.buffer
async def test_post_request(http_protocol_cls: HTTPProtocol):
async def test_post_request(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
body = b""
more_body = True
@ -353,7 +387,7 @@ async def test_post_request(http_protocol_cls: HTTPProtocol):
assert b'Body: {"hello": "world"}' in protocol.transport.buffer
async def test_keepalive(http_protocol_cls: HTTPProtocol):
async def test_keepalive(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"", status_code=204)
protocol = get_connected_protocol(app, http_protocol_cls)
@ -364,7 +398,7 @@ async def test_keepalive(http_protocol_cls: HTTPProtocol):
assert not protocol.transport.is_closing()
async def test_keepalive_timeout(http_protocol_cls: HTTPProtocol):
async def test_keepalive_timeout(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"", status_code=204)
protocol = get_connected_protocol(app, http_protocol_cls)
@ -378,9 +412,7 @@ async def test_keepalive_timeout(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_keepalive_timeout_with_pipelined_requests(
http_protocol_cls: HTTPProtocol,
):
async def test_keepalive_timeout_with_pipelined_requests(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -403,7 +435,7 @@ async def test_keepalive_timeout_with_pipelined_requests(
assert protocol.timeout_keep_alive_task is not None
async def test_close(http_protocol_cls: HTTPProtocol):
async def test_close(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"", status_code=204, headers={"connection": "close"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -413,7 +445,7 @@ async def test_close(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_chunked_encoding(http_protocol_cls: HTTPProtocol):
async def test_chunked_encoding(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -424,7 +456,7 @@ async def test_chunked_encoding(http_protocol_cls: HTTPProtocol):
assert not protocol.transport.is_closing()
async def test_chunked_encoding_empty_body(http_protocol_cls: HTTPProtocol):
async def test_chunked_encoding_empty_body(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -435,9 +467,7 @@ async def test_chunked_encoding_empty_body(http_protocol_cls: HTTPProtocol):
assert not protocol.transport.is_closing()
async def test_chunked_encoding_head_request(
http_protocol_cls: HTTPProtocol,
):
async def test_chunked_encoding_head_request(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"Hello, world!", status_code=200, headers={"transfer-encoding": "chunked"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -447,7 +477,7 @@ async def test_chunked_encoding_head_request(
assert not protocol.transport.is_closing()
async def test_pipelined_requests(http_protocol_cls: HTTPProtocol):
async def test_pipelined_requests(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -468,7 +498,7 @@ async def test_pipelined_requests(http_protocol_cls: HTTPProtocol):
protocol.transport.clear_buffer()
async def test_undersized_request(http_protocol_cls: HTTPProtocol):
async def test_undersized_request(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"xxx", headers={"content-length": "10"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -477,7 +507,7 @@ async def test_undersized_request(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_oversized_request(http_protocol_cls: HTTPProtocol):
async def test_oversized_request(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"xxx" * 20, headers={"content-length": "10"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -486,7 +516,7 @@ async def test_oversized_request(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_large_post_request(http_protocol_cls: HTTPProtocol):
async def test_large_post_request(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -496,7 +526,7 @@ async def test_large_post_request(http_protocol_cls: HTTPProtocol):
assert not protocol.transport.read_paused
async def test_invalid_http(http_protocol_cls: HTTPProtocol):
async def test_invalid_http(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -504,7 +534,7 @@ async def test_invalid_http(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_app_exception(http_protocol_cls: HTTPProtocol):
async def test_app_exception(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
raise Exception()
@ -515,7 +545,7 @@ async def test_app_exception(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_exception_during_response(http_protocol_cls: HTTPProtocol):
async def test_exception_during_response(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.start", "status": 200})
await send({"type": "http.response.body", "body": b"1", "more_body": True})
@ -528,7 +558,7 @@ async def test_exception_during_response(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_no_response_returned(http_protocol_cls: HTTPProtocol):
async def test_no_response_returned(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable): ...
protocol = get_connected_protocol(app, http_protocol_cls)
@ -538,7 +568,7 @@ async def test_no_response_returned(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_partial_response_returned(http_protocol_cls: HTTPProtocol):
async def test_partial_response_returned(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.start", "status": 200})
@ -549,7 +579,7 @@ async def test_partial_response_returned(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_response_header_splitting(http_protocol_cls: HTTPProtocol):
async def test_response_header_splitting(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"", headers={"key": "value\r\nCookie: smuggled=value"})
protocol = get_connected_protocol(app, http_protocol_cls)
@ -560,7 +590,7 @@ async def test_response_header_splitting(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_duplicate_start_message(http_protocol_cls: HTTPProtocol):
async def test_duplicate_start_message(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.start", "status": 200})
await send({"type": "http.response.start", "status": 200})
@ -572,7 +602,7 @@ async def test_duplicate_start_message(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_missing_start_message(http_protocol_cls: HTTPProtocol):
async def test_missing_start_message(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.body", "body": b""})
@ -583,7 +613,7 @@ async def test_missing_start_message(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_message_after_body_complete(http_protocol_cls: HTTPProtocol):
async def test_message_after_body_complete(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.start", "status": 200})
await send({"type": "http.response.body", "body": b""})
@ -596,7 +626,7 @@ async def test_message_after_body_complete(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_value_returned(http_protocol_cls: HTTPProtocol):
async def test_value_returned(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
await send({"type": "http.response.start", "status": 200})
await send({"type": "http.response.body", "body": b""})
@ -609,7 +639,7 @@ async def test_value_returned(http_protocol_cls: HTTPProtocol):
assert protocol.transport.is_closing()
async def test_early_disconnect(http_protocol_cls: HTTPProtocol):
async def test_early_disconnect(http_protocol_cls: type[HTTPProtocol]):
got_disconnect_event = False
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
@ -630,7 +660,7 @@ async def test_early_disconnect(http_protocol_cls: HTTPProtocol):
assert got_disconnect_event
async def test_early_response(http_protocol_cls: HTTPProtocol):
async def test_early_response(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -641,7 +671,7 @@ async def test_early_response(http_protocol_cls: HTTPProtocol):
assert not protocol.transport.is_closing()
async def test_read_after_response(http_protocol_cls: HTTPProtocol):
async def test_read_after_response(http_protocol_cls: type[HTTPProtocol]):
message_after_response = None
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
@ -658,7 +688,7 @@ async def test_read_after_response(http_protocol_cls: HTTPProtocol):
assert message_after_response == {"type": "http.disconnect"}
async def test_http10_request(http_protocol_cls: HTTPProtocol):
async def test_http10_request(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "http"
content = "Version: %s" % scope["http_version"]
@ -672,7 +702,7 @@ async def test_http10_request(http_protocol_cls: HTTPProtocol):
assert b"Version: 1.0" in protocol.transport.buffer
async def test_root_path(http_protocol_cls: HTTPProtocol):
async def test_root_path(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "http"
root_path = scope.get("root_path", "")
@ -687,7 +717,7 @@ async def test_root_path(http_protocol_cls: HTTPProtocol):
assert b"root_path=/app path=/app/" in protocol.transport.buffer
async def test_raw_path(http_protocol_cls: HTTPProtocol):
async def test_raw_path(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "http"
path = scope["path"]
@ -704,7 +734,7 @@ async def test_raw_path(http_protocol_cls: HTTPProtocol):
assert b"Done" in protocol.transport.buffer
async def test_max_concurrency(http_protocol_cls: HTTPProtocol):
async def test_max_concurrency(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls, limit_concurrency=1)
@ -725,27 +755,27 @@ async def test_max_concurrency(http_protocol_cls: HTTPProtocol):
)
async def test_shutdown_during_request(http_protocol_cls: HTTPProtocol):
async def test_shutdown_during_request(http_protocol_cls: type[HTTPProtocol]):
app = Response(b"", status_code=204)
protocol = get_connected_protocol(app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
protocol.shutdown()
protocol.shutdown() # type: ignore[attr-defined]
await protocol.loop.run_one()
assert b"HTTP/1.1 204 No Content" in protocol.transport.buffer
assert protocol.transport.is_closing()
async def test_shutdown_during_idle(http_protocol_cls: HTTPProtocol):
async def test_shutdown_during_idle(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
protocol.shutdown()
protocol.shutdown() # type: ignore[attr-defined]
assert protocol.transport.buffer == b""
assert protocol.transport.is_closing()
async def test_100_continue_sent_when_body_consumed(http_protocol_cls: HTTPProtocol):
async def test_100_continue_sent_when_body_consumed(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
body = b""
more_body = True
@ -777,7 +807,7 @@ async def test_100_continue_sent_when_body_consumed(http_protocol_cls: HTTPProto
async def test_100_continue_not_sent_when_body_not_consumed(
http_protocol_cls: HTTPProtocol,
http_protocol_cls: type[HTTPProtocol],
):
app = Response(b"", status_code=204)
@ -799,7 +829,7 @@ async def test_100_continue_not_sent_when_body_not_consumed(
assert b"HTTP/1.1 204 No Content" in protocol.transport.buffer
async def test_supported_upgrade_request(http_protocol_cls: HTTPProtocol):
async def test_supported_upgrade_request(http_protocol_cls: type[HTTPProtocol]):
pytest.importorskip("wsproto")
app = Response("Hello, world", media_type="text/plain")
@ -809,7 +839,7 @@ async def test_supported_upgrade_request(http_protocol_cls: HTTPProtocol):
assert b"HTTP/1.1 426 " in protocol.transport.buffer
async def test_unsupported_ws_upgrade_request(http_protocol_cls: HTTPProtocol):
async def test_unsupported_ws_upgrade_request(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls, ws="none")
@ -820,7 +850,7 @@ async def test_unsupported_ws_upgrade_request(http_protocol_cls: HTTPProtocol):
async def test_unsupported_ws_upgrade_request_warn_on_auto(
caplog: pytest.LogCaptureFixture, http_protocol_cls: HTTPProtocol
caplog: pytest.LogCaptureFixture, http_protocol_cls: type[HTTPProtocol]
):
app = Response("Hello, world", media_type="text/plain")
@ -836,7 +866,7 @@ async def test_unsupported_ws_upgrade_request_warn_on_auto(
assert msg in warnings
async def test_http2_upgrade_request(http_protocol_cls: HTTPProtocol, ws_protocol_cls: WSProtocol):
async def test_http2_upgrade_request(http_protocol_cls: type[HTTPProtocol], ws_protocol_cls: type[WSProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls, ws=ws_protocol_cls)
@ -867,7 +897,7 @@ def asgi2app(scope: Scope):
async def test_scopes(
asgi2or3_app: ASGIApplication,
expected_scopes: dict[str, str],
http_protocol_cls: HTTPProtocol,
http_protocol_cls: type[HTTPProtocol],
):
protocol = get_connected_protocol(asgi2or3_app, http_protocol_cls)
protocol.data_received(SIMPLE_GET_REQUEST)
@ -884,7 +914,7 @@ async def test_scopes(
],
)
async def test_invalid_http_request(
request_line: str, http_protocol_cls: HTTPProtocol, caplog: pytest.LogCaptureFixture
request_line: str, http_protocol_cls: type[HTTPProtocol], caplog: pytest.LogCaptureFixture
):
app = Response("Hello, world", media_type="text/plain")
request = INVALID_REQUEST_TEMPLATE % request_line
@ -1007,7 +1037,7 @@ async def test_huge_headers_h11_max_incomplete():
assert b"Hello, world" in protocol.transport.buffer
async def test_return_close_header(http_protocol_cls: HTTPProtocol):
async def test_return_close_header(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -1021,7 +1051,7 @@ async def test_return_close_header(http_protocol_cls: HTTPProtocol):
assert b"connection: close" in protocol.transport.buffer.lower()
async def test_close_connection_with_multiple_requests(http_protocol_cls: HTTPProtocol):
async def test_close_connection_with_multiple_requests(http_protocol_cls: type[HTTPProtocol]):
app = Response("Hello, world", media_type="text/plain")
protocol = get_connected_protocol(app, http_protocol_cls)
@ -1035,7 +1065,7 @@ async def test_close_connection_with_multiple_requests(http_protocol_cls: HTTPPr
assert b"connection: close" in protocol.transport.buffer.lower()
async def test_close_connection_with_post_request(http_protocol_cls: HTTPProtocol):
async def test_close_connection_with_post_request(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
body = b""
more_body = True
@ -1054,7 +1084,7 @@ async def test_close_connection_with_post_request(http_protocol_cls: HTTPProtoco
assert b"Body: {'hello': 'world'}" in protocol.transport.buffer
async def test_iterator_headers(http_protocol_cls: HTTPProtocol):
async def test_iterator_headers(http_protocol_cls: type[HTTPProtocol]):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
headers = iter([(b"x-test-header", b"test value")])
await send({"type": "http.response.start", "status": 200, "headers": headers})
@ -1066,7 +1096,7 @@ async def test_iterator_headers(http_protocol_cls: HTTPProtocol):
assert b"x-test-header: test value" in protocol.transport.buffer
async def test_lifespan_state(http_protocol_cls: HTTPProtocol):
async def test_lifespan_state(http_protocol_cls: type[HTTPProtocol]):
expected_states = [{"a": 123, "b": [1]}, {"a": 123, "b": [1, 2]}]
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
@ -1095,7 +1125,7 @@ async def test_lifespan_state(http_protocol_cls: HTTPProtocol):
async def test_header_upgrade_is_not_websocket_depend_installed(
caplog: pytest.LogCaptureFixture, http_protocol_cls: HTTPProtocol
caplog: pytest.LogCaptureFixture, http_protocol_cls: type[HTTPProtocol]
):
caplog.set_level(logging.WARNING, logger="uvicorn.error")
app = Response("Hello, world", media_type="text/plain")
@ -1111,7 +1141,7 @@ async def test_header_upgrade_is_not_websocket_depend_installed(
async def test_header_upgrade_is_websocket_depend_not_installed(
caplog: pytest.LogCaptureFixture, http_protocol_cls: HTTPProtocol
caplog: pytest.LogCaptureFixture, http_protocol_cls: type[HTTPProtocol]
):
caplog.set_level(logging.WARNING, logger="uvicorn.error")
app = Response("Hello, world", media_type="text/plain")

View File

@ -10,7 +10,12 @@ from uvicorn.protocols.utils import get_client_addr, get_local_addr, get_remote_
class MockSocket:
def __init__(self, family, peername=None, sockname=None):
def __init__(
self,
family: socket.AddressFamily,
peername: tuple[str, int] | None = None,
sockname: tuple[str, int] | str | None = None,
):
self.peername = peername
self.sockname = sockname
self.family = family
@ -40,9 +45,8 @@ def test_get_local_addr_with_socket():
transport = MockTransport({"socket": MockSocket(family=socket.AF_INET, sockname=("123.45.6.7", 123))})
assert get_local_addr(transport) == ("123.45.6.7", 123)
if hasattr(socket, "AF_UNIX"): # pragma: no cover
transport = MockTransport({"socket": MockSocket(family=socket.AF_UNIX, sockname=("127.0.0.1", 8000))})
assert get_local_addr(transport) == ("127.0.0.1", 8000)
transport = MockTransport({"socket": MockSocket(family=socket.AF_INET, sockname="/tmp/test.sock")})
assert get_local_addr(transport) == ("/tmp/test.sock", None)
def test_get_remote_addr_with_socket():
@ -62,11 +66,14 @@ def test_get_remote_addr_with_socket():
def test_get_local_addr():
transport = MockTransport({"sockname": "path/to/unix-domain-socket"})
assert get_local_addr(transport) is None
assert get_local_addr(transport) == ("path/to/unix-domain-socket", None)
transport = MockTransport({"sockname": ("123.45.6.7", 123)})
assert get_local_addr(transport) == ("123.45.6.7", 123)
transport = MockTransport({})
assert get_local_addr(transport) is None
def test_get_remote_addr():
transport = MockTransport({"peername": None})
@ -81,5 +88,5 @@ def test_get_remote_addr():
[({"client": ("127.0.0.1", 36000)}, "127.0.0.1:36000"), ({"client": None}, "")],
ids=["ip:port client", "None client"],
)
def test_get_client_addr(scope, expected_client):
def test_get_client_addr(scope: Any, expected_client: str):
assert get_client_addr(scope) == expected_client

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from copy import deepcopy
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
import httpx
import pytest
@ -10,6 +10,7 @@ import websockets
import websockets.client
import websockets.exceptions
from websockets.extensions.permessage_deflate import ClientPerMessageDeflateFactory
from websockets.frames import Opcode
from websockets.typing import Subprotocol
from tests.response import Response
@ -27,6 +28,7 @@ from uvicorn._types import (
)
from uvicorn.config import Config
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
from uvicorn.protocols.websockets.websockets_sansio_impl import WebSocketsSansIOProtocol
try:
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
@ -36,19 +38,13 @@ except ModuleNotFoundError: # pragma: no cover
skip_if_no_wsproto = pytest.mark.skipif(True, reason="wsproto is not installed.")
if TYPE_CHECKING:
import sys
from uvicorn.protocols.http.h11_impl import H11Protocol
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol as _WSProtocol
if sys.version_info >= (3, 10): # pragma: no cover
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
HTTPProtocol: TypeAlias = "type[H11Protocol | HttpToolsProtocol]"
WSProtocol: TypeAlias = "type[_WSProtocol | WebSocketProtocol]"
KeepaliveWSProtocol: TypeAlias = "type[_WSProtocol | WebSocketsSansIOProtocol]"
pytestmark = pytest.mark.anyio
@ -213,8 +209,8 @@ async def test_headers(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProto
async def websocket_connect(self, message: WebSocketConnectEvent):
headers = self.scope.get("headers")
headers = dict(headers) # type: ignore
assert headers[b"host"].startswith(b"127.0.0.1") # type: ignore
assert headers[b"username"] == bytes("abraão", "utf-8") # type: ignore
assert headers[b"host"].startswith(b"127.0.0.1")
assert headers[b"username"] == bytes("abraão", "utf-8")
await self.send({"type": "websocket.accept"})
async def open_connection(url: str):
@ -458,6 +454,27 @@ async def test_asgi_return_value(ws_protocol_cls: WSProtocol, http_protocol_cls:
assert websocket.close_code == 1006
async def test_close_transport_on_asgi_return(
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
"""The ASGI callable should call the `websocket.close` event.
If it doesn't, the server should still send a close frame to the client.
"""
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
message = await receive()
if message["type"] == "websocket.connect":
await send({"type": "websocket.accept"})
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
async with run_server(config):
async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
with pytest.raises(websockets.exceptions.ConnectionClosed):
await websocket.recv()
assert websocket.close_code == 1006
@pytest.mark.parametrize("code", [None, 1000, 1001])
@pytest.mark.parametrize("reason", [None, "test", False], ids=["none_as_reason", "normal_reason", "without_reason"])
async def test_app_close(
@ -736,6 +753,61 @@ async def test_send_binary_data_to_server_bigger_than_default_on_websockets(
assert ws.close_code == expected_result
async def test_fragmented_message_exceeding_max_size(
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
"""Stream non-FIN fragments past `ws_max_size` - the server must close with 1009."""
class App(WebSocketResponse):
async def websocket_connect(self, message: WebSocketConnectEvent):
await self.send({"type": "websocket.accept"})
config = Config(
app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", ws_max_size=2048, port=unused_tcp_port
)
async with run_server(config):
async with websockets.connect(f"ws://127.0.0.1:{unused_tcp_port}") as ws:
payload = b"A" * 1024
with pytest.raises(websockets.exceptions.ConnectionClosed) as exc_info:
await ws.write_frame(False, Opcode.BINARY, payload)
for _ in range(63): # 64 KiB total, well past 2 KiB budget
await ws.write_frame(False, Opcode.CONT, payload)
await ws.recv()
assert exc_info.value.rcvd is not None
assert exc_info.value.rcvd.code == 1009
async def test_fragmented_message_reassembly(
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
"""Server reassembles a fragmented message and delivers it to the app intact."""
received: list[bytes] = []
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "websocket"
connect = await receive()
assert connect["type"] == "websocket.connect"
await send({"type": "websocket.accept"})
message = await receive()
assert message["type"] == "websocket.receive"
payload = message.get("bytes")
assert payload is not None
received.append(payload)
await send({"type": "websocket.close"})
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
async with run_server(config):
async with websockets.connect(f"ws://127.0.0.1:{unused_tcp_port}") as ws:
payload = b"A" * 512
await ws.write_frame(False, Opcode.BINARY, payload)
for _ in range(4):
await ws.write_frame(False, Opcode.CONT, payload)
await ws.write_frame(True, Opcode.CONT, payload)
assert received == [b"A" * 512 * 6]
async def test_server_reject_connection(
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
@ -869,6 +941,8 @@ async def test_server_reject_connection_with_invalid_status(
response = await wsresponse(url)
assert response.status_code == 500
assert response.content == b"Internal Server Error"
assert response.headers["content-length"] == "21"
assert response.headers["connection"] == "close"
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
async with run_server(config):
@ -1186,3 +1260,118 @@ async def test_lifespan_state(ws_protocol_cls: WSProtocol, http_protocol_cls: HT
assert is_open
assert expected_states == actual_states
@pytest.fixture(
params=[
pytest.param(
"uvicorn.protocols.websockets.wsproto_impl:WSProtocol",
marks=skip_if_no_wsproto,
id="wsproto",
),
pytest.param(
"uvicorn.protocols.websockets.websockets_sansio_impl:WebSocketsSansIOProtocol", id="websockets-sansio"
),
]
)
def keepalive_ws_protocol_cls(request: pytest.FixtureRequest):
from uvicorn.importer import import_from_string
return import_from_string(request.param)
async def test_server_keepalive_ping_pong(
keepalive_ws_protocol_cls: KeepaliveWSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
while True:
message = await receive()
if message["type"] == "websocket.connect":
await send({"type": "websocket.accept"})
elif message["type"] == "websocket.disconnect":
break
config = Config(
app=app,
ws=keepalive_ws_protocol_cls,
http=http_protocol_cls,
lifespan="off",
ws_ping_interval=0.1,
ws_ping_timeout=5.0,
port=unused_tcp_port,
)
async with run_server(config) as server:
# The websockets client auto-responds to ping frames, keeping the connection alive.
async with websockets.connect(f"ws://127.0.0.1:{unused_tcp_port}", ping_interval=None):
protocol = list(server.server_state.connections)[0]
assert isinstance(protocol, (_WSProtocol, WebSocketsSansIOProtocol))
# Wait until the server sends at least one keepalive ping, then
# sleep past the timeout window and ensure the connection stays open.
# This verifies that the client answered the ping without depending
# on clock granularity for the measured RTT.
async def ping_sent() -> None:
while protocol.ping_sent_at == 0.0:
await asyncio.sleep(0.05)
await asyncio.wait_for(ping_sent(), timeout=5.0)
await asyncio.sleep(0.2)
assert not protocol.transport.is_closing()
async def test_server_keepalive_ping_timeout(
keepalive_ws_protocol_cls: KeepaliveWSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
while True:
message = await receive()
if message["type"] == "websocket.connect":
await send({"type": "websocket.accept"})
elif message["type"] == "websocket.disconnect":
break
config = Config(
app=app,
ws=keepalive_ws_protocol_cls,
http=http_protocol_cls,
lifespan="off",
ws_ping_interval=0.1,
ws_ping_timeout=0.1,
log_level="trace",
port=unused_tcp_port,
)
async with run_server(config):
async with websockets.connect(f"ws://127.0.0.1:{unused_tcp_port}", ping_interval=None) as websocket:
# Swallow outgoing pong frames so the server's ping never gets ack'd.
websocket.transport.write = lambda data: None # type: ignore[method-assign]
with pytest.raises(websockets.exceptions.ConnectionClosedError) as exc_info:
await asyncio.wait_for(websocket.recv(), timeout=1)
assert exc_info.value.rcvd is not None
assert exc_info.value.rcvd.code == 1011
assert exc_info.value.rcvd.reason == "keepalive ping timeout"
async def test_server_keepalive_disabled(
keepalive_ws_protocol_cls: KeepaliveWSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
):
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
while True:
message = await receive()
if message["type"] == "websocket.connect":
await send({"type": "websocket.accept"})
elif message["type"] == "websocket.disconnect":
break
config = Config(
app=app,
ws=keepalive_ws_protocol_cls,
http=http_protocol_cls,
lifespan="off",
ws_ping_interval=None,
port=unused_tcp_port,
)
async with run_server(config) as server:
async with websockets.connect(f"ws://127.0.0.1:{unused_tcp_port}", ping_interval=None):
protocol = list(server.server_state.connections)[0]
assert isinstance(protocol, (_WSProtocol, WebSocketsSansIOProtocol))
assert protocol.ping_timer is None

View File

@ -6,7 +6,8 @@ import signal
import socket
import threading
import time
from typing import Any, Callable
from collections.abc import Callable
from typing import Any
import pytest
@ -29,12 +30,8 @@ def new_console_in_windows(test_function: Callable[[], Any]) -> Callable[[], Any
name = test_function.__name__
subprocess.check_call(
[
sys.executable,
"-c",
f"from {module} import {name}; {name}.__wrapped__()",
],
creationflags=subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
[sys.executable, "-c", f"from {module} import {name}; {name}.__wrapped__()"],
creationflags=subprocess.CREATE_NO_WINDOW,
)
return new_function
@ -87,9 +84,10 @@ def test_multiprocess_health_check() -> None:
process = supervisor.processes[0]
process.kill()
assert not process.is_alive()
time.sleep(1)
for p in supervisor.processes:
assert p.is_alive()
deadline = time.monotonic() + 10
while not all(p.is_alive() for p in supervisor.processes): # pragma: no cover
assert time.monotonic() < deadline, "Timed out waiting for processes to be alive"
time.sleep(0.1)
supervisor.signal_queue.append(signal.SIGINT)
supervisor.join_all()
@ -132,7 +130,13 @@ def test_multiprocess_sighup() -> None:
time.sleep(1)
pids = [p.pid for p in supervisor.processes]
supervisor.signal_queue.append(signal.SIGHUP)
time.sleep(1)
# Poll instead of a fixed sleep — the supervisor loop runs on a 0.5s interval and `restart_all()` terminates/joins
# each worker sequentially, so the total time is non-deterministic.
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if [p.pid for p in supervisor.processes] != pids:
break
time.sleep(0.1)
assert pids != [p.pid for p in supervisor.processes]
supervisor.signal_queue.append(signal.SIGINT)
supervisor.join_all()

View File

@ -3,11 +3,10 @@ from __future__ import annotations
import signal
import socket
import sys
from collections.abc import Generator
from collections.abc import Callable, Generator
from pathlib import Path
from threading import Thread
from time import sleep
from typing import Callable
import pytest
from pytest_mock import MockerFixture

View File

@ -44,9 +44,7 @@ def test_loop_auto():
async def test_http_auto():
config = Config(app=app)
server_state = ServerState()
protocol = AutoHTTPProtocol( # type: ignore[call-arg]
config=config, server_state=server_state, app_state={}
)
protocol = AutoHTTPProtocol(config=config, server_state=server_state, app_state={})
assert type(protocol).__name__ == expected_http

View File

@ -23,8 +23,6 @@ def test_asyncio_run__custom_loop_factory() -> None:
def test_asyncio_run__passing_a_non_awaitable_callback_should_throw_error() -> None:
with pytest.raises(ValueError):
asyncio_run(
lambda: None, # type: ignore
loop_factory=CustomLoop,
)
# TypeError on Python >= 3.14
with pytest.raises((ValueError, TypeError)):
asyncio_run(lambda: None, loop_factory=CustomLoop) # type: ignore

View File

@ -7,10 +7,10 @@ import logging
import os
import socket
import sys
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import closing
from pathlib import Path
from typing import IO, Any, Callable, Literal
from typing import IO, Any, Literal
from unittest.mock import MagicMock
import pytest
@ -366,6 +366,35 @@ def test_log_config_yaml(
mocked_logging_config_module.dictConfig.assert_called_once_with(logging_config)
def test_log_config_yaml_missing_pyyaml(mocked_logging_config_module: MagicMock, mocker: MockerFixture) -> None:
"""
Test that a helpful error is raised when PyYAML is not installed.
"""
mocker.patch.dict(sys.modules, {"yaml": None})
with pytest.raises(ImportError, match=r"Install the PyYAML package or uvicorn\[standard\]"):
Config(app=asgi_app, log_config="log_config.yaml")
def test_log_config_pathlike(
mocked_logging_config_module: MagicMock,
logging_config: dict[str, Any],
json_logging_config: str,
mocker: MockerFixture,
tmp_path: Path,
) -> None:
"""
Test that one can pass a `os.PathLike` (e.g. `pathlib.Path`) as the log config path.
"""
path = tmp_path / "log_config.json"
mocked_open = mocker.patch("uvicorn.config.open", mocker.mock_open(read_data=json_logging_config))
config = Config(app=asgi_app, log_config=path)
config.load()
mocked_open.assert_called_once_with(os.fspath(path))
mocked_logging_config_module.dictConfig.assert_called_once_with(logging_config)
@pytest.mark.parametrize("config_file", ["log_config.ini", configparser.ConfigParser(), io.StringIO()])
def test_log_config_file(
mocked_logging_config_module: MagicMock,
@ -462,6 +491,13 @@ def test_config_log_effective_level(log_level: int, uvicorn_logger_level: int) -
assert logging.getLogger("uvicorn.asgi").getEffectiveLevel() == effective_level
@pytest.mark.parametrize("log_level", ["INFO", "Info", "info"])
def test_config_log_level_case_insensitive(log_level: str) -> None:
config = Config(app=asgi_app, log_level=log_level)
config.load()
assert logging.getLogger("uvicorn.error").level == logging.INFO
def test_ws_max_size() -> None:
config = Config(app=asgi_app, ws_max_size=1000)
config.load()
@ -517,6 +553,37 @@ def test_bind_fd_works_with_reload_or_workers(reload: bool, workers: int): # pr
fdsock.close()
@pytest.fixture
def stdin_socket() -> Iterator[socket.socket]: # pragma: py-win32
with closing(socket.socket(socket.AF_INET)) as sock:
sock.bind(("127.0.0.1", 0))
saved_stdin = os.dup(0)
os.dup2(sock.fileno(), 0)
try:
yield sock
finally:
os.dup2(saved_stdin, 0)
os.close(saved_stdin)
@pytest.mark.parametrize(
"reload, workers",
[
(True, 1),
(False, 2),
],
ids=["--reload=True --workers=1", "--reload=False --workers=2"],
)
@pytest.mark.skipif(sys.platform == "win32", reason="require unix-like system")
def test_bind_stdin_works_with_reload_or_workers(
reload: bool, workers: int, stdin_socket: socket.socket
): # pragma: py-win32
config = Config(app=asgi_app, fd=0, reload=reload, workers=workers)
config.load()
with closing(config.bind_socket()) as sock:
assert sock.getsockname() == stdin_socket.getsockname()
@pytest.mark.parametrize(
"reload, workers, expected",
[

View File

@ -98,7 +98,7 @@ def test_lifespan_auto_with_error():
lifespan = LifespanOn(config)
await lifespan.startup()
assert lifespan.error_occured
assert lifespan.error_occurred
assert not lifespan.should_exit
await lifespan.shutdown()
@ -117,7 +117,7 @@ def test_lifespan_on_with_error():
lifespan = LifespanOn(config)
await lifespan.startup()
assert lifespan.error_occured
assert lifespan.error_occurred
assert lifespan.should_exit
await lifespan.shutdown()
@ -143,7 +143,7 @@ def test_lifespan_with_failed_startup(mode, raise_exception, caplog):
await lifespan.startup()
assert lifespan.startup_failed
assert lifespan.error_occured is raise_exception
assert lifespan.error_occurred is raise_exception
assert lifespan.should_exit
await lifespan.shutdown()
@ -171,7 +171,7 @@ def test_lifespan_scope_asgi3app():
await lifespan.startup()
assert not lifespan.startup_failed
assert not lifespan.error_occured
assert not lifespan.error_occurred
assert not lifespan.should_exit
await lifespan.shutdown()
@ -228,7 +228,7 @@ def test_lifespan_with_failed_shutdown(mode, raise_exception, caplog):
assert not lifespan.startup_failed
await lifespan.shutdown()
assert lifespan.shutdown_failed
assert lifespan.error_occured is raise_exception
assert lifespan.error_occurred is raise_exception
assert lifespan.should_exit
loop = asyncio.new_event_loop()

View File

@ -1,7 +1,9 @@
import importlib
import inspect
import socket
import sys
from logging import WARNING
from pathlib import Path
import httpx
import pytest
@ -12,6 +14,7 @@ from uvicorn import Server
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
from uvicorn.config import Config
from uvicorn.main import run
from uvicorn.supervisors import Multiprocess
pytestmark = pytest.mark.anyio
@ -85,6 +88,61 @@ def test_run_invalid_app_config_combination(caplog: pytest.LogCaptureFixture) ->
)
def test_run_fails_fast_in_parent_on_bad_app_path(
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Bad app path with `--workers > 1` exits in the parent.
Regression for https://github.com/encode/uvicorn/discussions/2440: without
parent-side validation the supervisor restarts dying workers forever.
"""
def fail(*args: object, **kwargs: object) -> None: # pragma: no cover
pytest.fail("parent reached supervisor; should have exited on bad app path")
monkeypatch.setattr(Config, "bind_socket", fail)
monkeypatch.setattr(Multiprocess, "run", fail)
with pytest.raises(SystemExit) as exit_exception:
run("tests.test_main:nonexistent_attr", workers=2)
assert exit_exception.value.code == 1
assert any("Error loading ASGI app" in record.message for record in caplog.records)
def test_run_imports_app_before_starting_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""`uvicorn.run()` imports the app before `Server.run` opens the event loop.
Regression for https://github.com/encode/uvicorn/issues/941: an app whose
module body calls `asyncio.run(...)` crashes with "loop already running"
if Uvicorn imports it inside the server's event loop. The parent must
import the app synchronously, before `Server.run` enters `asyncio.run`.
"""
module = tmp_path / "eager_async_app.py"
module.write_text(
"import asyncio\n"
"async def _build():\n"
" async def app(scope, receive, send):\n"
" pass\n"
" return app\n"
"app = asyncio.run(_build())\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
imported_before_server_run: list[bool] = []
def tracking_run(self: Server, sockets: object = None) -> None:
imported_before_server_run.append("eager_async_app" in sys.modules)
self.started = True
monkeypatch.setattr(Server, "run", tracking_run)
# The import side effect (`eager_async_app` lands in `sys.modules`) must
# happen before `Server.run`, which is where the event loop opens.
run("eager_async_app:app")
assert imported_before_server_run == [True]
def test_run_startup_failure(caplog: pytest.LogCaptureFixture) -> None:
async def app(scope, receive, send):
assert scope["type"] == "lifespan"

View File

@ -2,19 +2,22 @@ from __future__ import annotations
import asyncio
import contextlib
import contextvars
import json
import logging
import signal
import sys
from collections.abc import Generator
from collections.abc import Callable, Generator
from contextlib import AbstractContextManager
from typing import Callable
import httpx
import pytest
from tests.protocols.test_http import SIMPLE_GET_REQUEST
from tests.utils import run_server
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
from uvicorn._types import ASGIApplication, ASGIReceiveCallable, ASGISendCallable, Scope
from uvicorn.config import Config
from uvicorn.protocols.http.flow_control import HIGH_WATER_LIMIT
from uvicorn.protocols.http.h11_impl import H11Protocol
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from uvicorn.server import Server
@ -84,10 +87,45 @@ async def test_server_interrupt(
assert server.should_exit
async def test_shutdown_on_early_exit_during_startup(unused_tcp_port: int):
"""Test that lifespan.shutdown is called even when should_exit is set during startup."""
startup_complete = False
shutdown_complete = False
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
nonlocal startup_complete, shutdown_complete
if scope["type"] == "lifespan":
while True:
message = await receive()
if message["type"] == "lifespan.startup":
await asyncio.sleep(0.5)
await send({"type": "lifespan.startup.complete"})
startup_complete = True
elif message["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"})
shutdown_complete = True
return
config = Config(app=app, lifespan="on", port=unused_tcp_port)
server = Server(config=config)
# Simulate a reload signal arriving during startup:
# set should_exit before the 0.5s startup sleep finishes.
async def set_exit():
await asyncio.sleep(0.2)
server.should_exit = True
asyncio.create_task(set_exit())
await server.serve()
assert startup_complete
assert shutdown_complete, "lifespan.shutdown was not called despite startup completing"
async def test_request_than_limit_max_requests_warn_log(
unused_tcp_port: int, http_protocol_cls: type[H11Protocol | HttpToolsProtocol], caplog: pytest.LogCaptureFixture
):
caplog.set_level(logging.WARNING, logger="uvicorn.error")
caplog.set_level(logging.INFO, logger="uvicorn.error")
config = Config(app=app, limit_max_requests=1, port=unused_tcp_port, http=http_protocol_cls)
async with run_server(config):
async with httpx.AsyncClient() as client:
@ -95,3 +133,131 @@ async def test_request_than_limit_max_requests_warn_log(
responses = await asyncio.gather(*tasks)
assert len(responses) == 2
assert "Maximum request limit of 1 exceeded. Terminating process." in caplog.text
async def test_limit_max_requests_jitter(
unused_tcp_port: int, http_protocol_cls: type[H11Protocol | HttpToolsProtocol], caplog: pytest.LogCaptureFixture
):
caplog.set_level(logging.INFO, logger="uvicorn.error")
config = Config(
app=app, limit_max_requests=1, limit_max_requests_jitter=2, port=unused_tcp_port, http=http_protocol_cls
)
async with run_server(config) as server:
limit = server.limit_max_requests
assert limit is not None
assert 1 <= limit <= 3
async with httpx.AsyncClient() as client:
tasks = [client.get(f"http://127.0.0.1:{unused_tcp_port}") for _ in range(limit + 1)]
await asyncio.gather(*tasks)
assert f"Maximum request limit of {limit} exceeded. Terminating process." in caplog.text
@contextlib.asynccontextmanager
async def _raw_server(
*,
app: ASGIApplication,
port: int,
http_protocol_cls: type[H11Protocol | HttpToolsProtocol],
reset_contextvars: bool = False,
):
config = Config(app=app, port=port, loop="asyncio", http=http_protocol_cls, reset_contextvars=reset_contextvars)
server = Server(config=config)
task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.01)
reader, writer = await asyncio.open_connection("127.0.0.1", port)
async def extract_json_body(request: bytes):
writer.write(request)
await writer.drain()
status, *headers = (await reader.readuntil(b"\r\n\r\n")).split(b"\r\n")[:-2]
assert status == b"HTTP/1.1 200 OK"
content_length = next(int(h.split(b":", 1)[1]) for h in headers if h.lower().startswith(b"content-length:"))
return json.loads(await reader.readexactly(content_length))
try:
yield extract_json_body
finally:
writer.close()
await writer.wait_closed()
server.should_exit = True
await task
async def test_contextvars_preserved_by_default(
http_protocol_cls: type[H11Protocol | HttpToolsProtocol], unused_tcp_port: int
):
"""By default, context set outside the ASGI task is visible inside it."""
ctx: contextvars.ContextVar[str] = contextvars.ContextVar("ctx")
ctx.set("outer-value")
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "http"
while True:
message = await receive()
assert message["type"] == "http.request"
if not message["more_body"]:
break
body = json.dumps({"ctx": ctx.get("MISSING")}).encode("utf-8")
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("utf-8"))]
await send({"type": "http.response.start", "status": 200, "headers": headers})
await send({"type": "http.response.body", "body": body})
async with _raw_server(app=app, http_protocol_cls=http_protocol_cls, port=unused_tcp_port) as extract_json_body:
assert await extract_json_body(SIMPLE_GET_REQUEST) == {"ctx": "outer-value"}
async def test_reset_contextvars_asyncio(
http_protocol_cls: type[H11Protocol | HttpToolsProtocol], unused_tcp_port: int
):
"""With reset_contextvars=True, each ASGI run starts with a fresh context.
Non-regression test for https://github.com/encode/uvicorn/issues/2167.
"""
default_contextvars = {c.name for c in contextvars.copy_context().keys()}
ctx: contextvars.ContextVar[str] = contextvars.ContextVar("ctx")
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "http"
# initial context should be empty
initial_context = {
n: v for c, v in contextvars.copy_context().items() if (n := c.name) not in default_contextvars
}
# set any contextvar before the body is read
ctx.set(scope["path"])
while True:
message = await receive()
assert message["type"] == "http.request"
if not message["more_body"]:
break
body = json.dumps(initial_context).encode("utf-8")
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("utf-8"))]
await send({"type": "http.response.start", "status": 200, "headers": headers})
await send({"type": "http.response.body", "body": body})
# body larger than HIGH_WATER_LIMIT forces a reading pause on the main thread
# and a resumption inside the ASGI task, which is where the original pollution showed up.
large_body = b"a" * (HIGH_WATER_LIMIT + 1)
large_request = b"\r\n".join(
[
b"POST /large-body HTTP/1.1",
b"Host: example.org",
b"Content-Type: application/octet-stream",
f"Content-Length: {len(large_body)}".encode(),
b"",
large_body,
]
)
async with _raw_server(
app=app, http_protocol_cls=http_protocol_cls, port=unused_tcp_port, reset_contextvars=True
) as extract_json_body:
assert await extract_json_body(large_request) == {}
assert await extract_json_body(SIMPLE_GET_REQUEST) == {}

View File

@ -1,9 +1,17 @@
from __future__ import annotations
import ssl
from collections.abc import Callable
from typing import TypeAlias
import httpx
import pytest
from tests.utils import run_server
from uvicorn.config import Config
DefaultFactory: TypeAlias = Callable[[], ssl.SSLContext]
async def app(scope, receive, send):
assert scope["type"] == "http"
@ -92,3 +100,108 @@ async def test_run_password(
async with httpx.AsyncClient(verify=tls_ca_ssl_context) as client:
response = await client.get(f"https://127.0.0.1:{unused_tcp_port}")
assert response.status_code == 204
@pytest.mark.anyio
async def test_run_ssl_context_factory_default(
tls_ca_ssl_context: ssl.SSLContext,
tls_certificate_server_cert_path: str,
tls_certificate_private_key_path: str,
unused_tcp_port: int,
) -> None:
"""A factory that just delegates to the default factory should produce a working server."""
def ssl_context_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> ssl.SSLContext:
return default_ssl_context_factory()
config = Config(
app=app,
loop="asyncio",
limit_max_requests=1,
ssl_keyfile=tls_certificate_private_key_path,
ssl_certfile=tls_certificate_server_cert_path,
ssl_context_factory=ssl_context_factory,
port=unused_tcp_port,
)
async with run_server(config):
async with httpx.AsyncClient(verify=tls_ca_ssl_context) as client:
response = await client.get(f"https://127.0.0.1:{unused_tcp_port}")
assert response.status_code == 204
@pytest.mark.anyio
async def test_run_ssl_context_factory_custom(
tls_ca_ssl_context: ssl.SSLContext,
tls_certificate_server_cert_path: str,
tls_certificate_private_key_path: str,
unused_tcp_port: int,
) -> None:
"""A factory that builds its own SSLContext from scratch should work without ssl_keyfile/ssl_certfile."""
def ssl_context_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(tls_certificate_server_cert_path, tls_certificate_private_key_path)
return ctx
config = Config(
app=app,
loop="asyncio",
limit_max_requests=1,
ssl_context_factory=ssl_context_factory,
port=unused_tcp_port,
)
async with run_server(config):
async with httpx.AsyncClient(verify=tls_ca_ssl_context) as client:
response = await client.get(f"https://127.0.0.1:{unused_tcp_port}")
assert response.status_code == 204
def test_ssl_context_factory_mutates_default(
tls_certificate_server_cert_path: str,
tls_certificate_private_key_path: str,
) -> None:
"""The factory can call the default and mutate the result (e.g., bump TLS minimum version)."""
def ssl_context_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> ssl.SSLContext:
ctx = default_ssl_context_factory()
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
return ctx
config = Config(
app=app,
ssl_keyfile=tls_certificate_private_key_path,
ssl_certfile=tls_certificate_server_cert_path,
ssl_context_factory=ssl_context_factory,
)
config.load()
assert config.is_ssl
assert isinstance(config.ssl, ssl.SSLContext)
assert config.ssl.minimum_version == ssl.TLSVersion.TLSv1_3
def test_default_ssl_context_factory_requires_ssl_certfile() -> None:
"""Calling `default_ssl_context_factory()` without `ssl_certfile` raises a clear error."""
def ssl_context_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> ssl.SSLContext:
return default_ssl_context_factory()
config = Config(app=app, ssl_context_factory=ssl_context_factory)
with pytest.raises(RuntimeError, match="requires `ssl_certfile`"):
config.load()
def test_ssl_context_factory_must_return_ssl_context() -> None:
def bad_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> object:
return "not an SSLContext"
config = Config(app=app, ssl_context_factory=bad_factory) # type: ignore[arg-type]
with pytest.raises(TypeError, match="must return an `ssl.SSLContext`"):
config.load()
def test_is_ssl_true_when_only_factory_set() -> None:
def ssl_context_factory(config: Config, default_ssl_context_factory: DefaultFactory) -> ssl.SSLContext:
return ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # pragma: no cover
config = Config(app=app, ssl_context_factory=ssl_context_factory)
assert config.is_ssl is True

View File

@ -16,7 +16,8 @@ from uvicorn import Config, Server
async def run_server(config: Config, sockets: list[socket] | None = None) -> AsyncIterator[Server]:
server = Server(config=config)
task = asyncio.create_task(server.serve(sockets=sockets))
await asyncio.sleep(0.1)
while not server.started:
await asyncio.sleep(0.05)
try:
yield server
finally:

1875
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
from uvicorn.config import Config
from uvicorn.main import Server, main, run
__version__ = "0.36.1"
__version__ = "0.47.0"
__all__ = ["main", "run", "Config", "Server"]

View File

@ -5,6 +5,13 @@ import sys
from collections.abc import Callable, Coroutine
from typing import Any, TypeVar
__all__ = ["asyncio_run", "iscoroutinefunction"]
if sys.version_info >= (3, 14):
from inspect import iscoroutinefunction
else:
from asyncio import iscoroutinefunction
_T = TypeVar("_T")
if sys.version_info >= (3, 12):

View File

@ -8,9 +8,9 @@ from __future__ import annotations
import multiprocessing
import os
import sys
from collections.abc import Callable
from multiprocessing.context import SpawnProcess
from socket import socket
from typing import Callable
from uvicorn.config import Config
@ -79,6 +79,6 @@ def subprocess_started(
# Now we can call into `Server.run(sockets=sockets)`
target(sockets=sockets)
except KeyboardInterrupt: # pragma: no cover
# supress the exception to avoid a traceback from subprocess.Popen
# suppress the exception to avoid a traceback from subprocess.Popen
# the parent already expects us to end, so no vital information is lost
pass

View File

@ -32,8 +32,8 @@ from __future__ import annotations
import sys
import types
from collections.abc import Awaitable, Iterable, MutableMapping
from typing import Any, Callable, Literal, Optional, Protocol, TypedDict, Union
from collections.abc import Awaitable, Callable, Iterable, MutableMapping
from typing import Any, Literal, Protocol, TypedDict
if sys.version_info >= (3, 11): # pragma: py-lt-311
from typing import NotRequired
@ -42,9 +42,9 @@ else: # pragma: py-gte-311
# WSGI
Environ = MutableMapping[str, Any]
ExcInfo = tuple[type[BaseException], BaseException, Optional[types.TracebackType]]
StartResponse = Callable[[str, Iterable[tuple[str, str]], Optional[ExcInfo]], None]
WSGIApp = Callable[[Environ, StartResponse], Union[Iterable[bytes], BaseException]]
ExcInfo = tuple[type[BaseException], BaseException, types.TracebackType | None]
StartResponse = Callable[[str, Iterable[tuple[str, str]], ExcInfo | None], None]
WSGIApp = Callable[[Environ, StartResponse], Iterable[bytes] | BaseException]
# ASGI
@ -93,8 +93,8 @@ class LifespanScope(TypedDict):
state: NotRequired[dict[str, Any]]
WWWScope = Union[HTTPScope, WebSocketScope]
Scope = Union[HTTPScope, WebSocketScope, LifespanScope]
WWWScope = HTTPScope | WebSocketScope
Scope = HTTPScope | WebSocketScope | LifespanScope
class HTTPRequestEvent(TypedDict):
@ -159,7 +159,7 @@ class _WebSocketReceiveEventText(TypedDict):
text: str
WebSocketReceiveEvent = Union[_WebSocketReceiveEventBytes, _WebSocketReceiveEventText]
WebSocketReceiveEvent = _WebSocketReceiveEventBytes | _WebSocketReceiveEventText
class _WebSocketSendEventBytes(TypedDict):
@ -174,7 +174,7 @@ class _WebSocketSendEventText(TypedDict):
text: str
WebSocketSendEvent = Union[_WebSocketSendEventBytes, _WebSocketSendEventText]
WebSocketSendEvent = _WebSocketSendEventBytes | _WebSocketSendEventText
class WebSocketResponseStartEvent(TypedDict):
@ -227,36 +227,36 @@ class LifespanShutdownFailedEvent(TypedDict):
message: str
WebSocketEvent = Union[WebSocketReceiveEvent, WebSocketDisconnectEvent, WebSocketConnectEvent]
WebSocketEvent = WebSocketReceiveEvent | WebSocketDisconnectEvent | WebSocketConnectEvent
ASGIReceiveEvent = Union[
HTTPRequestEvent,
HTTPDisconnectEvent,
WebSocketConnectEvent,
WebSocketReceiveEvent,
WebSocketDisconnectEvent,
LifespanStartupEvent,
LifespanShutdownEvent,
]
ASGIReceiveEvent = (
HTTPRequestEvent
| HTTPDisconnectEvent
| WebSocketConnectEvent
| WebSocketReceiveEvent
| WebSocketDisconnectEvent
| LifespanStartupEvent
| LifespanShutdownEvent
)
ASGISendEvent = Union[
HTTPResponseStartEvent,
HTTPResponseBodyEvent,
HTTPResponseTrailersEvent,
HTTPServerPushEvent,
HTTPDisconnectEvent,
WebSocketAcceptEvent,
WebSocketSendEvent,
WebSocketResponseStartEvent,
WebSocketResponseBodyEvent,
WebSocketCloseEvent,
LifespanStartupCompleteEvent,
LifespanStartupFailedEvent,
LifespanShutdownCompleteEvent,
LifespanShutdownFailedEvent,
]
ASGISendEvent = (
HTTPResponseStartEvent
| HTTPResponseBodyEvent
| HTTPResponseTrailersEvent
| HTTPServerPushEvent
| HTTPDisconnectEvent
| WebSocketAcceptEvent
| WebSocketSendEvent
| WebSocketResponseStartEvent
| WebSocketResponseBodyEvent
| WebSocketCloseEvent
| LifespanStartupCompleteEvent
| LifespanStartupFailedEvent
| LifespanShutdownCompleteEvent
| LifespanShutdownFailedEvent
)
ASGIReceiveCallable = Callable[[], Awaitable[ASGIReceiveEvent]]
@ -270,12 +270,5 @@ class ASGI2Protocol(Protocol):
ASGI2Application = type[ASGI2Protocol]
ASGI3Application = Callable[
[
Scope,
ASGIReceiveCallable,
ASGISendCallable,
],
Awaitable[None],
]
ASGIApplication = Union[ASGI2Application, ASGI3Application]
ASGI3Application = Callable[[Scope, ASGIReceiveCallable, ASGISendCallable], Awaitable[None]]
ASGIApplication = ASGI2Application | ASGI3Application

View File

@ -9,13 +9,14 @@ import os
import socket
import ssl
import sys
from collections.abc import Awaitable
from collections.abc import Awaitable, Callable
from configparser import RawConfigParser
from pathlib import Path
from typing import IO, Any, Callable, Literal
from typing import IO, Any, Literal
import click
from uvicorn._compat import iscoroutinefunction
from uvicorn._types import ASGIApplication
from uvicorn.importer import ImportFromStringError, import_from_string
from uvicorn.logging import TRACE_LOG_LEVEL
@ -192,7 +193,7 @@ class Config:
ws_per_message_deflate: bool = True,
lifespan: LifespanType = "auto",
env_file: str | os.PathLike[str] | None = None,
log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_config: dict[str, Any] | str | os.PathLike[str] | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_level: str | int | None = None,
access_log: bool = True,
use_colors: bool | None = None,
@ -210,6 +211,7 @@ class Config:
root_path: str = "",
limit_concurrency: int | None = None,
limit_max_requests: int | None = None,
limit_max_requests_jitter: int = 0,
backlog: int = 2048,
timeout_keep_alive: int = 5,
timeout_notify: int = 30,
@ -221,11 +223,13 @@ class Config:
ssl_keyfile_password: str | None = None,
ssl_version: int = SSL_PROTOCOL_VERSION,
ssl_cert_reqs: int = ssl.CERT_NONE,
ssl_ca_certs: str | None = None,
ssl_ca_certs: str | os.PathLike[str] | None = None,
ssl_ciphers: str = "TLSv1",
ssl_context_factory: Callable[[Config, Callable[[], ssl.SSLContext]], ssl.SSLContext] | None = None,
headers: list[tuple[str, str]] | None = None,
factory: bool = False,
h11_max_incomplete_event_size: int | None = None,
reset_contextvars: bool = False,
):
self.app = app
self.host = host
@ -255,6 +259,7 @@ class Config:
self.root_path = root_path
self.limit_concurrency = limit_concurrency
self.limit_max_requests = limit_max_requests
self.limit_max_requests_jitter = limit_max_requests_jitter
self.backlog = backlog
self.timeout_keep_alive = timeout_keep_alive
self.timeout_notify = timeout_notify
@ -268,10 +273,12 @@ class Config:
self.ssl_cert_reqs = ssl_cert_reqs
self.ssl_ca_certs = ssl_ca_certs
self.ssl_ciphers = ssl_ciphers
self.ssl_context_factory = ssl_context_factory
self.headers: list[tuple[str, str]] = headers or []
self.encoded_headers: list[tuple[bytes, bytes]] = []
self.factory = factory
self.h11_max_incomplete_event_size = h11_max_incomplete_event_size
self.reset_contextvars = reset_contextvars
self.loaded = False
self.configure_logging()
@ -352,7 +359,7 @@ class Config:
@property
def is_ssl(self) -> bool:
return bool(self.ssl_keyfile or self.ssl_certfile)
return bool(self.ssl_keyfile or self.ssl_certfile or self.ssl_context_factory)
@property
def use_subprocess(self) -> bool:
@ -362,6 +369,9 @@ class Config:
logging.addLevelName(TRACE_LOG_LEVEL, "TRACE")
if self.log_config is not None:
if isinstance(self.log_config, os.PathLike):
self.log_config = os.fspath(self.log_config)
if isinstance(self.log_config, dict):
if self.use_colors in (True, False):
self.log_config["formatters"]["default"]["use_colors"] = self.use_colors
@ -372,9 +382,12 @@ class Config:
loaded_config = json.load(file)
logging.config.dictConfig(loaded_config)
elif isinstance(self.log_config, str) and self.log_config.endswith((".yaml", ".yml")):
# Install the PyYAML package or the uvicorn[standard] optional
# dependencies to enable this functionality.
import yaml
try:
import yaml
except ImportError as e:
raise ImportError(
"Install the PyYAML package or uvicorn[standard] to use `--log-config` with YAML files."
) from e
with open(self.log_config) as file:
loaded_config = yaml.safe_load(file)
@ -386,7 +399,7 @@ class Config:
if self.log_level is not None:
if isinstance(self.log_level, str):
log_level = LOG_LEVELS[self.log_level]
log_level = LOG_LEVELS[self.log_level.lower()]
else:
log_level = self.log_level
logging.getLogger("uvicorn.error").setLevel(log_level)
@ -396,12 +409,43 @@ class Config:
logging.getLogger("uvicorn.access").handlers = []
logging.getLogger("uvicorn.access").propagate = False
def load_app(self) -> Any:
"""Import the app and return it. Exits on failure."""
try:
return import_from_string(self.app)
except ImportFromStringError as exc:
logger.error("Error loading ASGI app. %s" % exc)
sys.exit(1)
def load(self) -> None:
assert not self.loaded
if self.is_ssl:
if self.ssl_context_factory is not None:
def default_factory() -> ssl.SSLContext:
if not self.ssl_certfile:
raise RuntimeError(
"`default_ssl_context_factory()` requires `ssl_certfile` to be set on `Config`. "
"Either pass `ssl_certfile` (and optionally `ssl_keyfile`) or build the `SSLContext` "
"directly inside `ssl_context_factory` without calling the default factory."
)
return create_ssl_context(
keyfile=self.ssl_keyfile,
certfile=self.ssl_certfile,
password=self.ssl_keyfile_password,
ssl_version=self.ssl_version,
cert_reqs=self.ssl_cert_reqs,
ca_certs=self.ssl_ca_certs,
ciphers=self.ssl_ciphers,
)
context = self.ssl_context_factory(self, default_factory)
if not isinstance(context, ssl.SSLContext):
raise TypeError(f"`ssl_context_factory` must return an `ssl.SSLContext`, got {type(context).__name__}")
self.ssl: ssl.SSLContext | None = context
elif self.is_ssl:
assert self.ssl_certfile
self.ssl: ssl.SSLContext | None = create_ssl_context(
self.ssl = create_ssl_context(
keyfile=self.ssl_keyfile,
certfile=self.ssl_certfile,
password=self.ssl_keyfile_password,
@ -434,11 +478,7 @@ class Config:
self.lifespan_class = import_from_string(LIFESPAN[self.lifespan])
try:
self.loaded_app = import_from_string(self.app)
except ImportFromStringError as exc:
logger.error("Error loading ASGI app. %s" % exc)
sys.exit(1)
self.loaded_app = self.load_app()
try:
self.loaded_app = self.loaded_app()
@ -456,10 +496,10 @@ class Config:
if inspect.isclass(self.loaded_app):
use_asgi_3 = hasattr(self.loaded_app, "__await__")
elif inspect.isfunction(self.loaded_app):
use_asgi_3 = inspect.iscoroutinefunction(self.loaded_app)
use_asgi_3 = iscoroutinefunction(self.loaded_app)
else:
call = getattr(self.loaded_app, "__call__", None)
use_asgi_3 = inspect.iscoroutinefunction(call)
use_asgi_3 = iscoroutinefunction(call)
self.interface = "asgi3" if use_asgi_3 else "asgi2"
if self.interface == "wsgi":
@ -497,7 +537,7 @@ class Config:
def bind_socket(self) -> socket.socket:
logger_args: list[str | int]
if self.uds: # pragma: py-win32
if self.uds is not None: # pragma: py-win32
path = self.uds
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
@ -512,7 +552,7 @@ class Config:
sock_name_format = "%s"
color_message = "Uvicorn running on " + click.style(sock_name_format, bold=True) + " (Press CTRL+C to quit)"
logger_args = [self.uds]
elif self.fd: # pragma: py-win32
elif self.fd is not None: # pragma: py-win32
sock = socket.fromfd(self.fd, socket.AF_UNIX, socket.SOCK_STREAM)
message = "Uvicorn running on socket %s (Press CTRL+C to quit)"
fd_name_format = "%s"

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import logging
from asyncio import Queue
from typing import Any, Union
from typing import Any
from uvicorn import Config
from uvicorn._types import (
@ -16,13 +16,13 @@ from uvicorn._types import (
LifespanStartupFailedEvent,
)
LifespanReceiveMessage = Union[LifespanStartupEvent, LifespanShutdownEvent]
LifespanSendMessage = Union[
LifespanStartupFailedEvent,
LifespanShutdownFailedEvent,
LifespanStartupCompleteEvent,
LifespanShutdownCompleteEvent,
]
LifespanReceiveMessage = LifespanStartupEvent | LifespanShutdownEvent
LifespanSendMessage = (
LifespanStartupFailedEvent
| LifespanShutdownFailedEvent
| LifespanStartupCompleteEvent
| LifespanShutdownCompleteEvent
)
STATE_TRANSITION_ERROR = "Got invalid state transition on lifespan protocol."
@ -38,7 +38,7 @@ class LifespanOn:
self.startup_event = asyncio.Event()
self.shutdown_event = asyncio.Event()
self.receive_queue: Queue[LifespanReceiveMessage] = asyncio.Queue()
self.error_occured = False
self.error_occurred = False
self.startup_failed = False
self.shutdown_failed = False
self.should_exit = False
@ -55,21 +55,21 @@ class LifespanOn:
await self.receive_queue.put(startup_event)
await self.startup_event.wait()
if self.startup_failed or (self.error_occured and self.config.lifespan == "on"):
if self.startup_failed or (self.error_occurred and self.config.lifespan == "on"):
self.logger.error("Application startup failed. Exiting.")
self.should_exit = True
else:
self.logger.info("Application startup complete.")
async def shutdown(self) -> None:
if self.error_occured:
if self.error_occurred:
return
self.logger.info("Waiting for application shutdown.")
shutdown_event: LifespanShutdownEvent = {"type": "lifespan.shutdown"}
await self.receive_queue.put(shutdown_event)
await self.shutdown_event.wait()
if self.shutdown_failed or (self.error_occured and self.config.lifespan == "on"):
if self.shutdown_failed or (self.error_occurred and self.config.lifespan == "on"):
self.logger.error("Application shutdown failed. Exiting.")
self.should_exit = True
else:
@ -86,7 +86,7 @@ class LifespanOn:
await app(scope, self.receive, self.send)
except BaseException as exc:
self.asgi = None
self.error_occured = True
self.error_occurred = True
if self.startup_failed or self.shutdown_failed:
return
if self.config.lifespan == "auto":

View File

@ -55,13 +55,13 @@ class ColourizedFormatter(logging.Formatter):
def formatMessage(self, record: logging.LogRecord) -> str:
recordcopy = copy(record)
levelname = recordcopy.levelname
seperator = " " * (8 - len(recordcopy.levelname))
separator = " " * (8 - len(recordcopy.levelname))
if self.use_colors:
levelname = self.color_level_name(levelname, recordcopy.levelno)
if "color_message" in recordcopy.__dict__:
recordcopy.msg = recordcopy.__dict__["color_message"]
recordcopy.__dict__["message"] = recordcopy.getMessage()
recordcopy.__dict__["levelprefix"] = levelname + ":" + seperator
recordcopy.__dict__["levelprefix"] = levelname + ":" + separator
return super().formatMessage(recordcopy)

View File

@ -7,8 +7,9 @@ import platform
import ssl
import sys
import warnings
from collections.abc import Callable
from configparser import RawConfigParser
from typing import IO, Any, Callable, get_args
from typing import IO, Any, get_args
import click
@ -272,11 +273,19 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
default=None,
help="Maximum number of requests to service before terminating the process.",
)
@click.option(
"--limit-max-requests-jitter",
type=int,
default=0,
help="Maximum jitter to add to limit_max_requests."
" Staggers worker restarts to avoid all workers restarting simultaneously.",
show_default=True,
)
@click.option(
"--timeout-keep-alive",
type=int,
default=5,
help="Close Keep-Alive connections if no new data is received within this timeout.",
help="Close Keep-Alive connections if no new data is received within this timeout (in seconds).",
show_default=True,
)
@click.option(
@ -363,6 +372,13 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
default=None,
help="For h11, the maximum number of bytes to buffer of an incomplete event.",
)
@click.option(
"--reset-contextvars",
is_flag=True,
default=False,
help="Run each ASGI request in a fresh contextvars.Context. Hides context set in the lifespan.",
show_default=True,
)
@click.option(
"--factory",
is_flag=True,
@ -404,6 +420,7 @@ def main(
limit_concurrency: int,
backlog: int,
limit_max_requests: int,
limit_max_requests_jitter: int,
timeout_keep_alive: int,
timeout_graceful_shutdown: int | None,
timeout_worker_healthcheck: int,
@ -418,6 +435,7 @@ def main(
use_colors: bool,
app_dir: str,
h11_max_incomplete_event_size: int | None,
reset_contextvars: bool,
factory: bool,
) -> None:
run(
@ -454,6 +472,7 @@ def main(
limit_concurrency=limit_concurrency,
backlog=backlog,
limit_max_requests=limit_max_requests,
limit_max_requests_jitter=limit_max_requests_jitter,
timeout_keep_alive=timeout_keep_alive,
timeout_graceful_shutdown=timeout_graceful_shutdown,
timeout_worker_healthcheck=timeout_worker_healthcheck,
@ -469,6 +488,7 @@ def main(
factory=factory,
app_dir=app_dir,
h11_max_incomplete_event_size=h11_max_incomplete_event_size,
reset_contextvars=reset_contextvars,
)
@ -496,7 +516,7 @@ def run(
reload_delay: float = 0.25,
workers: int | None = None,
env_file: str | os.PathLike[str] | None = None,
log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_config: dict[str, Any] | str | os.PathLike[str] | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_level: str | int | None = None,
access_log: bool = True,
proxy_headers: bool = True,
@ -507,6 +527,7 @@ def run(
limit_concurrency: int | None = None,
backlog: int = 2048,
limit_max_requests: int | None = None,
limit_max_requests_jitter: int = 0,
timeout_keep_alive: int = 5,
timeout_graceful_shutdown: int | None = None,
timeout_worker_healthcheck: int = 5,
@ -515,13 +536,15 @@ def run(
ssl_keyfile_password: str | None = None,
ssl_version: int = SSL_PROTOCOL_VERSION,
ssl_cert_reqs: int = ssl.CERT_NONE,
ssl_ca_certs: str | None = None,
ssl_ca_certs: str | os.PathLike[str] | None = None,
ssl_ciphers: str = "TLSv1",
ssl_context_factory: Callable[[Config, Callable[[], ssl.SSLContext]], ssl.SSLContext] | None = None,
headers: list[tuple[str, str]] | None = None,
use_colors: bool | None = None,
app_dir: str | None = None,
factory: bool = False,
h11_max_incomplete_event_size: int | None = None,
reset_contextvars: bool = False,
) -> None:
if app_dir is not None:
sys.path.insert(0, app_dir)
@ -560,6 +583,7 @@ def run(
limit_concurrency=limit_concurrency,
backlog=backlog,
limit_max_requests=limit_max_requests,
limit_max_requests_jitter=limit_max_requests_jitter,
timeout_keep_alive=timeout_keep_alive,
timeout_graceful_shutdown=timeout_graceful_shutdown,
timeout_worker_healthcheck=timeout_worker_healthcheck,
@ -570,18 +594,21 @@ def run(
ssl_cert_reqs=ssl_cert_reqs,
ssl_ca_certs=ssl_ca_certs,
ssl_ciphers=ssl_ciphers,
ssl_context_factory=ssl_context_factory,
headers=headers,
use_colors=use_colors,
factory=factory,
h11_max_incomplete_event_size=h11_max_incomplete_event_size,
reset_contextvars=reset_contextvars,
)
server = Server(config=config)
if (config.reload or config.workers > 1) and not isinstance(app, str):
logger = logging.getLogger("uvicorn.error")
logger.warning("You must pass the application as an import string to enable 'reload' or 'workers'.")
sys.exit(1)
config.load_app()
server = Server(config=config)
try:
if config.should_reload:
sock = config.bind_socket()
@ -591,8 +618,8 @@ def run(
Multiprocess(config, target=server.run, sockets=[sock]).run()
else:
server.run()
except KeyboardInterrupt:
pass # pragma: full coverage
except KeyboardInterrupt: # pragma: full coverage
pass
finally:
if config.uds and os.path.exists(config.uds):
os.remove(config.uds) # pragma: py-win32

View File

@ -45,16 +45,12 @@ class ProxyHeadersMiddleware:
if b"x-forwarded-for" in headers:
x_forwarded_for = headers[b"x-forwarded-for"].decode("latin1")
host = self.trusted_hosts.get_trusted_client_host(x_forwarded_for)
host, port = self.trusted_hosts.get_trusted_client_address(x_forwarded_for)
if host:
# If the x-forwarded-for header is empty then host is an empty string.
# Only set the client if we actually got something usable.
# See: https://github.com/Kludex/uvicorn/issues/1068
# We've lost the connecting client's port information by now,
# so only include the host.
port = 0
scope["client"] = (host, port)
return await self.app(scope, receive, send)
@ -64,6 +60,41 @@ def _parse_raw_hosts(value: str) -> list[str]:
return [item.strip() for item in value.split(",")]
def _parse_host_port(value: str) -> tuple[str, int]:
"""Parse a forwarded host value into host and optional port.
Accepts bare IPs, IPv4 `host:port`, and bracketed IPv6 `[host]:port`.
Any unrecognized or malformed value is treated conservatively and returned
without a port so trust checks do not silently normalize arbitrary input.
"""
if value.startswith("["):
bracket_end = value.find("]")
if bracket_end == -1:
return value, 0
host = value[1:bracket_end]
remainder = value[bracket_end + 1 :]
if not remainder:
return host, 0
if not remainder.startswith(":"):
return value, 0
try:
return host, int(remainder[1:])
except ValueError:
return host, 0
if value.count(":") == 1:
host, port = value.rsplit(":", 1)
try:
return host, int(port)
except ValueError:
return value, 0
return value, 0
class _TrustedHosts:
"""Container for trusted hosts and networks"""
@ -122,21 +153,22 @@ class _TrustedHosts:
except ValueError:
return host in self.trusted_literals
def get_trusted_client_host(self, x_forwarded_for: str) -> str:
"""Extract the client host from x_forwarded_for header
def get_trusted_client_address(self, x_forwarded_for: str) -> tuple[str, int]:
"""Extract the client address from x_forwarded_for header.
In general this is the first "untrusted" host in the forwarded for list.
"""
x_forwarded_for_hosts = _parse_raw_hosts(x_forwarded_for)
if self.always_trust:
return x_forwarded_for_hosts[0]
return _parse_host_port(x_forwarded_for_hosts[0])
# Note: each proxy appends to the header list so check it in reverse order
for host in reversed(x_forwarded_for_hosts):
for host_port in reversed(x_forwarded_for_hosts):
host, port = _parse_host_port(host_port)
if host not in self:
return host
return host, port
# All hosts are trusted meaning that the client was also a trusted proxy
# See https://github.com/Kludex/uvicorn/issues/1068#issuecomment-855371576
return x_forwarded_for_hosts[0]
return _parse_host_port(x_forwarded_for_hosts[0])

View File

@ -150,7 +150,7 @@ class WSGIResponder:
if message is None:
return
await send(message)
else:
else: # pragma: no cover
await self.send_event.wait()
self.send_event.clear()

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import asyncio
import contextvars
import http
import logging
from typing import Any, Callable, Literal, cast
import sys
from collections.abc import Callable
from typing import Any, Literal
from urllib.parse import unquote
import h11
@ -75,7 +78,7 @@ class H11Protocol(asyncio.Protocol):
# Per-connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.flow: FlowControl = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None
@ -247,7 +250,16 @@ class H11Protocol(asyncio.Protocol):
message_event=asyncio.Event(),
on_response=self.on_response_complete,
)
task = self.loop.create_task(self.cycle.run_asgi(app))
if self.config.reset_contextvars:
# Opt-in workaround for https://github.com/python/cpython/issues/140947:
# asyncio can leak context vars between tasks. Hides context set in the
# lifespan or by external instrumentation.
if sys.version_info >= (3, 11): # pragma: py-lt-311
task = self.loop.create_task(self.cycle.run_asgi(app), context=contextvars.Context())
else: # pragma: py-gte-311
task = contextvars.Context().run(self.loop.create_task, self.cycle.run_asgi(app))
else:
task = self.loop.create_task(self.cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
@ -390,7 +402,7 @@ class RequestResponseCycle:
self.waiting_for_100_continue = conn.they_are_waiting_for_100_continue
# Request state
self.body = b""
self.body = bytearray()
self.more_body = True
# Response state
@ -445,8 +457,6 @@ class RequestResponseCycle:
# ASGI interface
async def send(self, message: ASGISendEvent) -> None:
message_type = message["type"]
if self.flow.write_paused and not self.disconnected:
await self.flow.drain() # pragma: full coverage
@ -455,10 +465,8 @@ class RequestResponseCycle:
if not self.response_started:
# Sending response status line and headers
if message_type != "http.response.start":
msg = "Expected ASGI message 'http.response.start', but got '%s'."
raise RuntimeError(msg % message_type)
message = cast("HTTPResponseStartEvent", message)
if message["type"] != "http.response.start":
raise RuntimeError(f"Expected ASGI message 'http.response.start', but got '{message['type']}'.")
self.response_started = True
self.waiting_for_100_continue = False
@ -487,10 +495,8 @@ class RequestResponseCycle:
elif not self.response_complete:
# Sending response body
if message_type != "http.response.body":
msg = "Expected ASGI message 'http.response.body', but got '%s'."
raise RuntimeError(msg % message_type)
message = cast("HTTPResponseBodyEvent", message)
if message["type"] != "http.response.body":
raise RuntimeError(f"Expected ASGI message 'http.response.body', but got '{message['type']}'.")
body = message.get("body", b"")
more_body = message.get("more_body", False)
@ -509,8 +515,7 @@ class RequestResponseCycle:
else:
# Response already sent
msg = "Unexpected ASGI message '%s' sent, after response already completed."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
if self.response_complete:
if self.conn.our_state is h11.MUST_CLOSE or not self.keep_alive:
@ -534,10 +539,6 @@ class RequestResponseCycle:
if self.disconnected or self.response_complete:
return {"type": "http.disconnect"}
message: HTTPRequestEvent = {
"type": "http.request",
"body": self.body,
"more_body": self.more_body,
}
self.body = b""
message: HTTPRequestEvent = {"type": "http.request", "body": bytes(self.body), "more_body": self.more_body}
self.body = bytearray()
return message

View File

@ -1,13 +1,16 @@
from __future__ import annotations
import asyncio
import contextvars
import http
import logging
import re
import sys
import urllib
from asyncio.events import TimerHandle
from collections import deque
from typing import Any, Callable, Literal, cast
from collections.abc import Callable
from typing import Any, Literal
import httptools
@ -16,7 +19,6 @@ from uvicorn._types import (
ASGIReceiveEvent,
ASGISendEvent,
HTTPRequestEvent,
HTTPResponseStartEvent,
HTTPScope,
)
from uvicorn.config import Config
@ -25,7 +27,7 @@ from uvicorn.protocols.http.flow_control import CLOSE_HEADER, HIGH_WATER_LIMIT,
from uvicorn.protocols.utils import get_client_addr, get_local_addr, get_path_with_query_string, get_remote_addr, is_ssl
from uvicorn.server import ServerState
HEADER_RE = re.compile(b'[\x00-\x1f\x7f()<>@,;:[]={} \t\\"]')
HEADER_RE = re.compile(b'[\x00-\x1f\x7f()<>@,;:\\[\\]={} \t\\\\"]')
HEADER_VALUE_RE = re.compile(b"[\x00-\x08\x0a-\x1f\x7f]")
@ -83,7 +85,7 @@ class HttpToolsProtocol(asyncio.Protocol):
# Per-connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.flow: FlowControl = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None
self.pipeline: deque[tuple[RequestResponseCycle, ASGI3Application]] = deque()
@ -287,14 +289,26 @@ class HttpToolsProtocol(asyncio.Protocol):
)
if existing_cycle is None or existing_cycle.response_complete:
# Standard case - start processing the request.
task = self.loop.create_task(self.cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
self._start_asgi_task(self.cycle, app)
else:
# Pipelined HTTP requests need to be queued up.
self.flow.pause_reading()
self.pipeline.appendleft((self.cycle, app))
def _start_asgi_task(self, cycle: RequestResponseCycle, app: ASGI3Application) -> None:
if self.config.reset_contextvars:
# Opt-in workaround for https://github.com/python/cpython/issues/140947:
# asyncio can leak context vars between tasks. Hides context set in the
# lifespan or by external instrumentation.
if sys.version_info >= (3, 11): # pragma: py-lt-311
task = self.loop.create_task(cycle.run_asgi(app), context=contextvars.Context())
else: # pragma: py-gte-311
task = contextvars.Context().run(self.loop.create_task, cycle.run_asgi(app))
else:
task = self.loop.create_task(cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
def on_body(self, body: bytes) -> None:
if (self.parser.should_upgrade() and self._should_upgrade()) or self.cycle.response_complete:
return
@ -325,9 +339,7 @@ class HttpToolsProtocol(asyncio.Protocol):
# Keep-Alive timeout instead.
if self.pipeline:
cycle, app = self.pipeline.pop()
task = self.loop.create_task(cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
self._start_asgi_task(cycle, app)
else:
self.timeout_keep_alive_task = self.loop.call_later(
self.timeout_keep_alive, self.timeout_keep_alive_handler
@ -394,7 +406,7 @@ class RequestResponseCycle:
self.waiting_for_100_continue = expect_100_continue
# Request state
self.body = b""
self.body = bytearray()
self.more_body = True
# Response state
@ -448,8 +460,6 @@ class RequestResponseCycle:
# ASGI interface
async def send(self, message: ASGISendEvent) -> None:
message_type = message["type"]
if self.flow.write_paused and not self.disconnected:
await self.flow.drain() # pragma: full coverage
@ -458,10 +468,8 @@ class RequestResponseCycle:
if not self.response_started:
# Sending response status line and headers
if message_type != "http.response.start":
msg = "Expected ASGI message 'http.response.start', but got '%s'."
raise RuntimeError(msg % message_type)
message = cast("HTTPResponseStartEvent", message)
if message["type"] != "http.response.start":
raise RuntimeError(f"Expected ASGI message 'http.response.start', but got '{message['type']}'.")
self.response_started = True
self.waiting_for_100_continue = False
@ -512,11 +520,10 @@ class RequestResponseCycle:
elif not self.response_complete:
# Sending response body
if message_type != "http.response.body":
msg = "Expected ASGI message 'http.response.body', but got '%s'."
raise RuntimeError(msg % message_type)
if message["type"] != "http.response.body":
raise RuntimeError(f"Expected ASGI message 'http.response.body', but got '{message['type']}'.")
body = cast(bytes, message.get("body", b""))
body = message.get("body", b"")
more_body = message.get("more_body", False)
# Write response body
@ -550,8 +557,7 @@ class RequestResponseCycle:
else:
# Response already sent
msg = "Unexpected ASGI message '%s' sent, after response already completed."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
async def receive(self) -> ASGIReceiveEvent:
if self.waiting_for_100_continue and not self.transport.is_closing():
@ -565,6 +571,6 @@ class RequestResponseCycle:
if self.disconnected or self.response_complete:
return {"type": "http.disconnect"}
message: HTTPRequestEvent = {"type": "http.request", "body": self.body, "more_body": self.more_body}
self.body = b""
message: HTTPRequestEvent = {"type": "http.request", "body": bytes(self.body), "more_body": self.more_body}
self.body = bytearray()
return message

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import socket
import urllib.parse
from uvicorn._types import WWWScope
@ -10,7 +11,7 @@ class ClientDisconnected(OSError): ...
def get_remote_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
socket_info = transport.get_extra_info("socket")
socket_info: socket.socket | None = transport.get_extra_info("socket")
if socket_info is not None:
try:
info = socket_info.getpeername()
@ -21,20 +22,25 @@ def get_remote_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
return None
info = transport.get_extra_info("peername")
if info is not None and isinstance(info, (list, tuple)) and len(info) == 2:
if info is not None and isinstance(info, list | tuple) and len(info) == 2:
return (str(info[0]), int(info[1]))
return None
def get_local_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
socket_info = transport.get_extra_info("socket")
def get_local_addr(transport: asyncio.Transport) -> tuple[str, int | None] | None:
socket_info: socket.socket | None = transport.get_extra_info("socket")
if socket_info is not None:
info = socket_info.getsockname()
return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None
if isinstance(info, tuple):
return (str(info[0]), int(info[1]))
if isinstance(info, str):
return (info, None)
return None
info = transport.get_extra_info("sockname")
if info is not None and isinstance(info, (list, tuple)) and len(info) == 2:
if info is not None and isinstance(info, list | tuple) and len(info) == 2:
return (str(info[0]), int(info[1]))
if isinstance(info, str):
return (info, None)
return None

View File

@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from typing import Callable
from collections.abc import Callable
AutoWebSocketsProtocol: Callable[..., asyncio.Protocol] | None
try:

View File

@ -4,7 +4,7 @@ import asyncio
import http
import logging
from collections.abc import Sequence
from typing import Any, Literal, Optional, cast
from typing import Any, Literal, cast
from urllib.parse import unquote
import websockets
@ -20,15 +20,10 @@ from websockets.typing import Subprotocol
from uvicorn._types import (
ASGI3Application,
ASGISendEvent,
WebSocketAcceptEvent,
WebSocketCloseEvent,
WebSocketConnectEvent,
WebSocketDisconnectEvent,
WebSocketReceiveEvent,
WebSocketResponseBodyEvent,
WebSocketResponseStartEvent,
WebSocketScope,
WebSocketSendEvent,
)
from uvicorn.config import Config
from uvicorn.logging import TRACE_LOG_LEVEL
@ -82,7 +77,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -244,7 +239,6 @@ class WebSocketProtocol(WebSocketServerProtocol):
result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value]
except ClientDisconnected: # pragma: full coverage
self.closed_event.set()
self.transport.close()
except BaseException:
self.closed_event.set()
self.logger.exception("Exception in ASGI application\n")
@ -252,31 +246,26 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.send_500_response()
else:
await self.handshake_completed_event.wait()
self.transport.close()
else:
self.closed_event.set()
if not self.handshake_started_event.is_set():
self.logger.error("ASGI callable returned without sending handshake.")
self.send_500_response()
self.transport.close()
elif result is not None:
self.logger.error("ASGI callable should return None, but returned '%s'.", result)
await self.handshake_completed_event.wait()
self.transport.close()
await self.handshake_completed_event.wait()
self.transport.close()
async def asgi_send(self, message: ASGISendEvent) -> None:
message_type = message["type"]
if not self.handshake_started_event.is_set():
if message_type == "websocket.accept":
message = cast("WebSocketAcceptEvent", message)
if message["type"] == "websocket.accept":
self.logger.info(
'%s - "WebSocket %s" [accepted]',
get_client_addr(self.scope),
get_path_with_query_string(self.scope),
)
self.initial_response = None
self.accepted_subprotocol = cast(Optional[Subprotocol], message.get("subprotocol"))
self.accepted_subprotocol = cast(Subprotocol | None, message.get("subprotocol"))
if "headers" in message:
self.extra_headers.extend(
# ASGI spec requires bytes
@ -286,8 +275,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
)
self.handshake_started_event.set()
elif message_type == "websocket.close":
message = cast("WebSocketCloseEvent", message)
elif message["type"] == "websocket.close":
self.logger.info(
'%s - "WebSocket %s" 403',
get_client_addr(self.scope),
@ -297,8 +285,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.handshake_started_event.set()
self.closed_event.set()
elif message_type == "websocket.http.response.start":
message = cast("WebSocketResponseStartEvent", message)
elif message["type"] == "websocket.http.response.start":
self.logger.info(
'%s - "WebSocket %s" %d',
get_client_addr(self.scope),
@ -314,50 +301,48 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.handshake_started_event.set()
else:
msg = (
raise RuntimeError(
"Expected ASGI message 'websocket.accept', 'websocket.close', "
"or 'websocket.http.response.start' but got '%s'."
f"or 'websocket.http.response.start' but got '{message['type']}'."
)
raise RuntimeError(msg % message_type)
elif not self.closed_event.is_set() and self.initial_response is None:
await self.handshake_completed_event.wait()
try:
if message_type == "websocket.send":
message = cast("WebSocketSendEvent", message)
if message["type"] == "websocket.send":
bytes_data = message.get("bytes")
text_data = message.get("text")
data = text_data if bytes_data is None else bytes_data
await self.send(data) # type: ignore[arg-type]
elif message_type == "websocket.close":
message = cast("WebSocketCloseEvent", message)
elif message["type"] == "websocket.close":
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
await self.close(code, reason)
self.closed_event.set()
else:
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
except ConnectionClosed as exc:
raise ClientDisconnected from exc
elif self.initial_response is not None:
if message_type == "websocket.http.response.body":
message = cast("WebSocketResponseBodyEvent", message)
if message["type"] == "websocket.http.response.body":
body = self.initial_response[2] + message["body"]
self.initial_response = self.initial_response[:2] + (body,)
if not message.get("more_body", False):
self.closed_event.set()
else:
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
else:
msg = "Unexpected ASGI message '%s', after sending 'websocket.close' or response already completed."
raise RuntimeError(msg % message_type)
raise RuntimeError(
f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close' "
"or response already completed."
)
async def asgi_receive(self) -> WebSocketDisconnectEvent | WebSocketConnectEvent | WebSocketReceiveEvent:
if not self.connect_sent:

View File

@ -2,7 +2,10 @@ from __future__ import annotations
import asyncio
import logging
import random
import struct
import sys
from asyncio import TimerHandle
from asyncio.transports import BaseTransport, Transport
from http import HTTPStatus
from typing import Any, Literal, cast
@ -17,17 +20,13 @@ from websockets.server import ServerProtocol
from uvicorn._types import (
ASGIReceiveEvent,
ASGISendEvent,
WebSocketAcceptEvent,
WebSocketCloseEvent,
WebSocketResponseBodyEvent,
WebSocketResponseStartEvent,
WebSocketScope,
WebSocketSendEvent,
)
from uvicorn.config import Config
from uvicorn.logging import TRACE_LOG_LEVEL
from uvicorn.protocols.utils import (
ClientDisconnected,
get_client_addr,
get_local_addr,
get_path_with_query_string,
get_remote_addr,
@ -66,7 +65,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -96,8 +95,17 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.writable = asyncio.Event()
self.writable.set()
# Keepalive state
self.ping_interval = config.ws_ping_interval
self.ping_timeout = config.ws_ping_timeout
self.ping_timer: TimerHandle | None = None
self.pong_timer: TimerHandle | None = None
self.pending_ping_payload: bytes | None = None
self.ping_sent_at: float = 0.0
self.last_ping_rtt: float = 0.0
# Buffers
self.bytes = b""
self.bytes = bytearray()
def connection_made(self, transport: BaseTransport) -> None:
"""Called when a connection is made."""
@ -113,6 +121,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket connection made", prefix)
def connection_lost(self, exc: Exception | None) -> None:
self.stop_keepalive()
code = 1005 if self.handshake_complete else 1006
self.queue.put_nowait({"type": "websocket.disconnect", "code": code})
self.connections.remove(self)
@ -129,6 +138,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
pass
def shutdown(self) -> None:
self.stop_keepalive()
if self.handshake_complete:
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
self.conn.send_close(1012)
@ -159,7 +169,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
elif event.opcode == Opcode.PING:
self.handle_ping()
elif event.opcode == Opcode.PONG:
pass # pragma: no cover
self.handle_pong(event)
elif event.opcode == Opcode.CLOSE:
self.handle_close(event)
else:
@ -187,14 +197,14 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
raw_path, _, query_string = event.path.partition("?")
self.scope: WebSocketScope = {
"type": "websocket",
"asgi": {"version": self.config.asgi_version, "spec_version": "2.3"},
"asgi": {"version": self.config.asgi_version, "spec_version": "2.4"},
"http_version": "1.1",
"scheme": self.scheme,
"server": self.server,
"client": self.client,
"root_path": self.root_path,
"path": unquote(raw_path),
"raw_path": raw_path.encode("ascii"),
"path": self.root_path + unquote(raw_path),
"raw_path": self.root_path.encode("ascii") + raw_path.encode("ascii"),
"query_string": query_string.encode("ascii"),
"headers": headers,
"subprotocols": event.headers.get_all("Sec-WebSocket-Protocol"),
@ -206,19 +216,19 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
task.add_done_callback(self.on_task_complete)
self.tasks.add(task)
def handle_cont(self, event: Frame) -> None: # pragma: no cover
self.bytes += event.data
def handle_cont(self, event: Frame) -> None:
self.bytes.extend(event.data)
if event.fin:
self.send_receive_event_to_app()
def handle_text(self, event: Frame) -> None:
self.bytes = event.data
self.bytes = bytearray(event.data)
self.curr_msg_data_type: Literal["text", "bytes"] = "text"
if event.fin:
self.send_receive_event_to_app()
def handle_bytes(self, event: Frame) -> None:
self.bytes = event.data
self.bytes = bytearray(event.data)
self.curr_msg_data_type = "bytes"
if event.fin:
self.send_receive_event_to_app()
@ -233,7 +243,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.handle_parser_exception()
return
else:
self.queue.put_nowait({"type": "websocket.receive", "bytes": self.bytes})
self.queue.put_nowait({"type": "websocket.receive", "bytes": bytes(self.bytes)})
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()
@ -242,6 +252,67 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
def handle_pong(self, event: Frame) -> None:
# Ignore unsolicited pongs and stale pongs whose payload doesn't match the ping currently in flight
if self.pending_ping_payload is None or bytes(event.data) != self.pending_ping_payload:
return # pragma: no cover
self.last_ping_rtt = self.loop.time() - self.ping_sent_at
self.pending_ping_payload = None
# The peer answered in time; cancel the pong deadline and chain the next ping. This `schedule_ping()` call is
# what keeps the keepalive loop running when ping_timeout is set. When ping_timeout is None the next ping is
# already scheduled by `send_keepalive_ping`, so we must not schedule a duplicate here.
if self.pong_timer is not None:
self.pong_timer.cancel()
self.pong_timer = None
self.schedule_ping()
def start_keepalive(self) -> None:
if self.ping_interval is not None and self.ping_interval > 0:
self.schedule_ping()
def stop_keepalive(self) -> None:
if self.ping_timer is not None:
self.ping_timer.cancel()
self.ping_timer = None
if self.pong_timer is not None: # pragma: no cover
self.pong_timer.cancel()
self.pong_timer = None
self.pending_ping_payload = None
def schedule_ping(self) -> None:
assert self.ping_interval is not None
delay = max(0.0, self.ping_interval - self.last_ping_rtt)
self.ping_timer = self.loop.call_later(delay, self.send_keepalive_ping)
def send_keepalive_ping(self) -> None:
self.ping_timer = None
if self.close_sent or self.transport.is_closing(): # pragma: no cover
return
# Random 4-byte payload identifies this ping; `handle_pong` uses it to ignore stale or unsolicited pongs.
# See https://github.com/python-websockets/websockets/blob/4d229bf9f583d593aa103287aee0a77c9fbc3a79/src/websockets/asyncio/connection.py#L624
self.pending_ping_payload = struct.pack("!I", random.getrandbits(32))
self.ping_sent_at = self.loop.time()
self.conn.send_ping(self.pending_ping_payload)
self.transport.write(b"".join(self.conn.data_to_send()))
if self.ping_timeout is not None:
self.pong_timer = self.loop.call_later(self.ping_timeout, self.keepalive_timeout)
else: # pragma: no cover
self.schedule_ping()
def keepalive_timeout(self) -> None:
self.pong_timer = None
self.pending_ping_payload = None
if self.close_sent or self.transport.is_closing(): # pragma: no cover
return
if self.logger.level <= TRACE_LOG_LEVEL:
prefix = "%s:%d - " % self.client if self.client else ""
self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket keepalive ping timeout", prefix)
self.conn.fail(1011, "keepalive ping timeout")
self.transport.write(b"".join(self.conn.data_to_send()))
self.close_sent = True
self.transport.close()
def handle_close(self, event: Frame) -> None:
if not self.close_sent and not self.transport.is_closing():
assert self.conn.close_rcvd is not None
@ -271,19 +342,17 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
try:
result = await self.app(self.scope, self.receive, self.send)
except ClientDisconnected:
self.transport.close() # pragma: no cover
pass # pragma: full coverage
except BaseException:
self.logger.exception("Exception in ASGI application\n")
self.send_500_response()
self.transport.close()
else:
if not self.handshake_complete:
self.logger.error("ASGI callable returned without completing handshake.")
self.send_500_response()
self.transport.close()
elif result is not None:
self.logger.error("ASGI callable should return None, but returned '%s'.", result)
self.transport.close()
self.transport.close()
def send_500_response(self) -> None:
if self.initial_response or self.handshake_complete:
@ -296,18 +365,15 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
async def send(self, message: ASGISendEvent) -> None:
await self.writable.wait()
message_type = message["type"]
if not self.handshake_complete and self.initial_response is None:
if message_type == "websocket.accept":
message = cast(WebSocketAcceptEvent, message)
if message["type"] == "websocket.accept":
self.logger.info(
'%s - "WebSocket %s" [accepted]',
self.scope["client"],
get_client_addr(self.scope),
get_path_with_query_string(self.scope),
)
headers = [
(name.decode("latin-1").lower(), value.decode("latin-1").lower())
(name.decode("latin-1").lower(), value.decode("latin-1"))
for name, value in (self.default_headers + list(message.get("headers", [])))
]
accepted_subprotocol = message.get("subprotocol")
@ -320,13 +386,13 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.conn.send_response(self.response)
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
self.start_keepalive()
elif message_type == "websocket.close":
message = cast(WebSocketCloseEvent, message)
elif message["type"] == "websocket.close":
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1006})
self.logger.info(
'%s - "WebSocket %s" 403',
self.scope["client"],
get_client_addr(self.scope),
get_path_with_query_string(self.scope),
)
response = self.conn.reject(HTTPStatus.FORBIDDEN, "")
@ -336,13 +402,12 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.handshake_complete = True
self.transport.write(b"".join(output))
self.transport.close()
elif message_type == "websocket.http.response.start" and self.initial_response is None:
message = cast(WebSocketResponseStartEvent, message)
elif message["type"] == "websocket.http.response.start" and self.initial_response is None:
if not (100 <= message["status"] < 600):
raise RuntimeError("Invalid HTTP status code '%d' in response." % message["status"])
self.logger.info(
'%s - "WebSocket %s" %d',
self.scope["client"],
get_client_addr(self.scope),
get_path_with_query_string(self.scope),
message["status"],
)
@ -352,44 +417,41 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
]
self.initial_response = (message["status"], headers, b"")
else:
msg = (
raise RuntimeError(
"Expected ASGI message 'websocket.accept', 'websocket.close' "
"or 'websocket.http.response.start' "
"but got '%s'."
f"or 'websocket.http.response.start' but got '{message['type']}'."
)
raise RuntimeError(msg % message_type)
elif not self.close_sent and self.initial_response is None:
try:
if message_type == "websocket.send":
message = cast(WebSocketSendEvent, message)
if message["type"] == "websocket.send":
bytes_data = message.get("bytes")
text_data = message.get("text")
if text_data:
self.conn.send_text(text_data.encode())
elif bytes_data:
if bytes_data is not None:
self.conn.send_binary(bytes_data)
elif text_data is not None:
self.conn.send_text(text_data.encode())
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
elif message_type == "websocket.close" and not self.transport.is_closing():
message = cast(WebSocketCloseEvent, message)
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
self.queue.put_nowait({"type": "websocket.disconnect", "code": code, "reason": reason})
self.conn.send_close(code, reason)
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
self.close_sent = True
self.transport.close()
elif message["type"] == "websocket.close":
if not self.transport.is_closing():
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
self.queue.put_nowait({"type": "websocket.disconnect", "code": code, "reason": reason})
self.conn.send_close(code, reason)
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
self.close_sent = True
self.transport.close()
else:
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
except InvalidState:
raise ClientDisconnected()
elif self.initial_response is not None:
if message_type == "websocket.http.response.body":
message = cast(WebSocketResponseBodyEvent, message)
if message["type"] == "websocket.http.response.body":
body = self.initial_response[2] + message["body"]
self.initial_response = self.initial_response[:2] + (body,)
if not message.get("more_body", False):
@ -402,12 +464,10 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.transport.write(b"".join(output))
self.transport.close()
else: # pragma: no cover
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
else:
msg = "Unexpected ASGI message '%s', after sending 'websocket.close'."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close'.")
async def receive(self) -> ASGIReceiveEvent:
message = await self.queue.get()

View File

@ -2,6 +2,10 @@ from __future__ import annotations
import asyncio
import logging
import random
import struct
from asyncio import TimerHandle
from io import BytesIO, StringIO
from typing import Any, Literal, cast
from urllib.parse import unquote
@ -11,17 +15,7 @@ from wsproto.connection import ConnectionState
from wsproto.extensions import Extension, PerMessageDeflate
from wsproto.utilities import LocalProtocolError, RemoteProtocolError
from uvicorn._types import (
ASGI3Application,
ASGISendEvent,
WebSocketAcceptEvent,
WebSocketCloseEvent,
WebSocketEvent,
WebSocketResponseBodyEvent,
WebSocketResponseStartEvent,
WebSocketScope,
WebSocketSendEvent,
)
from uvicorn._types import ASGI3Application, ASGISendEvent, WebSocketEvent, WebSocketReceiveEvent, WebSocketScope
from uvicorn.config import Config
from uvicorn.logging import TRACE_LOG_LEVEL
from uvicorn.protocols.utils import (
@ -35,6 +29,36 @@ from uvicorn.protocols.utils import (
from uvicorn.server import ServerState
class FrameTooLargeError(Exception):
"""Raised when accumulated websocket message bytes exceed `ws_max_size`."""
class WebsocketBuffer:
def __init__(self, max_length: int) -> None:
self.value: BytesIO | StringIO | None = None
self.length = 0
self.max_length = max_length
def extend(self, event: events.TextMessage | events.BytesMessage) -> None:
if self.value is None:
self.value = StringIO() if isinstance(event, events.TextMessage) else BytesIO()
self.value.write(event.data) # type: ignore[arg-type]
# `ws_max_size` is a byte budget, so count UTF-8 bytes for text.
self.length += len(event.data.encode()) if isinstance(event, events.TextMessage) else len(event.data)
if self.length > self.max_length:
raise FrameTooLargeError
def clear(self) -> None:
self.value = None
self.length = 0
def to_message(self) -> WebSocketReceiveEvent:
if isinstance(self.value, StringIO):
return {"type": "websocket.receive", "text": self.value.getvalue()}
assert isinstance(self.value, BytesIO)
return {"type": "websocket.receive", "bytes": self.value.getvalue()}
class WSProtocol(asyncio.Protocol):
def __init__(
self,
@ -60,7 +84,7 @@ class WSProtocol(asyncio.Protocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int] | None = None
self.server: tuple[str, int | None] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -78,15 +102,21 @@ class WSProtocol(asyncio.Protocol):
self.writable = asyncio.Event()
self.writable.set()
# Buffers
self.bytes = b""
self.text = ""
# Keepalive state
self.ping_interval = config.ws_ping_interval
self.ping_timeout = config.ws_ping_timeout
self.ping_timer: TimerHandle | None = None
self.pong_timer: TimerHandle | None = None
self.pending_ping_payload: bytes | None = None
self.ping_sent_at: float = 0.0
self.last_ping_rtt: float = 0.0
# Buffer
self.buffer = WebsocketBuffer(self.config.ws_max_size)
# Protocol interface
def connection_made( # type: ignore[override]
self, transport: asyncio.Transport
) -> None:
def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override]
self.connections.add(self)
self.transport = transport
self.server = get_local_addr(transport)
@ -98,6 +128,7 @@ class WSProtocol(asyncio.Protocol):
self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket connection made", prefix)
def connection_lost(self, exc: Exception | None) -> None:
self.stop_keepalive()
code = 1005 if self.handshake_complete else 1006
self.queue.put_nowait({"type": "websocket.disconnect", "code": code})
self.connections.remove(self)
@ -125,16 +156,18 @@ class WSProtocol(asyncio.Protocol):
def handle_events(self) -> None:
for event in self.conn.events():
if self.close_sent:
return
if isinstance(event, events.Request):
self.handle_connect(event)
elif isinstance(event, events.TextMessage):
self.handle_text(event)
elif isinstance(event, events.BytesMessage):
self.handle_bytes(event)
elif isinstance(event, (events.TextMessage, events.BytesMessage)):
self.handle_message(event)
elif isinstance(event, events.CloseConnection):
self.handle_close(event)
elif isinstance(event, events.Ping):
self.handle_ping(event)
elif isinstance(event, events.Pong):
self.handle_pong(event)
def pause_writing(self) -> None:
"""
@ -149,6 +182,7 @@ class WSProtocol(asyncio.Protocol):
self.writable.set() # pragma: full coverage
def shutdown(self) -> None:
self.stop_keepalive()
if self.handshake_complete:
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
output = self.conn.send(wsproto.events.CloseConnection(code=1012))
@ -190,21 +224,20 @@ class WSProtocol(asyncio.Protocol):
task.add_done_callback(self.on_task_complete)
self.tasks.add(task)
def handle_text(self, event: events.TextMessage) -> None:
self.text += event.data
def handle_message(self, event: events.TextMessage | events.BytesMessage) -> None:
try:
self.buffer.extend(event)
except FrameTooLargeError:
self.close_sent = True
reason = f"Message exceeds the maximum size ({self.config.ws_max_size} bytes)"
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1009, "reason": reason})
if not self.transport.is_closing():
self.transport.write(self.conn.send(wsproto.events.CloseConnection(code=1009, reason=reason)))
self.transport.close()
return
if event.message_finished:
self.queue.put_nowait({"type": "websocket.receive", "text": self.text})
self.text = ""
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()
def handle_bytes(self, event: events.BytesMessage) -> None:
self.bytes += event.data
# todo: we may want to guard the size of self.bytes and self.text
if event.message_finished:
self.queue.put_nowait({"type": "websocket.receive", "bytes": self.bytes})
self.bytes = b""
self.queue.put_nowait(self.buffer.to_message())
self.buffer.clear()
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()
@ -218,6 +251,65 @@ class WSProtocol(asyncio.Protocol):
def handle_ping(self, event: events.Ping) -> None:
self.transport.write(self.conn.send(event.response()))
def handle_pong(self, event: events.Pong) -> None:
# Ignore unsolicited pongs and stale pongs whose payload doesn't match the ping currently in flight.
if self.pending_ping_payload is None or bytes(event.payload) != self.pending_ping_payload:
return # pragma: no cover
self.last_ping_rtt = self.loop.time() - self.ping_sent_at
self.pending_ping_payload = None
# The peer answered in time; cancel the pong deadline and chain the next ping. This `schedule_ping()` call is
# what keeps the keepalive loop running when ping_timeout is set. When ping_timeout is None the next ping is
# already scheduled by `send_keepalive_ping`, so we must not schedule a duplicate here.
if self.pong_timer is not None:
self.pong_timer.cancel()
self.pong_timer = None
self.schedule_ping()
def start_keepalive(self) -> None:
if self.ping_interval is not None and self.ping_interval > 0:
self.schedule_ping()
def stop_keepalive(self) -> None:
if self.ping_timer is not None:
self.ping_timer.cancel()
self.ping_timer = None
if self.pong_timer is not None: # pragma: no cover
self.pong_timer.cancel()
self.pong_timer = None
self.pending_ping_payload = None
def schedule_ping(self) -> None:
assert self.ping_interval is not None
delay = max(0.0, self.ping_interval - self.last_ping_rtt)
self.ping_timer = self.loop.call_later(delay, self.send_keepalive_ping)
def send_keepalive_ping(self) -> None:
self.ping_timer = None
if self.close_sent or self.transport.is_closing(): # pragma: no cover
return
# Random 4-byte payload identifies this ping; `handle_pong` uses it to ignore stale or unsolicited pongs.
self.pending_ping_payload = struct.pack("!I", random.getrandbits(32))
self.ping_sent_at = self.loop.time()
self.transport.write(self.conn.send(wsproto.events.Ping(payload=self.pending_ping_payload)))
if self.ping_timeout is not None:
self.pong_timer = self.loop.call_later(self.ping_timeout, self.keepalive_timeout)
else: # pragma: no cover
self.schedule_ping()
def keepalive_timeout(self) -> None:
self.pong_timer = None
self.pending_ping_payload = None
if self.close_sent or self.transport.is_closing(): # pragma: no cover
return
if self.logger.level <= TRACE_LOG_LEVEL:
prefix = "%s:%d - " % self.client if self.client else ""
self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket keepalive ping timeout", prefix)
reason = "keepalive ping timeout"
self.transport.write(self.conn.send(wsproto.events.CloseConnection(code=1011, reason=reason)))
self.close_sent = True
self.transport.close()
def send_500_response(self) -> None:
if self.response_started or self.handshake_complete:
return # we cannot send responses anymore
@ -234,28 +326,23 @@ class WSProtocol(asyncio.Protocol):
try:
result = await self.app(self.scope, self.receive, self.send) # type: ignore[func-returns-value]
except ClientDisconnected:
self.transport.close() # pragma: full coverage
pass # pragma: full coverage
except BaseException:
self.logger.exception("Exception in ASGI application\n")
self.send_500_response()
self.transport.close()
else:
if not self.handshake_complete:
self.logger.error("ASGI callable returned without completing handshake.")
self.send_500_response()
self.transport.close()
elif result is not None:
self.logger.error("ASGI callable should return None, but returned '%s'.", result)
self.transport.close()
self.transport.close()
async def send(self, message: ASGISendEvent) -> None:
await self.writable.wait()
message_type = message["type"]
if not self.handshake_complete:
if message_type == "websocket.accept":
message = cast(WebSocketAcceptEvent, message)
if message["type"] == "websocket.accept":
self.logger.info(
'%s - "WebSocket %s" [accepted]',
get_client_addr(self.scope),
@ -276,8 +363,9 @@ class WSProtocol(asyncio.Protocol):
)
)
self.transport.write(output)
self.start_keepalive()
elif message_type == "websocket.close":
elif message["type"] == "websocket.close":
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1006})
self.logger.info(
'%s - "WebSocket %s" 403',
@ -291,8 +379,7 @@ class WSProtocol(asyncio.Protocol):
self.transport.write(output)
self.transport.close()
elif message_type == "websocket.http.response.start":
message = cast(WebSocketResponseStartEvent, message)
elif message["type"] == "websocket.http.response.start":
# ensure status code is in the valid range
if not (100 <= message["status"] < 600):
msg = "Invalid HTTP status code '%d' in response."
@ -314,17 +401,14 @@ class WSProtocol(asyncio.Protocol):
self.response_started = True
else:
msg = (
raise RuntimeError(
"Expected ASGI message 'websocket.accept', 'websocket.close' "
"or 'websocket.http.response.start' "
"but got '%s'."
f"or 'websocket.http.response.start' but got '{message['type']}'."
)
raise RuntimeError(msg % message_type)
elif not self.close_sent and not self.response_started:
try:
if message_type == "websocket.send":
message = cast(WebSocketSendEvent, message)
if message["type"] == "websocket.send":
bytes_data = message.get("bytes")
text_data = message.get("text")
data = text_data if bytes_data is None else bytes_data
@ -332,8 +416,7 @@ class WSProtocol(asyncio.Protocol):
if not self.transport.is_closing():
self.transport.write(output)
elif message_type == "websocket.close":
message = cast(WebSocketCloseEvent, message)
elif message["type"] == "websocket.close":
self.close_sent = True
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
@ -344,13 +427,13 @@ class WSProtocol(asyncio.Protocol):
self.transport.close()
else:
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
except LocalProtocolError as exc:
raise ClientDisconnected from exc
elif self.response_started:
if message_type == "websocket.http.response.body":
message = cast("WebSocketResponseBodyEvent", message)
if message["type"] == "websocket.http.response.body":
body_finished = not message.get("more_body", False)
reject_data = events.RejectData(data=message["body"], body_finished=body_finished)
output = self.conn.send(reject_data)
@ -362,12 +445,10 @@ class WSProtocol(asyncio.Protocol):
self.transport.close()
else:
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
else:
msg = "Unexpected ASGI message '%s', after sending 'websocket.close'."
raise RuntimeError(msg % message_type)
raise RuntimeError(f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close'.")
async def receive(self) -> WebSocketEvent:
message = await self.queue.get()

View File

@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
import contextlib
import functools
import logging
import os
import platform
import random
import signal
import socket
import sys
@ -13,7 +15,7 @@ import time
from collections.abc import Generator, Sequence
from email.utils import formatdate
from types import FrameType
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, TypeAlias
import click
@ -27,7 +29,7 @@ if TYPE_CHECKING:
from uvicorn.protocols.websockets.websockets_sansio_impl import WebSocketsSansIOProtocol
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol
Protocols = Union[H11Protocol, HttpToolsProtocol, WSProtocol, WebSocketProtocol, WebSocketsSansIOProtocol]
Protocols: TypeAlias = H11Protocol | HttpToolsProtocol | WSProtocol | WebSocketProtocol | WebSocketsSansIOProtocol
HANDLED_SIGNALS = (
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
@ -63,6 +65,12 @@ class Server:
self._captured_signals: list[int] = []
@functools.cached_property
def limit_max_requests(self) -> int | None:
if self.config.limit_max_requests is None:
return None
return self.config.limit_max_requests + random.randint(0, self.config.limit_max_requests_jitter)
def run(self, sockets: list[socket.socket] | None = None) -> None:
return asyncio_run(self.serve(sockets=sockets), loop_factory=self.config.get_loop_factory())
@ -84,14 +92,14 @@ class Server:
logger.info(message, process_id, extra={"color_message": color_message})
await self.startup(sockets=sockets)
if self.should_exit:
return
await self.main_loop()
await self.shutdown(sockets=sockets)
if not self.should_exit:
await self.main_loop()
if self.started:
await self.shutdown(sockets=sockets)
message = "Finished server process [%d]"
color_message = "Finished server process [" + click.style("%d", fg="cyan") + "]"
logger.info(message, process_id, extra={"color_message": color_message})
message = "Finished server process [%d]"
color_message = "Finished server process [" + click.style("%d", fg="cyan") + "]"
logger.info(message, process_id, extra={"color_message": color_message})
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
await self.lifespan.startup()
@ -253,9 +261,9 @@ class Server:
if self.should_exit:
return True
max_requests = self.config.limit_max_requests
max_requests = self.limit_max_requests
if max_requests is not None and self.server_state.total_requests >= max_requests:
logger.warning(f"Maximum request limit of {max_requests} exceeded. Terminating process.")
logger.info("Maximum request limit of %d exceeded. Terminating process.", max_requests)
return True
return False

View File

@ -5,11 +5,10 @@ import os
import signal
import sys
import threading
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from pathlib import Path
from socket import socket
from types import FrameType
from typing import Callable
import click

View File

@ -4,9 +4,10 @@ import logging
import os
import signal
import threading
from collections.abc import Callable
from multiprocessing import Pipe
from socket import socket
from typing import Any, Callable
from typing import Any
import click

View File

@ -1,10 +1,9 @@
from __future__ import annotations
import logging
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from pathlib import Path
from socket import socket
from typing import Callable
from uvicorn.config import Config
from uvicorn.supervisors.basereload import BaseReload

View File

@ -1,8 +1,8 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from socket import socket
from typing import Callable
from watchfiles import watch
@ -61,7 +61,7 @@ class WatchFilesReload(BaseReload):
) -> None:
super().__init__(config, target, sockets)
self.reloader_name = "WatchFiles"
self.reload_dirs = []
self.reload_dirs: list[Path] = []
for directory in config.reload_dirs:
self.reload_dirs.append(directory)
@ -73,6 +73,7 @@ class WatchFilesReload(BaseReload):
# using yield_on_timeout here mostly to make sure tests don't
# hang forever, won't affect the class's behavior
yield_on_timeout=True,
ignore_permission_denied=True,
)
def should_restart(self) -> list[Path] | None: