Compare commits

..

2 Commits

Author SHA1 Message Date
Marcelo Trylesinski
0014792263 Avoid double-copy of single-frame binary payload 2026-04-22 22:21:31 +02:00
Marcelo Trylesinski
c1615ebb21 Use bytearray for incoming WebSocket message buffer in websockets-sansio 2026-04-22 22:08:27 +02:00
21 changed files with 243 additions and 412 deletions

View File

@ -14,6 +14,8 @@ jobs:
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read # Only needed for private repos. Needed to clone the repo.
actions: read # Only needed for private repos. Needed for upload-sarif to read workflow run info.
steps:
- name: Checkout repository

View File

@ -1,57 +0,0 @@
.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 processes are graceful restarted one after another. If you update the code, the new worker process will use the new code.
- `SIGHUP`: Work processeses 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,36 +225,6 @@ 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@ -44,18 +44,6 @@ 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,15 +44,4 @@
{{ 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,31 +2,6 @@
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

View File

@ -138,8 +138,6 @@ 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.

184
docs/sponsorship.md Normal file
View File

@ -0,0 +1,184 @@
# ✨ 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

@ -62,6 +62,7 @@ nav:
- Docker: deployment/docker.md
- Release Notes: release-notes.md
- Contributing: contributing.md
- Sponsorship: sponsorship.md
extra:
analytics:
@ -79,9 +80,6 @@ extra:
- icon: fontawesome/solid/globe
link: https://fastapiexpert.com
extra_css:
- css/extra.css
markdown_extensions:
- attr_list
- admonition

View File

@ -82,8 +82,7 @@ docs = [
[tool.uv]
default-groups = ["dev", "docs"]
required-version = ">=0.9.17"
exclude-newer = "7 days"
required-version = ">=0.8.6"
[project.scripts]
uvicorn = "uvicorn.main:main"

View File

@ -777,12 +777,17 @@ async def test_fragmented_message_exceeding_max_size(
assert exc_info.value.rcvd.code == 1009
@pytest.mark.parametrize("opcode,key", [(Opcode.BINARY, "bytes"), (Opcode.TEXT, "text")])
async def test_fragmented_message_reassembly(
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
ws_protocol_cls: WSProtocol,
http_protocol_cls: HTTPProtocol,
unused_tcp_port: int,
opcode: Opcode,
key: str,
):
"""Server reassembles a fragmented message and delivers it to the app intact."""
received: list[bytes] = []
received: list[str | bytes] = []
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
assert scope["type"] == "websocket"
@ -791,8 +796,8 @@ async def test_fragmented_message_reassembly(
await send({"type": "websocket.accept"})
message = await receive()
assert message["type"] == "websocket.receive"
payload = message.get("bytes")
assert payload is not None
payload = message.get(key)
assert isinstance(payload, (str, bytes))
received.append(payload)
await send({"type": "websocket.close"})
@ -800,12 +805,13 @@ async def test_fragmented_message_reassembly(
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)
await ws.write_frame(False, opcode, 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]
expected: str | bytes = "A" * 512 * 6 if opcode is Opcode.TEXT else b"A" * 512 * 6
assert received == [expected]
async def test_server_reject_connection(

View File

@ -553,37 +553,6 @@ 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

@ -1,9 +1,7 @@
import importlib
import inspect
import socket
import sys
from logging import WARNING
from pathlib import Path
import httpx
import pytest
@ -14,7 +12,6 @@ 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
@ -88,61 +85,6 @@ 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

@ -142,13 +142,17 @@ async def test_limit_max_requests_jitter(
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)
server = Server(config=config)
limit = server.limit_max_requests
assert limit is not None
assert 1 <= limit <= 3
task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.01)
async with httpx.AsyncClient() as client:
for _ in range(limit + 1):
await client.get(f"http://127.0.0.1:{unused_tcp_port}")
await task
assert f"Maximum request limit of {limit} exceeded. Terminating process." in caplog.text

View File

@ -1,17 +1,9 @@
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"
@ -100,108 +92,3 @@ 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

6
uv.lock generated
View File

@ -1757,11 +1757,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]

View File

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

View File

@ -225,7 +225,6 @@ class Config:
ssl_cert_reqs: int = ssl.CERT_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,
@ -273,7 +272,6 @@ 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
@ -359,7 +357,7 @@ class Config:
@property
def is_ssl(self) -> bool:
return bool(self.ssl_keyfile or self.ssl_certfile or self.ssl_context_factory)
return bool(self.ssl_keyfile or self.ssl_certfile)
@property
def use_subprocess(self) -> bool:
@ -409,43 +407,12 @@ 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.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:
if self.is_ssl:
assert self.ssl_certfile
self.ssl = create_ssl_context(
self.ssl: ssl.SSLContext | None = create_ssl_context(
keyfile=self.ssl_keyfile,
certfile=self.ssl_certfile,
password=self.ssl_keyfile_password,
@ -478,7 +445,11 @@ class Config:
self.lifespan_class = import_from_string(LIFESPAN[self.lifespan])
self.loaded_app = self.load_app()
try:
self.loaded_app = import_from_string(self.app)
except ImportFromStringError as exc:
logger.error("Error loading ASGI app. %s" % exc)
sys.exit(1)
try:
self.loaded_app = self.loaded_app()
@ -537,7 +508,7 @@ class Config:
def bind_socket(self) -> socket.socket:
logger_args: list[str | int]
if self.uds is not None: # pragma: py-win32
if self.uds: # pragma: py-win32
path = self.uds
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
@ -552,7 +523,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 is not None: # pragma: py-win32
elif self.fd: # 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

@ -538,7 +538,6 @@ def run(
ssl_cert_reqs: int = ssl.CERT_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,
@ -594,21 +593,19 @@ 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()
@ -618,8 +615,8 @@ def run(
Multiprocess(config, target=server.run, sockets=[sock]).run()
else:
server.run()
except KeyboardInterrupt: # pragma: full coverage
pass
except KeyboardInterrupt:
pass # pragma: full coverage
finally:
if config.uds and os.path.exists(config.uds):
os.remove(config.uds) # pragma: py-win32

View File

@ -105,7 +105,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.last_ping_rtt: float = 0.0
# Buffers
self.bytes = bytearray()
self.bytes: bytes | bytearray = b""
def connection_made(self, transport: BaseTransport) -> None:
"""Called when a connection is made."""
@ -217,21 +217,28 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.tasks.add(task)
def handle_cont(self, event: Frame) -> None:
assert isinstance(self.bytes, bytearray)
self.bytes.extend(event.data)
if event.fin:
self.send_receive_event_to_app()
def handle_text(self, event: Frame) -> None:
self.bytes = bytearray(event.data)
self.curr_msg_data_type: Literal["text", "bytes"] = "text"
if event.fin:
# Single-frame message: pass through as bytes (zero copies).
self.bytes = event.data
self.send_receive_event_to_app()
else:
# Fragmented: promote to bytearray so continuations extend in place.
self.bytes = bytearray(event.data)
def handle_bytes(self, event: Frame) -> None:
self.bytes = bytearray(event.data)
self.curr_msg_data_type = "bytes"
if event.fin:
self.bytes = event.data
self.send_receive_event_to_app()
else:
self.bytes = bytearray(event.data)
def send_receive_event_to_app(self) -> None:
if self.curr_msg_data_type == "text":
@ -243,7 +250,9 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.handle_parser_exception()
return
else:
self.queue.put_nowait({"type": "websocket.receive", "bytes": bytes(self.bytes)})
# Freeze the buffer to bytes only when it was grown across fragments.
payload = self.bytes if isinstance(self.bytes, bytes) else bytes(self.bytes)
self.queue.put_nowait({"type": "websocket.receive", "bytes": payload})
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()