diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml
new file mode 100644
index 00000000..632b09a9
--- /dev/null
+++ b/.github/workflows/codspeed.yml
@@ -0,0 +1,36 @@
+name: CodSpeed
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main", "version-*"]
+ # `workflow_dispatch` allows CodSpeed to trigger backtest
+ # performance analysis in order to generate initial data.
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ benchmarks:
+ name: Run benchmarks
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ python-version: "3.13"
+ enable-cache: true
+
+ - name: Install dependencies
+ run: uv sync --group dev
+
+ - name: Run benchmarks
+ uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
+ with:
+ mode: simulation
+ run: uv run pytest tests/benchmarks/ --codspeed
diff --git a/README.md b/README.md
index cf1ea5ea..23818d2f 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
+
HTTPX2 is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**.
diff --git a/pyproject.toml b/pyproject.toml
index 76de1795..ec447b9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dev = [
# Packaging
"build==1.3.0",
"twine==6.1.0",
+ "pytest-codspeed>=4.5.0",
]
docs = ["zensical>=0.0.41", "mkdocstrings[python]>=0.27"]
bench = [
diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/benchmarks/test_content.py b/tests/benchmarks/test_content.py
new file mode 100644
index 00000000..2d4b9561
--- /dev/null
+++ b/tests/benchmarks/test_content.py
@@ -0,0 +1,44 @@
+"""Benchmarks for request/response content encoding."""
+
+from httpx2._content import encode_content, encode_json, encode_urlencoded_data
+
+
+def test_encode_content_bytes_small(benchmark):
+ """Encode a small bytes payload."""
+ benchmark(encode_content, b"Hello, world!")
+
+
+def test_encode_content_bytes_medium(benchmark):
+ """Encode a 10 KB bytes payload."""
+ payload = b"x" * 10_240
+ benchmark(encode_content, payload)
+
+
+def test_encode_content_str(benchmark):
+ """Encode a string payload (triggers UTF-8 encoding)."""
+ benchmark(encode_content, "Hello, world! " * 100)
+
+
+def test_encode_urlencoded_data(benchmark):
+ """Encode URL-encoded form data."""
+ data = {
+ "username": "admin",
+ "password": "secret",
+ "remember": "true",
+ "redirect": "/dashboard",
+ }
+ benchmark(encode_urlencoded_data, data)
+
+
+def test_encode_json_small(benchmark):
+ """Encode a small JSON body."""
+ benchmark(encode_json, {"key": "value"})
+
+
+def test_encode_json_nested(benchmark):
+ """Encode a nested JSON body."""
+ payload = {
+ "users": [{"id": i, "name": f"user_{i}", "email": f"user_{i}@example.com"} for i in range(20)],
+ "meta": {"page": 1, "total": 100},
+ }
+ benchmark(encode_json, payload)
diff --git a/tests/benchmarks/test_decoders.py b/tests/benchmarks/test_decoders.py
new file mode 100644
index 00000000..ff99747b
--- /dev/null
+++ b/tests/benchmarks/test_decoders.py
@@ -0,0 +1,48 @@
+"""Benchmarks for response decoders."""
+
+from httpx2._decoders import ByteChunker, IdentityDecoder, LineDecoder, TextDecoder
+
+
+def test_identity_decoder(benchmark):
+ """Decode a payload through the identity (no-op) decoder."""
+ decoder = IdentityDecoder()
+ data = b"Hello, world!" * 100
+ benchmark(decoder.decode, data)
+
+
+def test_text_decoder(benchmark):
+ """Decode bytes to text using TextDecoder."""
+ data = b"Hello, world! This is a test of the text decoder. " * 50
+
+ def run():
+ decoder = TextDecoder(encoding="utf-8")
+ decoder.decode(data)
+ decoder.flush()
+
+ benchmark(run)
+
+
+def test_line_decoder(benchmark):
+ """Split text into lines using LineDecoder."""
+ text = "line1\nline2\nline3\nline4\nline5\n" * 20
+
+ def run():
+ decoder = LineDecoder()
+ lines = list(decoder.decode(text))
+ lines.extend(decoder.flush())
+ return lines
+
+ benchmark(run)
+
+
+def test_byte_chunker(benchmark):
+ """Chunk bytes into fixed-size pieces."""
+ data = b"x" * 10_000
+
+ def run():
+ chunker = ByteChunker(chunk_size=1024)
+ chunks = list(chunker.decode(data))
+ chunks.extend(chunker.flush())
+ return chunks
+
+ benchmark(run)
diff --git a/tests/benchmarks/test_models.py b/tests/benchmarks/test_models.py
new file mode 100644
index 00000000..59c1d78b
--- /dev/null
+++ b/tests/benchmarks/test_models.py
@@ -0,0 +1,115 @@
+"""Benchmarks for Headers, Request, and Response construction."""
+
+from httpx2 import Headers, Request, Response
+from httpx2._urls import URL
+
+# ---------------------------------------------------------------------------
+# Headers
+# ---------------------------------------------------------------------------
+
+
+HEADER_LIST = [
+ ("Content-Type", "application/json"),
+ ("Content-Length", "1024"),
+ ("Authorization", "Bearer token123"),
+ ("Accept", "text/html"),
+ ("Accept-Encoding", "gzip, deflate, br"),
+ ("Cache-Control", "no-cache"),
+ ("X-Request-ID", "abc-def-123"),
+ ("X-Custom-Header", "custom-value"),
+]
+
+
+def test_headers_from_list(benchmark):
+ """Construct Headers from a list of tuples."""
+ benchmark(Headers, HEADER_LIST)
+
+
+def test_headers_getitem(benchmark):
+ """Look up a header value by name (case-insensitive)."""
+ headers = Headers(HEADER_LIST)
+ benchmark(headers.__getitem__, "content-type")
+
+
+def test_headers_contains(benchmark):
+ """Check header presence."""
+ headers = Headers(HEADER_LIST)
+ benchmark(headers.__contains__, "Authorization")
+
+
+def test_headers_items(benchmark):
+ """Iterate over all header items."""
+ headers = Headers(HEADER_LIST)
+ benchmark(lambda: dict(headers.items()))
+
+
+def test_headers_copy(benchmark):
+ """Copy a Headers instance."""
+ headers = Headers(HEADER_LIST)
+ benchmark(headers.copy)
+
+
+# ---------------------------------------------------------------------------
+# URL
+# ---------------------------------------------------------------------------
+
+
+def test_url_construction(benchmark):
+ """Build a URL object from a string."""
+ benchmark(URL, "https://www.example.com/path?query=value#fragment")
+
+
+def test_url_copy_with(benchmark):
+ """Copy a URL, replacing the path component."""
+ url = URL("https://www.example.com/path?query=value#fragment")
+ benchmark(url.copy_with, path="/new-path")
+
+
+# ---------------------------------------------------------------------------
+# Request
+# ---------------------------------------------------------------------------
+
+
+def test_request_get(benchmark):
+ """Construct a minimal GET request."""
+ benchmark(Request, "GET", "https://example.com/")
+
+
+def test_request_post_json(benchmark):
+ """Construct a POST request with a JSON body."""
+ benchmark(Request, "POST", "https://example.com/api", json={"key": "value", "count": 42})
+
+
+def test_request_post_form(benchmark):
+ """Construct a POST request with form-encoded data."""
+ benchmark(Request, "POST", "https://example.com/api", data={"username": "admin", "password": "secret"})
+
+
+def test_request_with_headers(benchmark):
+ """Construct a GET request with custom headers."""
+ benchmark(
+ Request,
+ "GET",
+ "https://example.com/",
+ headers={"Authorization": "Bearer token", "Accept": "application/json"},
+ )
+
+
+# ---------------------------------------------------------------------------
+# Response
+# ---------------------------------------------------------------------------
+
+
+def test_response_text(benchmark):
+ """Construct and read a plain-text Response."""
+ benchmark(Response, 200, text="Hello, world!")
+
+
+def test_response_json(benchmark):
+ """Construct and read a JSON Response."""
+ benchmark(Response, 200, json={"message": "ok", "data": [1, 2, 3]})
+
+
+def test_response_html(benchmark):
+ """Construct and read an HTML Response."""
+ benchmark(Response, 200, html="Hello
")
diff --git a/tests/benchmarks/test_urlparse.py b/tests/benchmarks/test_urlparse.py
new file mode 100644
index 00000000..cbff8742
--- /dev/null
+++ b/tests/benchmarks/test_urlparse.py
@@ -0,0 +1,38 @@
+"""Benchmarks for URL parsing — one of the hottest paths in httpx2."""
+
+from httpx2._urlparse import urlparse
+
+
+def test_urlparse_simple(benchmark):
+ """Parse a simple HTTP URL."""
+ benchmark(urlparse, "https://www.example.com/path")
+
+
+def test_urlparse_with_query_and_fragment(benchmark):
+ """Parse a URL containing query parameters and a fragment."""
+ benchmark(urlparse, "https://www.example.com/path?key=value&foo=bar#section")
+
+
+def test_urlparse_complex(benchmark):
+ """Parse a URL with userinfo, port, long path, query, and fragment."""
+ benchmark(urlparse, "https://user:password@www.example.com:8443/path/to/resource?query=value&page=1#fragment")
+
+
+def test_urlparse_ipv6(benchmark):
+ """Parse a URL with an IPv6 host."""
+ benchmark(urlparse, "https://[::1]:8080/path")
+
+
+def test_urlparse_unicode_host(benchmark):
+ """Parse a URL with a unicode (IDNA) hostname."""
+ benchmark(urlparse, "https://münchen.de/path")
+
+
+def test_urlparse_long_path(benchmark):
+ """Parse a URL with a deeply nested path."""
+ benchmark(urlparse, "https://example.com/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p")
+
+
+def test_urlparse_with_encoded_characters(benchmark):
+ """Parse a URL containing percent-encoded characters."""
+ benchmark(urlparse, "https://example.com/path%20with%20spaces?q=hello%20world")
diff --git a/uv.lock b/uv.lock
index f5a126dd..f8c3678f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -38,6 +38,7 @@ dev = [
{ name = "httpx2", extras = ["brotli", "cli", "http2", "socks", "zstd"], editable = "src/httpx2" },
{ name = "mypy", specifier = "==1.17.1" },
{ name = "pytest", specifier = ">=9.0.3" },
+ { name = "pytest-codspeed", specifier = ">=4.5.0" },
{ name = "pytest-httpbin", specifier = "==2.0.0" },
{ name = "pytest-trio", specifier = "==0.8.0" },
{ name = "ruff", specifier = "==0.12.11" },
@@ -2745,6 +2746,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
+[[package]]
+name = "pytest-codspeed"
+version = "4.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+ { name = "pytest" },
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/1e/213eb4d263140fb907e9fcc5813fdcadb864d6832bf8e2d3f7fd88ca0096/pytest_codspeed-4.5.0.tar.gz", hash = "sha256:deb6ab9c9b07eba56fcb7b97206c7e48aaff697b6f73a013d8dbe4f62e76afd3", size = 209664, upload-time = "2026-04-28T13:12:17.726Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/2d/dd7be8a84dac07f0b72a1372252fc66688533a7771910cdd58544a8b6f36/pytest_codspeed-4.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ddc80dda2018aae3bcac9571d47de26aacd9cfb1764b3a1704fa269474cc83f7", size = 222525, upload-time = "2026-04-28T13:11:49.264Z" },
+ { url = "https://files.pythonhosted.org/packages/09/06/1daee2c11b5873dd42799f989a0d4b39ba1c33dbe4adc6339f1c48edb28e/pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:108ae3fecf8a665f017f2abc92a4d9740c57eb8432436baeb489053787427504", size = 822704, upload-time = "2026-04-28T13:11:51.732Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/47/85b5a6f3ee82cd19374abd244df6fc011e9acd559fc283bdf8cbc6e156f6/pytest_codspeed-4.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8b7a880f2cac69d167affe5e85d9fc7f21beeb1c7591ef2109fbc0983b806a4", size = 823667, upload-time = "2026-04-28T13:11:53.15Z" },
+ { url = "https://files.pythonhosted.org/packages/60/f9/be1fa43649c9f71cc06d9f2330fb1cac3beddf6357effc9a1817f4831728/pytest_codspeed-4.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6da6f26435512110736dd258021bbf7859caf4d2a21c7ed06a86b67a999fac7", size = 222523, upload-time = "2026-04-28T13:11:54.638Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/89/9237a2d569b60f84183f6ae6193c6a4a135e5644ee08fed44bcf03d26545/pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be191120b1cb0252b443ef37887c94772bab4ca0c42cad7c15bcbcfcbb656ac4", size = 822696, upload-time = "2026-04-28T13:11:55.811Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/15/7dd0a37fb85e19d8b2b7366f9615c4e17335f23060275dcfa792ce8b482f/pytest_codspeed-4.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474730e996d424b17f7301d4b846261cca92d195b9fcb7de38599be9d68ee9ac", size = 823671, upload-time = "2026-04-28T13:11:57.147Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/3d/bf21b10c6d497378785b47e9cefcfc4a43e543443e120c03469940f14a61/pytest_codspeed-4.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db706a7a4200e8e236c31c77935fedcc0edbf44959ab8c156297909d9e8cfd33", size = 222601, upload-time = "2026-04-28T13:11:58.24Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/65/97823f28ae60921bf353773490906f9095e9d208a6d4bec2e7913695a5e6/pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac844078bd8760e7fc66debe1e90b4593dfce15f60f26b334e1137d4902df3a9", size = 822916, upload-time = "2026-04-28T13:11:59.648Z" },
+ { url = "https://files.pythonhosted.org/packages/95/10/4763d26e8255f243c96e39543d398afb2c64900d3785b8af1898b23a6ce0/pytest_codspeed-4.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66ecd52a277a5e5f0013e29084b49f9c5f60026d0585f58b86463cb188df5029", size = 823963, upload-time = "2026-04-28T13:12:00.976Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/7b/8108a06fcad6160759efc0a1d44e359414a4d23e52bb7079ca95be24058e/pytest_codspeed-4.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcc3309d046082a6e0dbd1d9f2bc5c83b0446c93ff011e3880b47c69bf8042cf", size = 222602, upload-time = "2026-04-28T13:12:01.974Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b4/4a43ce824cabe2ab8a727e31f90aa403dd2cd580576057024065a3ea74a3/pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12b49954268ed6828ce5a8d87aff13888946c254bff4ef9472bb4d5ae5272667", size = 822868, upload-time = "2026-04-28T13:12:02.988Z" },
+ { url = "https://files.pythonhosted.org/packages/54/f0/a319da002c800915b9f6a63b2da1e6cdd3230cafb9dea255cec4033e85f8/pytest_codspeed-4.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbeeb76d98335037670068c0d30319415f896e9c37eca510249b74684b460925", size = 823928, upload-time = "2026-04-28T13:12:04.467Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5b/d46caecce8aa7519477df75351e312203a20836bec2fcc15256ec34c001b/pytest_codspeed-4.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1b73f71e7cb5c83cf5d765d5ca39d08bb1090a9d2d2268496a22ca24b1776e3a", size = 222618, upload-time = "2026-04-28T13:12:05.482Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1d/8f34de29cfc3516df25a4553a6d7912735fbde9a276d448b1e00eb35a345/pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399e146240a52458aa4b5fc861a88551bc52eb9e2d30c8f8b328ddebc084e4f6", size = 822814, upload-time = "2026-04-28T13:12:06.425Z" },
+ { url = "https://files.pythonhosted.org/packages/75/3d/089614f7bd75fee1388885b886c3f6c1a332ffdce28a4b6b77d3aac7014f/pytest_codspeed-4.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4b43f59d1c31e7c193567369f767647e466f95126671c90be084c58633544f", size = 823857, upload-time = "2026-04-28T13:12:08.081Z" },
+ { url = "https://files.pythonhosted.org/packages/df/69/5f4a032df6508e8c59049b2fcfce568b79e40b82878df12a2e401a034336/pytest_codspeed-4.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4ef8651294386c032d86070893f8349929280162cf22210dbd488697ce26de21", size = 222781, upload-time = "2026-04-28T13:12:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/63/42/86a1efde2968bfc83e4fcd60ef1a1094be7f83460799296a12d563522a67/pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca31f5d0e783823a78442d5434382eb32f3885153d1833eb645c92d0c499470b", size = 828703, upload-time = "2026-04-28T13:12:10.502Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4e/eae070c50cb82e44f831dd5b24c854cb641906732bdf74f6314e71c1f266/pytest_codspeed-4.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ddd1a9f2dc0615479b2ba3f445a2e3587ce1316296fc79224700e73db06408", size = 829278, upload-time = "2026-04-28T13:12:11.879Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2d/8dd5e44a5518ba3cd1d63d1f2e631e318330d28cfbe15e548e89d429e289/pytest_codspeed-4.5.0-py3-none-any.whl", hash = "sha256:b19bfb734dcbd47b78022285a6eb9f2bf6331ef1bb8c15c2775058945d5f4ce3", size = 214090, upload-time = "2026-04-28T13:12:16.755Z" },
+]
+
[[package]]
name = "pytest-httpbin"
version = "2.0.0"