Compare commits

..

6 Commits

Author SHA1 Message Date
Marcelo Trylesinski
1af635b532 Resolve bind sockets in _serve instead of startup
Move the bind socket resolution from startup() to _serve() so startup
doesn't need to know about config.bind. The resolved sockets flow into
both startup and shutdown through the existing parameter.
2026-02-16 23:02:34 +01:00
Marcelo Trylesinski
a5be29d5c1 Move bind validation to end of constructor, add bind_unix_paths property
Move the `--bind` mutual exclusivity check to the end of `Config.__init__`
alongside other cross-field validation. Add a `bind_unix_paths` property
to encapsulate unix socket path extraction, and use it in the cleanup
code in `main.py`. Add a docs note explaining when to use `--bind`.
2026-02-16 21:52:41 +01:00
Marcelo Trylesinski
09e94aba5d Mark test cleanup branch as no cover 2026-02-15 21:46:53 +01:00
Marcelo Trylesinski
a36e46c994 Add coverage for unix socket and fd:// bind paths
Add tests for bind_sockets() with unix: and fd:// formats, and for
UDS cleanup in run(). Mark uncoverable branches with pragma: py-win32.
2026-02-15 21:44:15 +01:00
Marcelo Trylesinski
53f45d134a Format test_cli.py 2026-02-15 21:39:53 +01:00
Marcelo Trylesinski
8c9a0c2fb3 Add --bind / -b option for multiple socket bindings
Allow binding to multiple addresses simultaneously using the established
pattern from Gunicorn and Hypercorn. Supports HOST:PORT, [HOST]:PORT
(IPv6), unix:PATH, and fd://NUM formats. Mutually exclusive with
--host/--port/--uds/--fd.
2026-02-15 21:33:58 +01:00
45 changed files with 1135 additions and 2710 deletions

View File

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

View File

@ -1,36 +0,0 @@
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,3 +1,4 @@
---
name: Test Suite
on:
@ -12,9 +13,6 @@ jobs:
runs-on: "${{ matrix.os }}"
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
@ -22,12 +20,10 @@ jobs:
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2
with:
persist-credentials: false
- uses: "actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8" # v6.0.1
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
with:
python-version: ${{ matrix.python-version }}
enable-cache: ${{ matrix.os != 'windows-latest' }}
@ -52,62 +48,11 @@ 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,19 +10,14 @@ jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
with:
python-version: "3.11"
enable-cache: false
enable-cache: true
- name: Install dependencies
run: scripts/install
@ -31,13 +26,13 @@ jobs:
run: scripts/build
- name: Upload package distributions
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: package-distributions
path: dist/
- name: Upload documentation
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: documentation
path: site/
@ -56,7 +51,7 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: package-distributions
path: dist/
@ -72,11 +67,9 @@ jobs:
contents: write
steps:
# `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]
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: documentation
path: site/
@ -87,10 +80,10 @@ jobs:
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
with:
python-version: "3.12"
enable-cache: false
enable-cache: true
- name: Install dependencies
run: scripts/install
@ -102,19 +95,14 @@ jobs:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
environment:
name: cloudflare
url: https://uvicorn.dev
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: documentation
path: site/

View File

@ -1,25 +0,0 @@
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,4 +8,3 @@ venv/
htmlcov/
site/
dist/
.codspeed/

View File

@ -13,10 +13,10 @@ By default, Uvicorn uses `--loop auto`, which automatically selects:
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.
On Windows, the asyncio implementation uses [`ProactorEventLoop`][asyncio.ProactorEventLoop] if running with multiple workers,
otherwise it uses the standard [`SelectorEventLoop`][asyncio.SelectorEventLoop] for better performance.
??? info "Why can `ProactorEventLoop` fail with multiple processes on Windows?"
??? info "Why does `SelectorEventLoop` not work 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

View File

@ -1,319 +0,0 @@
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.

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,93 +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
* 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

View File

@ -39,7 +39,6 @@ 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
@ -47,6 +46,12 @@ uvicorn itself.
* `--port <int>` - Bind to a socket with this port. If set to 0, an available port will be picked. **Default:** *8000*.
* `--uds <path>` - Bind to a UNIX domain socket, for example `--uds /tmp/uvicorn.sock`. Useful if you want to run Uvicorn behind a reverse proxy.
* `--fd <int>` - Bind to socket from this file descriptor. Useful if you want to run Uvicorn within a process manager.
* `--bind <str>` / `-b <str>` - Bind to one or more addresses. May be specified multiple times to listen on multiple sockets simultaneously. Supported formats: `HOST:PORT` (e.g. `0.0.0.0:8000`), `[HOST]:PORT` for IPv6 (e.g. `[::1]:8000`), `unix:PATH` (e.g. `unix:/tmp/uvicorn.sock`), `fd://NUM` (e.g. `fd://3`). Mutually exclusive with `--host`, `--port`, `--uds`, and `--fd`.
!!! note
The `--host`, `--port`, `--uds`, and `--fd` options each bind to a single address of a single type.
Use `--bind` when you need to listen on multiple addresses (e.g. dual-stack IPv4 + IPv6) or mix
transport types (e.g. a TCP port for internal services and a unix socket behind a reverse proxy).
## Development
@ -94,10 +99,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. **Default:** *16777216* (16 MB).
* `--ws-max-size <int>` - Set the WebSockets max message size, in bytes. Only available with the `websockets` protocol. **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. **Default:** *20.0*.
* `--ws-ping-timeout <float>` - Set the WebSockets ping timeout, in seconds. **Default:** *20.0*.
* `--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-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).
@ -138,13 +143,10 @@ 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

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

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

View File

@ -44,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.20",
"watchfiles>=0.13",
"websockets>=10.4",
]
@ -53,10 +53,9 @@ dev = [
# We add uvicorn[standard] so `uv sync` considers the extras.
"uvicorn[standard]",
"ruff==0.15.1",
"pytest==9.0.3",
"pytest==9.0.2",
"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.20250915",
@ -82,8 +81,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"
@ -137,7 +135,7 @@ filterwarnings = [
parallel = true
source_pkgs = ["uvicorn", "tests"]
plugins = ["coverage_conditional_plugin"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py", "tests/benchmarks/*"]
omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py"]
[tool.coverage.report]
precision = 2

View File

@ -1,174 +0,0 @@
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

@ -1,115 +0,0 @@
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

@ -1,64 +0,0 @@
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()

View File

@ -1,40 +0,0 @@
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

@ -1,10 +1,9 @@
from __future__ import annotations
import contextlib
import ipaddress
from typing import TYPE_CHECKING
import httpx
import httpx._transports.asgi
import pytest
import websockets.client
@ -31,9 +30,6 @@ 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")
@ -430,31 +426,6 @@ 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:
@ -470,38 +441,6 @@ 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

@ -306,42 +306,6 @@ async def test_header_value_allowed_characters(http_protocol_cls: type[HTTPProto
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: type[HTTPProtocol], caplog: pytest.LogCaptureFixture):
get_request_with_query_string = b"\r\n".join(

View File

@ -10,12 +10,7 @@ from uvicorn.protocols.utils import get_client_addr, get_local_addr, get_remote_
class MockSocket:
def __init__(
self,
family: socket.AddressFamily,
peername: tuple[str, int] | None = None,
sockname: tuple[str, int] | str | None = None,
):
def __init__(self, family, peername=None, sockname=None):
self.peername = peername
self.sockname = sockname
self.family = family
@ -45,8 +40,9 @@ 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)
transport = MockTransport({"socket": MockSocket(family=socket.AF_INET, sockname="/tmp/test.sock")})
assert get_local_addr(transport) == ("/tmp/test.sock", None)
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)
def test_get_remote_addr_with_socket():
@ -66,14 +62,11 @@ 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) == ("path/to/unix-domain-socket", None)
assert get_local_addr(transport) is 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})
@ -88,5 +81,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: Any, expected_client: str):
def test_get_client_addr(scope, expected_client):
assert get_client_addr(scope) == expected_client

View File

@ -10,7 +10,6 @@ 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
@ -28,7 +27,6 @@ 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
@ -44,7 +42,6 @@ if TYPE_CHECKING:
HTTPProtocol: TypeAlias = "type[H11Protocol | HttpToolsProtocol]"
WSProtocol: TypeAlias = "type[_WSProtocol | WebSocketProtocol]"
KeepaliveWSProtocol: TypeAlias = "type[_WSProtocol | WebSocketsSansIOProtocol]"
pytestmark = pytest.mark.anyio
@ -753,61 +750,6 @@ 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
):
@ -941,8 +883,6 @@ 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):
@ -1260,118 +1200,3 @@ 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

@ -201,3 +201,75 @@ def test_set_app_via_environment_variable():
args, _ = mock_run.call_args
assert result.exit_code == 0
assert args == (app_path,)
def test_cli_bind_option() -> None:
runner = CliRunner()
with mock.patch.object(main, "run") as mock_run:
result = runner.invoke(cli, ["tests.test_cli:App", "--bind", "0.0.0.0:8000"])
assert result.output == ""
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[1]["bind"] == ["0.0.0.0:8000"]
def test_cli_bind_multiple() -> None:
runner = CliRunner()
with mock.patch.object(main, "run") as mock_run:
result = runner.invoke(cli, ["tests.test_cli:App", "-b", "127.0.0.1:8000", "-b", "127.0.0.1:9000"])
assert result.output == ""
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[1]["bind"] == ["127.0.0.1:8000", "127.0.0.1:9000"]
@pytest.mark.parametrize(
"extra_args",
[
["--host", "0.0.0.0"],
["--port", "9000"],
["--uds", "/tmp/test.sock"],
["--fd", "3"],
],
ids=["host", "port", "uds", "fd"],
)
def test_cli_bind_mutually_exclusive(extra_args: list[str]) -> None:
runner = CliRunner()
with pytest.raises(ValueError, match="'bind' is mutually exclusive with.*"):
runner.invoke(cli, ["tests.test_cli:App", "-b", "127.0.0.1:8000", *extra_args], catch_exceptions=False)
@pytest.mark.skipif(sys.platform == "win32", reason="require unix-like system")
def test_cli_bind_unix_cleanup() -> None: # pragma: py-win32
sock_path = "/tmp/uvicorn_test_cleanup.sock"
runner = CliRunner()
try:
Path(sock_path).touch()
with mock.patch.object(Config, "bind_sockets") as mock_bind_sockets:
with mock.patch.object(Multiprocess, "run") as mock_run:
result = runner.invoke(cli, ["tests.test_cli:App", "--workers=2", "-b", f"unix:{sock_path}"])
assert result.exit_code == 0
mock_bind_sockets.assert_called_once()
mock_run.assert_called_once()
assert not Path(sock_path).exists()
finally:
if Path(sock_path).exists(): # pragma: no cover
os.remove(sock_path)
def test_cli_bind_without_value_passes_none() -> None:
runner = CliRunner()
with mock.patch.object(main, "run") as mock_run:
result = runner.invoke(cli, ["tests.test_cli:App"])
assert result.exit_code in (0, 3)
mock_run.assert_called_once()
assert mock_run.call_args[1]["bind"] is None

View File

@ -366,35 +366,6 @@ 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,
@ -491,13 +462,6 @@ 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()
@ -553,37 +517,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",
[
@ -660,3 +593,88 @@ def test_setup_event_loop_is_removed(caplog: pytest.LogCaptureFixture) -> None:
AttributeError, match="The `setup_event_loop` method was replaced by `get_loop_factory` in uvicorn 0.36.0."
):
config.setup_event_loop()
@pytest.mark.parametrize(
"bind_str, expected_family",
[
("127.0.0.1:0", socket.AF_INET),
("0.0.0.0:0", socket.AF_INET),
("[::1]:0", socket.AF_INET6),
("[::]:0", socket.AF_INET6),
("localhost:0", socket.AF_INET),
],
ids=["ipv4", "ipv4-wildcard", "ipv6", "ipv6-wildcard", "hostname"],
)
def test_bind_sockets_address_formats(bind_str: str, expected_family: socket.AddressFamily) -> None:
config = Config(app=asgi_app, bind=[bind_str])
sockets = config.bind_sockets()
assert len(sockets) == 1
assert sockets[0].family == expected_family
sockets[0].close()
def test_bind_sockets_multiple() -> None:
config = Config(app=asgi_app, bind=["127.0.0.1:0", "127.0.0.1:0"])
sockets = config.bind_sockets()
assert len(sockets) == 2
for sock in sockets:
assert sock.family == socket.AF_INET
sock.close()
def test_bind_sockets_default_port() -> None:
config = Config(app=asgi_app, bind=["127.0.0.1"])
sockets = config.bind_sockets()
assert len(sockets) == 1
assert sockets[0].getsockname()[1] == 8000
sockets[0].close()
def test_bind_sockets_fallback() -> None:
config = Config(app=asgi_app, bind=None)
sockets = config.bind_sockets()
assert len(sockets) == 1
sockets[0].close()
@pytest.mark.parametrize(
"kwargs",
[
{"host": "0.0.0.0"},
{"port": 9000},
{"uds": "/tmp/test.sock"},
{"fd": 3},
],
ids=["host", "port", "uds", "fd"],
)
def test_bind_mutually_exclusive_with_other_params(kwargs: dict[str, Any]) -> None:
with pytest.raises(ValueError, match="'bind' is mutually exclusive with"):
Config(app=asgi_app, bind=["127.0.0.1:0"], **kwargs)
@pytest.mark.skipif(sys.platform == "win32", reason="requires unix sockets")
def test_bind_sockets_unix() -> None: # pragma: py-win32
sock_path = "/tmp/uvicorn_test_bind.sock"
try:
config = Config(app=asgi_app, bind=[f"unix:{sock_path}"])
sockets = config.bind_sockets()
assert len(sockets) == 1
assert sockets[0].family == socket.AF_UNIX
sockets[0].close()
finally:
if os.path.exists(sock_path):
os.unlink(sock_path)
@pytest.mark.skipif(sys.platform == "win32", reason="requires unix sockets")
def test_bind_sockets_fd(tmp_path: Path) -> None: # pragma: py-win32
# Create a socket, then bind via its file descriptor.
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
fd = listener.fileno()
config = Config(app=asgi_app, bind=[f"fd://{fd}"])
sockets = config.bind_sockets()
assert len(sockets) == 1
sockets[0].close()
listener.close()

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"
@ -175,6 +117,20 @@ async def test_exit_on_create_server_with_invalid_host() -> None:
assert exc_info.value.code == 1
async def test_run_with_bind(unused_tcp_port: int) -> None:
config = Config(app=app, bind=[f"127.0.0.1:{unused_tcp_port}"], loop="asyncio", limit_max_requests=1)
async with run_server(config):
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{unused_tcp_port}")
assert response.status_code == 204
async def test_run_with_bind_multiple() -> None:
config = Config(app=app, bind=["127.0.0.1:0", "127.0.0.1:0"], loop="asyncio", limit_max_requests=1)
async with run_server(config):
pass # Startup itself validates multiple sockets work
def test_deprecated_server_state_from_main() -> None:
with pytest.deprecated_call(
match="uvicorn.main.ServerState is deprecated, use uvicorn.server.ServerState instead."

View File

@ -15,12 +15,12 @@ import pytest
from tests.protocols.test_http import SIMPLE_GET_REQUEST
from tests.utils import run_server
from uvicorn import Server
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
pytestmark = pytest.mark.anyio
@ -87,45 +87,10 @@ 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.INFO, logger="uvicorn.error")
caplog.set_level(logging.WARNING, 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:
@ -135,32 +100,9 @@ async def test_request_than_limit_max_requests_warn_log(
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)
async def server(*, app: ASGIApplication, port: int, http_protocol_cls: type[H11Protocol | HttpToolsProtocol]):
config = Config(app=app, port=port, loop="asyncio", http=http_protocol_cls)
server = Server(config=config)
task = asyncio.create_task(server.serve())
@ -188,36 +130,10 @@ async def _raw_server(
await task
async def test_contextvars_preserved_by_default(
async def test_no_contextvars_pollution_asyncio(
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.
"""
"""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")
@ -237,13 +153,14 @@ async def test_reset_contextvars_asyncio(
if not message["more_body"]:
break
# return the initial context for empty assertion
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.
# body has to be larger than HIGH_WATER_LIMIT to trigger a reading pause on the main thread
# and a resumption inside the ASGI task
large_body = b"a" * (HIGH_WATER_LIMIT + 1)
large_request = b"\r\n".join(
[
@ -256,8 +173,6 @@ async def test_reset_contextvars_asyncio(
]
)
async with _raw_server(
app=app, http_protocol_cls=http_protocol_cls, port=unused_tcp_port, reset_contextvars=True
) as extract_json_body:
async with server(app=app, http_protocol_cls=http_protocol_cls, port=unused_tcp_port) as extract_json_body:
assert await extract_json_body(large_request) == {}
assert await extract_json_body(SIMPLE_GET_REQUEST) == {}

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

894
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.47.0"
__version__ = "0.40.0"
__all__ = ["main", "run", "Config", "Server"]

View File

@ -183,6 +183,7 @@ class Config:
port: int = 8000,
uds: str | None = None,
fd: int | None = None,
bind: list[str] | None = None,
loop: LoopFactoryType | str = "auto",
http: type[asyncio.Protocol] | HTTPProtocolType | str = "auto",
ws: type[asyncio.Protocol] | WSProtocolType | str = "auto",
@ -193,7 +194,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 | os.PathLike[str] | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_level: str | int | None = None,
access_log: bool = True,
use_colors: bool | None = None,
@ -211,7 +212,6 @@ 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,
@ -225,17 +225,16 @@ 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,
reset_contextvars: bool = False,
):
self.app = app
self.host = host
self.port = port
self.uds = uds
self.fd = fd
self.bind = bind
self.loop = loop
self.http = http
self.ws = ws
@ -259,7 +258,6 @@ 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
@ -273,12 +271,10 @@ 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()
@ -348,18 +344,28 @@ class Config:
if self.reload and self.workers > 1:
logger.warning('"workers" flag is ignored when reloading is enabled.')
if self.bind is not None:
# Only flag options explicitly set to non-default values.
conflicting: list[str] = []
if self.host != "127.0.0.1": # default host
conflicting.append("host")
if self.port != 8000: # default port
conflicting.append("port")
if self.uds is not None:
conflicting.append("uds")
if self.fd is not None:
conflicting.append("fd")
if conflicting:
raise ValueError(f"'bind' is mutually exclusive with {', '.join(map(repr, conflicting))}")
@property
def asgi_version(self) -> Literal["2.0", "3.0"]:
mapping: dict[str, Literal["2.0", "3.0"]] = {
"asgi2": "2.0",
"asgi3": "3.0",
"wsgi": "3.0",
}
mapping: dict[str, Literal["2.0", "3.0"]] = {"asgi2": "2.0", "asgi3": "3.0", "wsgi": "3.0"}
return mapping[self.interface]
@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:
@ -369,9 +375,6 @@ 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
@ -382,12 +385,9 @@ 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")):
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
# Install the PyYAML package or the uvicorn[standard] optional
# dependencies to enable this functionality.
import yaml
with open(self.log_config) as file:
loaded_config = yaml.safe_load(file)
@ -399,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.lower()]
log_level = LOG_LEVELS[self.log_level]
else:
log_level = self.log_level
logging.getLogger("uvicorn.error").setLevel(log_level)
@ -409,43 +409,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 +447,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()
@ -535,25 +508,29 @@ class Config:
return None
return loop_factory(use_subprocess=self.use_subprocess)
def bind_socket(self) -> socket.socket:
def _bind_one(
self,
*,
uds: str | None = None,
fd: int | None = None,
host: str = "127.0.0.1",
port: int = 8000,
) -> socket.socket:
logger_args: list[str | int]
if self.uds is not None: # pragma: py-win32
path = self.uds
if uds: # pragma: py-win32
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.bind(path)
uds_perms = 0o666
os.chmod(self.uds, uds_perms)
sock.bind(uds)
os.chmod(uds, 0o666)
except OSError as exc: # pragma: full coverage
logger.error(exc)
sys.exit(1)
message = "Uvicorn running on unix socket %s (Press CTRL+C to quit)"
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
sock = socket.fromfd(self.fd, socket.AF_UNIX, socket.SOCK_STREAM)
logger_args = [uds]
elif fd is not None: # pragma: py-win32
sock = socket.fromfd(fd, socket.AF_UNIX, socket.SOCK_STREAM)
message = "Uvicorn running on socket %s (Press CTRL+C to quit)"
fd_name_format = "%s"
color_message = "Uvicorn running on " + click.style(fd_name_format, bold=True) + " (Press CTRL+C to quit)"
@ -561,28 +538,53 @@ class Config:
else:
family = socket.AF_INET
addr_format = "%s://%s:%d"
if self.host and ":" in self.host: # pragma: full coverage
# It's an IPv6 address.
if host and ":" in host: # pragma: full coverage
family = socket.AF_INET6
addr_format = "%s://[%s]:%d"
sock = socket.socket(family=family)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((self.host, self.port))
sock.bind((host, port))
except OSError as exc: # pragma: full coverage
logger.error(exc)
sys.exit(1)
message = f"Uvicorn running on {addr_format} (Press CTRL+C to quit)"
color_message = "Uvicorn running on " + click.style(addr_format, bold=True) + " (Press CTRL+C to quit)"
protocol_name = "https" if self.is_ssl else "http"
logger_args = [protocol_name, self.host, sock.getsockname()[1]]
logger_args = [protocol_name, host, sock.getsockname()[1]]
logger.info(message, *logger_args, extra={"color_message": color_message})
sock.set_inheritable(True)
return sock
def bind_socket(self) -> socket.socket:
return self._bind_one(uds=self.uds, fd=self.fd, host=self.host, port=self.port)
def bind_sockets(self) -> list[socket.socket]:
if self.bind is None:
return [self.bind_socket()]
sockets: list[socket.socket] = []
for bind_str in self.bind:
if bind_str.startswith("unix:"): # pragma: py-win32
sock = self._bind_one(uds=bind_str[5:])
elif bind_str.startswith("fd://"): # pragma: py-win32
sock = self._bind_one(fd=int(bind_str[5:]))
else:
# Strip brackets for IPv6, then rsplit on last colon.
raw = bind_str.replace("[", "").replace("]", "")
try:
host, port_str = raw.rsplit(":", 1)
port = int(port_str)
except (ValueError, IndexError):
host, port = raw, 8000
sock = self._bind_one(host=host, port=port)
sockets.append(sock)
return sockets
@property
def bind_unix_paths(self) -> list[str]: # pragma: py-win32
return [b[5:] for b in (self.bind or []) if b.startswith("unix:")]
@property
def should_reload(self) -> bool:
return isinstance(self.app, str) and self.reload

View File

@ -77,6 +77,15 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
)
@click.option("--uds", type=str, default=None, help="Bind to a UNIX domain socket.")
@click.option("--fd", type=int, default=None, help="Bind to socket from this file descriptor.")
@click.option(
"--bind",
"-b",
"bind",
multiple=True,
help="Bind to a socket with this address. May be used multiple times. "
"Formats: HOST:PORT, [HOST]:PORT (IPv6), unix:PATH, fd://NUM. "
"Mutually exclusive with --host, --port, --uds, and --fd.",
)
@click.option("--reload", is_flag=True, default=False, help="Enable auto-reload.")
@click.option(
"--reload-dir",
@ -273,14 +282,6 @@ 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,
@ -372,13 +373,6 @@ 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,
@ -392,6 +386,7 @@ def main(
port: int,
uds: str,
fd: int,
bind: tuple[str, ...],
loop: LoopFactoryType | str,
http: HTTPProtocolType | str,
ws: WSProtocolType | str,
@ -420,7 +415,6 @@ 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,
@ -435,7 +429,6 @@ def main(
use_colors: bool,
app_dir: str,
h11_max_incomplete_event_size: int | None,
reset_contextvars: bool,
factory: bool,
) -> None:
run(
@ -444,6 +437,7 @@ def main(
port=port,
uds=uds,
fd=fd,
bind=list(bind) or None,
loop=loop,
http=http,
ws=ws,
@ -472,7 +466,6 @@ 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,
@ -488,7 +481,6 @@ def main(
factory=factory,
app_dir=app_dir,
h11_max_incomplete_event_size=h11_max_incomplete_event_size,
reset_contextvars=reset_contextvars,
)
@ -499,6 +491,7 @@ def run(
port: int = 8000,
uds: str | None = None,
fd: int | None = None,
bind: list[str] | None = None,
loop: LoopFactoryType | str = "auto",
http: type[asyncio.Protocol] | HTTPProtocolType | str = "auto",
ws: type[asyncio.Protocol] | WSProtocolType | str = "auto",
@ -516,7 +509,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 | os.PathLike[str] | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = LOGGING_CONFIG,
log_level: str | int | None = None,
access_log: bool = True,
proxy_headers: bool = True,
@ -527,7 +520,6 @@ 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,
@ -538,13 +530,11 @@ 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,
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)
@ -555,6 +545,7 @@ def run(
port=port,
uds=uds,
fd=fd,
bind=bind,
loop=loop,
http=http,
ws=ws,
@ -583,7 +574,6 @@ 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,
@ -594,35 +584,35 @@ 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()
ChangeReload(config, target=server.run, sockets=[sock]).run()
socks = config.bind_sockets()
ChangeReload(config, target=server.run, sockets=socks).run()
elif config.workers > 1:
sock = config.bind_socket()
Multiprocess(config, target=server.run, sockets=[sock]).run()
socks = config.bind_sockets()
Multiprocess(config, target=server.run, sockets=socks).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
for path in config.bind_unix_paths: # pragma: py-win32
if os.path.exists(path):
os.remove(path)
if not server.started and not config.should_reload and config.workers == 1:
sys.exit(STARTUP_FAILURE)

View File

@ -45,12 +45,16 @@ class ProxyHeadersMiddleware:
if b"x-forwarded-for" in headers:
x_forwarded_for = headers[b"x-forwarded-for"].decode("latin1")
host, port = self.trusted_hosts.get_trusted_client_address(x_forwarded_for)
host = self.trusted_hosts.get_trusted_client_host(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)
@ -60,41 +64,6 @@ 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"""
@ -153,22 +122,21 @@ class _TrustedHosts:
except ValueError:
return host in self.trusted_literals
def get_trusted_client_address(self, x_forwarded_for: str) -> tuple[str, int]:
"""Extract the client address from x_forwarded_for header.
def get_trusted_client_host(self, x_forwarded_for: str) -> str:
"""Extract the client host 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 _parse_host_port(x_forwarded_for_hosts[0])
return x_forwarded_for_hosts[0]
# Note: each proxy appends to the header list so check it in reverse order
for host_port in reversed(x_forwarded_for_hosts):
host, port = _parse_host_port(host_port)
for host in reversed(x_forwarded_for_hosts):
if host not in self:
return host, port
return host
# All hosts are trusted meaning that the client was also a trusted proxy
# See https://github.com/Kludex/uvicorn/issues/1068#issuecomment-855371576
return _parse_host_port(x_forwarded_for_hosts[0])
return x_forwarded_for_hosts[0]

View File

@ -4,9 +4,8 @@ import asyncio
import contextvars
import http
import logging
import sys
from collections.abc import Callable
from typing import Any, Literal
from typing import Any, Literal, cast
from urllib.parse import unquote
import h11
@ -78,7 +77,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 = None
self.server: tuple[str, int] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None
@ -250,16 +249,12 @@ class H11Protocol(asyncio.Protocol):
message_event=asyncio.Event(),
on_response=self.on_response_complete,
)
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))
# For the asyncio loop, we need to explicitly start with an empty context
# as it can be polluted from previous ASGI runs.
# See https://github.com/python/cpython/issues/140947 for details.
task = contextvars.Context().run(self.loop.create_task, self.cycle.run_asgi(app))
# TODO: Replace the line above with the line below for Python >= 3.11
# task = self.loop.create_task(self.cycle.run_asgi(app), context=contextvars.Context())
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
@ -402,7 +397,7 @@ class RequestResponseCycle:
self.waiting_for_100_continue = conn.they_are_waiting_for_100_continue
# Request state
self.body = bytearray()
self.body = b""
self.more_body = True
# Response state
@ -457,6 +452,8 @@ 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
@ -465,8 +462,10 @@ class RequestResponseCycle:
if not self.response_started:
# Sending response status line and headers
if message["type"] != "http.response.start":
raise RuntimeError(f"Expected ASGI message 'http.response.start', but got '{message['type']}'.")
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)
self.response_started = True
self.waiting_for_100_continue = False
@ -495,8 +494,10 @@ class RequestResponseCycle:
elif not self.response_complete:
# Sending response body
if message["type"] != "http.response.body":
raise RuntimeError(f"Expected ASGI message 'http.response.body', but got '{message['type']}'.")
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)
body = message.get("body", b"")
more_body = message.get("more_body", False)
@ -515,7 +516,8 @@ class RequestResponseCycle:
else:
# Response already sent
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
msg = "Unexpected ASGI message '%s' sent, after response already completed."
raise RuntimeError(msg % message_type)
if self.response_complete:
if self.conn.our_state is h11.MUST_CLOSE or not self.keep_alive:
@ -539,6 +541,10 @@ class RequestResponseCycle:
if self.disconnected or self.response_complete:
return {"type": "http.disconnect"}
message: HTTPRequestEvent = {"type": "http.request", "body": bytes(self.body), "more_body": self.more_body}
self.body = bytearray()
message: HTTPRequestEvent = {
"type": "http.request",
"body": self.body,
"more_body": self.more_body,
}
self.body = b""
return message

View File

@ -5,12 +5,11 @@ import contextvars
import http
import logging
import re
import sys
import urllib
from asyncio.events import TimerHandle
from collections import deque
from collections.abc import Callable
from typing import Any, Literal
from typing import Any, Literal, cast
import httptools
@ -19,6 +18,7 @@ from uvicorn._types import (
ASGIReceiveEvent,
ASGISendEvent,
HTTPRequestEvent,
HTTPResponseStartEvent,
HTTPScope,
)
from uvicorn.config import Config
@ -27,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]")
@ -85,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 = None
self.server: tuple[str, int] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["http", "https"] | None = None
self.pipeline: deque[tuple[RequestResponseCycle, ASGI3Application]] = deque()
@ -289,26 +289,19 @@ class HttpToolsProtocol(asyncio.Protocol):
)
if existing_cycle is None or existing_cycle.response_complete:
# Standard case - start processing the request.
self._start_asgi_task(self.cycle, app)
# For the asyncio loop, we need to explicitly start with an empty context
# as it can be polluted from previous ASGI runs.
# See https://github.com/python/cpython/issues/140947 for details.
task = contextvars.Context().run(self.loop.create_task, self.cycle.run_asgi(app))
# TODO: Replace the line above with the line below for Python >= 3.11
# task = self.loop.create_task(self.cycle.run_asgi(app), context=contextvars.Context())
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
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
@ -339,7 +332,9 @@ class HttpToolsProtocol(asyncio.Protocol):
# Keep-Alive timeout instead.
if self.pipeline:
cycle, app = self.pipeline.pop()
self._start_asgi_task(cycle, app)
task = self.loop.create_task(cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
else:
self.timeout_keep_alive_task = self.loop.call_later(
self.timeout_keep_alive, self.timeout_keep_alive_handler
@ -406,7 +401,7 @@ class RequestResponseCycle:
self.waiting_for_100_continue = expect_100_continue
# Request state
self.body = bytearray()
self.body = b""
self.more_body = True
# Response state
@ -460,6 +455,8 @@ 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
@ -468,8 +465,10 @@ class RequestResponseCycle:
if not self.response_started:
# Sending response status line and headers
if message["type"] != "http.response.start":
raise RuntimeError(f"Expected ASGI message 'http.response.start', but got '{message['type']}'.")
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)
self.response_started = True
self.waiting_for_100_continue = False
@ -520,10 +519,11 @@ class RequestResponseCycle:
elif not self.response_complete:
# Sending response body
if message["type"] != "http.response.body":
raise RuntimeError(f"Expected ASGI message 'http.response.body', but got '{message['type']}'.")
if message_type != "http.response.body":
msg = "Expected ASGI message 'http.response.body', but got '%s'."
raise RuntimeError(msg % message_type)
body = message.get("body", b"")
body = cast(bytes, message.get("body", b""))
more_body = message.get("more_body", False)
# Write response body
@ -557,7 +557,8 @@ class RequestResponseCycle:
else:
# Response already sent
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
msg = "Unexpected ASGI message '%s' sent, after response already completed."
raise RuntimeError(msg % message_type)
async def receive(self) -> ASGIReceiveEvent:
if self.waiting_for_100_continue and not self.transport.is_closing():
@ -571,6 +572,6 @@ class RequestResponseCycle:
if self.disconnected or self.response_complete:
return {"type": "http.disconnect"}
message: HTTPRequestEvent = {"type": "http.request", "body": bytes(self.body), "more_body": self.more_body}
self.body = bytearray()
message: HTTPRequestEvent = {"type": "http.request", "body": self.body, "more_body": self.more_body}
self.body = b""
return message

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import socket
import urllib.parse
from uvicorn._types import WWWScope
@ -11,7 +10,7 @@ class ClientDisconnected(OSError): ...
def get_remote_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
socket_info: socket.socket | None = transport.get_extra_info("socket")
socket_info = transport.get_extra_info("socket")
if socket_info is not None:
try:
info = socket_info.getpeername()
@ -27,20 +26,15 @@ def get_remote_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
return None
def get_local_addr(transport: asyncio.Transport) -> tuple[str, int | None] | None:
socket_info: socket.socket | None = transport.get_extra_info("socket")
def get_local_addr(transport: asyncio.Transport) -> tuple[str, int] | None:
socket_info = transport.get_extra_info("socket")
if socket_info is not None:
info = socket_info.getsockname()
if isinstance(info, tuple):
return (str(info[0]), int(info[1]))
if isinstance(info, str):
return (info, None)
return None
return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None
info = transport.get_extra_info("sockname")
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

@ -20,10 +20,15 @@ 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
@ -77,7 +82,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int | None] | None = None
self.server: tuple[str, int] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -257,8 +262,11 @@ class WebSocketProtocol(WebSocketServerProtocol):
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":
if message_type == "websocket.accept":
message = cast("WebSocketAcceptEvent", message)
self.logger.info(
'%s - "WebSocket %s" [accepted]',
get_client_addr(self.scope),
@ -275,7 +283,8 @@ class WebSocketProtocol(WebSocketServerProtocol):
)
self.handshake_started_event.set()
elif message["type"] == "websocket.close":
elif message_type == "websocket.close":
message = cast("WebSocketCloseEvent", message)
self.logger.info(
'%s - "WebSocket %s" 403',
get_client_addr(self.scope),
@ -285,7 +294,8 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.handshake_started_event.set()
self.closed_event.set()
elif message["type"] == "websocket.http.response.start":
elif message_type == "websocket.http.response.start":
message = cast("WebSocketResponseStartEvent", message)
self.logger.info(
'%s - "WebSocket %s" %d',
get_client_addr(self.scope),
@ -301,48 +311,50 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.handshake_started_event.set()
else:
raise RuntimeError(
msg = (
"Expected ASGI message 'websocket.accept', 'websocket.close', "
f"or 'websocket.http.response.start' but got '{message['type']}'."
"or 'websocket.http.response.start' but got '%s'."
)
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":
if message_type == "websocket.send":
message = cast("WebSocketSendEvent", message)
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":
elif message_type == "websocket.close":
message = cast("WebSocketCloseEvent", message)
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
await self.close(code, reason)
self.closed_event.set()
else:
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
except ConnectionClosed as exc:
raise ClientDisconnected from exc
elif self.initial_response is not None:
if message["type"] == "websocket.http.response.body":
if message_type == "websocket.http.response.body":
message = cast("WebSocketResponseBodyEvent", message)
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:
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
else:
raise RuntimeError(
f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close' "
"or response already completed."
)
msg = "Unexpected ASGI message '%s', after sending 'websocket.close' or response already completed."
raise RuntimeError(msg % message_type)
async def asgi_receive(self) -> WebSocketDisconnectEvent | WebSocketConnectEvent | WebSocketReceiveEvent:
if not self.connect_sent:

View File

@ -2,10 +2,7 @@ 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
@ -20,13 +17,17 @@ 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,
@ -65,7 +66,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int | None] | None = None
self.server: tuple[str, int] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -95,17 +96,8 @@ 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 = bytearray()
self.bytes = b""
def connection_made(self, transport: BaseTransport) -> None:
"""Called when a connection is made."""
@ -121,7 +113,6 @@ 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)
@ -138,7 +129,6 @@ 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)
@ -169,7 +159,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
elif event.opcode == Opcode.PING:
self.handle_ping()
elif event.opcode == Opcode.PONG:
self.handle_pong(event)
pass # pragma: no cover
elif event.opcode == Opcode.CLOSE:
self.handle_close(event)
else:
@ -197,14 +187,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.4"},
"asgi": {"version": self.config.asgi_version, "spec_version": "2.3"},
"http_version": "1.1",
"scheme": self.scheme,
"server": self.server,
"client": self.client,
"root_path": self.root_path,
"path": self.root_path + unquote(raw_path),
"raw_path": self.root_path.encode("ascii") + raw_path.encode("ascii"),
"path": unquote(raw_path),
"raw_path": raw_path.encode("ascii"),
"query_string": query_string.encode("ascii"),
"headers": headers,
"subprotocols": event.headers.get_all("Sec-WebSocket-Protocol"),
@ -216,19 +206,19 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
task.add_done_callback(self.on_task_complete)
self.tasks.add(task)
def handle_cont(self, event: Frame) -> None:
self.bytes.extend(event.data)
def handle_cont(self, event: Frame) -> None: # pragma: no cover
self.bytes += event.data
if event.fin:
self.send_receive_event_to_app()
def handle_text(self, event: Frame) -> None:
self.bytes = bytearray(event.data)
self.bytes = 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 = bytearray(event.data)
self.bytes = event.data
self.curr_msg_data_type = "bytes"
if event.fin:
self.send_receive_event_to_app()
@ -243,7 +233,7 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.handle_parser_exception()
return
else:
self.queue.put_nowait({"type": "websocket.receive", "bytes": bytes(self.bytes)})
self.queue.put_nowait({"type": "websocket.receive", "bytes": self.bytes})
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()
@ -252,67 +242,6 @@ 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
@ -365,15 +294,18 @@ 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":
if message_type == "websocket.accept":
message = cast(WebSocketAcceptEvent, message)
self.logger.info(
'%s - "WebSocket %s" [accepted]',
get_client_addr(self.scope),
self.scope["client"],
get_path_with_query_string(self.scope),
)
headers = [
(name.decode("latin-1").lower(), value.decode("latin-1"))
(name.decode("latin-1").lower(), value.decode("latin-1").lower())
for name, value in (self.default_headers + list(message.get("headers", [])))
]
accepted_subprotocol = message.get("subprotocol")
@ -386,13 +318,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":
elif message_type == "websocket.close":
message = cast(WebSocketCloseEvent, message)
self.queue.put_nowait({"type": "websocket.disconnect", "code": 1006})
self.logger.info(
'%s - "WebSocket %s" 403',
get_client_addr(self.scope),
self.scope["client"],
get_path_with_query_string(self.scope),
)
response = self.conn.reject(HTTPStatus.FORBIDDEN, "")
@ -402,12 +334,13 @@ 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:
elif message_type == "websocket.http.response.start" and self.initial_response is None:
message = cast(WebSocketResponseStartEvent, message)
if not (100 <= message["status"] < 600):
raise RuntimeError("Invalid HTTP status code '%d' in response." % message["status"])
self.logger.info(
'%s - "WebSocket %s" %d',
get_client_addr(self.scope),
self.scope["client"],
get_path_with_query_string(self.scope),
message["status"],
)
@ -417,41 +350,44 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
]
self.initial_response = (message["status"], headers, b"")
else:
raise RuntimeError(
msg = (
"Expected ASGI message 'websocket.accept', 'websocket.close' "
f"or 'websocket.http.response.start' but got '{message['type']}'."
"or 'websocket.http.response.start' "
"but got '%s'."
)
raise RuntimeError(msg % message_type)
elif not self.close_sent and self.initial_response is None:
try:
if message["type"] == "websocket.send":
if message_type == "websocket.send":
message = cast(WebSocketSendEvent, message)
bytes_data = message.get("bytes")
text_data = message.get("text")
if bytes_data is not None:
self.conn.send_binary(bytes_data)
elif text_data is not None:
if text_data:
self.conn.send_text(text_data.encode())
elif bytes_data:
self.conn.send_binary(bytes_data)
output = self.conn.data_to_send()
self.transport.write(b"".join(output))
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()
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()
else:
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
except InvalidState:
raise ClientDisconnected()
elif self.initial_response is not None:
if message["type"] == "websocket.http.response.body":
if message_type == "websocket.http.response.body":
message = cast(WebSocketResponseBodyEvent, message)
body = self.initial_response[2] + message["body"]
self.initial_response = self.initial_response[:2] + (body,)
if not message.get("more_body", False):
@ -464,10 +400,12 @@ class WebSocketsSansIOProtocol(asyncio.Protocol):
self.transport.write(b"".join(output))
self.transport.close()
else: # pragma: no cover
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
else:
raise RuntimeError(f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close'.")
msg = "Unexpected ASGI message '%s', after sending 'websocket.close'."
raise RuntimeError(msg % message_type)
async def receive(self) -> ASGIReceiveEvent:
message = await self.queue.get()

View File

@ -2,10 +2,6 @@ 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
@ -15,7 +11,17 @@ from wsproto.connection import ConnectionState
from wsproto.extensions import Extension, PerMessageDeflate
from wsproto.utilities import LocalProtocolError, RemoteProtocolError
from uvicorn._types import ASGI3Application, ASGISendEvent, WebSocketEvent, WebSocketReceiveEvent, WebSocketScope
from uvicorn._types import (
ASGI3Application,
ASGISendEvent,
WebSocketAcceptEvent,
WebSocketCloseEvent,
WebSocketEvent,
WebSocketResponseBodyEvent,
WebSocketResponseStartEvent,
WebSocketScope,
WebSocketSendEvent,
)
from uvicorn.config import Config
from uvicorn.logging import TRACE_LOG_LEVEL
from uvicorn.protocols.utils import (
@ -29,36 +35,6 @@ 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,
@ -84,7 +60,7 @@ class WSProtocol(asyncio.Protocol):
# Connection state
self.transport: asyncio.Transport = None # type: ignore[assignment]
self.server: tuple[str, int | None] | None = None
self.server: tuple[str, int] | None = None
self.client: tuple[str, int] | None = None
self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
@ -102,21 +78,15 @@ class WSProtocol(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
# Buffer
self.buffer = WebsocketBuffer(self.config.ws_max_size)
# Buffers
self.bytes = b""
self.text = ""
# Protocol interface
def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override]
def connection_made( # type: ignore[override]
self, transport: asyncio.Transport
) -> None:
self.connections.add(self)
self.transport = transport
self.server = get_local_addr(transport)
@ -128,7 +98,6 @@ 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)
@ -156,18 +125,16 @@ 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, events.BytesMessage)):
self.handle_message(event)
elif isinstance(event, events.TextMessage):
self.handle_text(event)
elif isinstance(event, events.BytesMessage):
self.handle_bytes(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:
"""
@ -182,7 +149,6 @@ 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))
@ -224,20 +190,21 @@ class WSProtocol(asyncio.Protocol):
task.add_done_callback(self.on_task_complete)
self.tasks.add(task)
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
def handle_text(self, event: events.TextMessage) -> None:
self.text += event.data
if event.message_finished:
self.queue.put_nowait(self.buffer.to_message())
self.buffer.clear()
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""
if not self.read_paused:
self.read_paused = True
self.transport.pause_reading()
@ -251,65 +218,6 @@ 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
@ -341,8 +249,11 @@ class WSProtocol(asyncio.Protocol):
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":
if message_type == "websocket.accept":
message = cast(WebSocketAcceptEvent, message)
self.logger.info(
'%s - "WebSocket %s" [accepted]',
get_client_addr(self.scope),
@ -363,9 +274,8 @@ 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',
@ -379,7 +289,8 @@ class WSProtocol(asyncio.Protocol):
self.transport.write(output)
self.transport.close()
elif message["type"] == "websocket.http.response.start":
elif message_type == "websocket.http.response.start":
message = cast(WebSocketResponseStartEvent, message)
# ensure status code is in the valid range
if not (100 <= message["status"] < 600):
msg = "Invalid HTTP status code '%d' in response."
@ -401,14 +312,17 @@ class WSProtocol(asyncio.Protocol):
self.response_started = True
else:
raise RuntimeError(
msg = (
"Expected ASGI message 'websocket.accept', 'websocket.close' "
f"or 'websocket.http.response.start' but got '{message['type']}'."
"or 'websocket.http.response.start' "
"but got '%s'."
)
raise RuntimeError(msg % message_type)
elif not self.close_sent and not self.response_started:
try:
if message["type"] == "websocket.send":
if message_type == "websocket.send":
message = cast(WebSocketSendEvent, message)
bytes_data = message.get("bytes")
text_data = message.get("text")
data = text_data if bytes_data is None else bytes_data
@ -416,7 +330,8 @@ class WSProtocol(asyncio.Protocol):
if not self.transport.is_closing():
self.transport.write(output)
elif message["type"] == "websocket.close":
elif message_type == "websocket.close":
message = cast(WebSocketCloseEvent, message)
self.close_sent = True
code = message.get("code", 1000)
reason = message.get("reason", "") or ""
@ -427,13 +342,13 @@ class WSProtocol(asyncio.Protocol):
self.transport.close()
else:
raise RuntimeError(
f"Expected ASGI message 'websocket.send' or 'websocket.close', but got '{message['type']}'."
)
msg = "Expected ASGI message 'websocket.send' or 'websocket.close', but got '%s'."
raise RuntimeError(msg % message_type)
except LocalProtocolError as exc:
raise ClientDisconnected from exc
elif self.response_started:
if message["type"] == "websocket.http.response.body":
if message_type == "websocket.http.response.body":
message = cast("WebSocketResponseBodyEvent", message)
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)
@ -445,10 +360,12 @@ class WSProtocol(asyncio.Protocol):
self.transport.close()
else:
raise RuntimeError(f"Expected ASGI message 'websocket.http.response.body' but got '{message['type']}'.")
msg = "Expected ASGI message 'websocket.http.response.body' but got '%s'."
raise RuntimeError(msg % message_type)
else:
raise RuntimeError(f"Unexpected ASGI message '{message['type']}', after sending 'websocket.close'.")
msg = "Unexpected ASGI message '%s', after sending 'websocket.close'."
raise RuntimeError(msg % message_type)
async def receive(self) -> WebSocketEvent:
message = await self.queue.get()

View File

@ -2,11 +2,9 @@ from __future__ import annotations
import asyncio
import contextlib
import functools
import logging
import os
import platform
import random
import signal
import socket
import sys
@ -65,12 +63,6 @@ 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())
@ -85,6 +77,9 @@ class Server:
if not config.loaded:
config.load()
if sockets is None and config.bind is not None:
sockets = config.bind_sockets()
self.lifespan = config.lifespan_class(config)
message = "Started server process [%d]"
@ -92,14 +87,14 @@ class Server:
logger.info(message, process_id, extra={"color_message": color_message})
await self.startup(sockets=sockets)
if not self.should_exit:
await self.main_loop()
if self.started:
await self.shutdown(sockets=sockets)
if self.should_exit:
return
await self.main_loop()
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()
@ -126,9 +121,7 @@ class Server:
# Explicitly passed a list of open sockets.
# We use this when the server is run from a Gunicorn worker.
def _share_socket(
sock: socket.SocketType,
) -> socket.SocketType: # pragma py-not-win32
def _share_socket(sock: socket.SocketType) -> socket.SocketType: # pragma py-not-win32
# Windows requires the socket be explicitly shared across
# multiple workers (processes).
from socket import fromshare # type: ignore[attr-defined]
@ -199,10 +192,7 @@ class Server:
if config.fd is not None: # pragma: py-win32
sock = listeners[0]
logger.info(
"Uvicorn running on socket %s (Press CTRL+C to quit)",
sock.getsockname(),
)
logger.info("Uvicorn running on socket %s (Press CTRL+C to quit)", sock.getsockname())
elif config.uds is not None: # pragma: py-win32
logger.info("Uvicorn running on unix socket %s (Press CTRL+C to quit)", config.uds)
@ -261,9 +251,9 @@ class Server:
if self.should_exit:
return True
max_requests = self.limit_max_requests
max_requests = self.config.limit_max_requests
if max_requests is not None and self.server_state.total_requests >= max_requests:
logger.info("Maximum request limit of %d exceeded. Terminating process.", max_requests)
logger.warning(f"Maximum request limit of {max_requests} exceeded. Terminating process.")
return True
return False

View File

@ -61,7 +61,7 @@ class WatchFilesReload(BaseReload):
) -> None:
super().__init__(config, target, sockets)
self.reloader_name = "WatchFiles"
self.reload_dirs: list[Path] = []
self.reload_dirs = []
for directory in config.reload_dirs:
self.reload_dirs.append(directory)
@ -73,7 +73,6 @@ 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: