Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions httpcore/_async/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream:
try:
if self._uds is None:
kwargs = {
"host": self._origin.host.decode("ascii"),
"host": self._origin.host.decode("ascii").rstrip("."),
"port": self._origin.port,
"local_address": self._local_address,
"timeout": timeout,
Expand Down Expand Up @@ -149,7 +149,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream:
kwargs = {
"ssl_context": ssl_context,
"server_hostname": sni_hostname
or self._origin.host.decode("ascii"),
or self._origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
async with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
2 changes: 1 addition & 1 deletion httpcore/_async/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ async def handle_async_request(self, request: Request) -> Response:

kwargs = {
"ssl_context": ssl_context,
"server_hostname": self._remote_origin.host.decode("ascii"),
"server_hostname": self._remote_origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
async with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
6 changes: 3 additions & 3 deletions httpcore/_async/socks_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async def handle_async_request(self, request: Request) -> Response:
try:
# Connect to the proxy
kwargs = {
"host": self._proxy_origin.host.decode("ascii"),
"host": self._proxy_origin.host.decode("ascii").rstrip("."),
"port": self._proxy_origin.port,
"timeout": timeout,
}
Expand All @@ -234,7 +234,7 @@ async def handle_async_request(self, request: Request) -> Response:
# Connect to the remote host using socks5
kwargs = {
"stream": stream,
"host": self._remote_origin.host.decode("ascii"),
"host": self._remote_origin.host.decode("ascii").rstrip("."),
"port": self._remote_origin.port,
"auth": self._proxy_auth,
}
Expand All @@ -259,7 +259,7 @@ async def handle_async_request(self, request: Request) -> Response:
kwargs = {
"ssl_context": ssl_context,
"server_hostname": sni_hostname
or self._remote_origin.host.decode("ascii"),
or self._remote_origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
async with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
20 changes: 14 additions & 6 deletions httpcore/_backends/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ def write(self, buffer: bytes, timeout: float | None = None) -> None:
exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError}
with map_exceptions(exc_map):
self._sock.settimeout(timeout)
while buffer:
nsent = self._perform_io(functools.partial(self.ssl_obj.write, buffer))
buffer = buffer[nsent:]
# Use a memoryview to avoid O(n²) copying when buffer is sliced on each
# iteration. Plain bytes slicing (buffer = buffer[n:]) creates a full copy;
# memoryview slicing is zero-copy, critical for large upload payloads.
view = memoryview(buffer)
while view:
nsent = self._perform_io(functools.partial(self.ssl_obj.write, view))
view = view[nsent:]

def close(self) -> None:
self._sock.close()
Expand Down Expand Up @@ -133,10 +137,14 @@ def write(self, buffer: bytes, timeout: float | None = None) -> None:

exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError}
with map_exceptions(exc_map):
while buffer:
# Use a memoryview to avoid O(n²) copying when buffer is sliced on each
# iteration. Plain bytes slicing (buffer = buffer[n:]) creates a full copy;
# memoryview slicing is zero-copy, critical for large upload payloads.
view = memoryview(buffer)
while view:
self._sock.settimeout(timeout)
n = self._sock.send(buffer)
buffer = buffer[n:]
n = self._sock.send(view)
view = view[n:]

def close(self) -> None:
self._sock.close()
Expand Down
2 changes: 1 addition & 1 deletion httpcore/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def __eq__(self, other: typing.Any) -> bool:

def __str__(self) -> str:
scheme = self.scheme.decode("ascii")
host = self.host.decode("ascii")
host = self.host.decode("ascii").rstrip(".")
port = str(self.port)
return f"{scheme}://{host}:{port}"

Expand Down
4 changes: 2 additions & 2 deletions httpcore/_sync/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def _connect(self, request: Request) -> NetworkStream:
try:
if self._uds is None:
kwargs = {
"host": self._origin.host.decode("ascii"),
"host": self._origin.host.decode("ascii").rstrip("."),
"port": self._origin.port,
"local_address": self._local_address,
"timeout": timeout,
Expand Down Expand Up @@ -149,7 +149,7 @@ def _connect(self, request: Request) -> NetworkStream:
kwargs = {
"ssl_context": ssl_context,
"server_hostname": sni_hostname
or self._origin.host.decode("ascii"),
or self._origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
2 changes: 1 addition & 1 deletion httpcore/_sync/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ def handle_request(self, request: Request) -> Response:

kwargs = {
"ssl_context": ssl_context,
"server_hostname": self._remote_origin.host.decode("ascii"),
"server_hostname": self._remote_origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
6 changes: 3 additions & 3 deletions httpcore/_sync/socks_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def handle_request(self, request: Request) -> Response:
try:
# Connect to the proxy
kwargs = {
"host": self._proxy_origin.host.decode("ascii"),
"host": self._proxy_origin.host.decode("ascii").rstrip("."),
"port": self._proxy_origin.port,
"timeout": timeout,
}
Expand All @@ -234,7 +234,7 @@ def handle_request(self, request: Request) -> Response:
# Connect to the remote host using socks5
kwargs = {
"stream": stream,
"host": self._remote_origin.host.decode("ascii"),
"host": self._remote_origin.host.decode("ascii").rstrip("."),
"port": self._remote_origin.port,
"auth": self._proxy_auth,
}
Expand All @@ -259,7 +259,7 @@ def handle_request(self, request: Request) -> Response:
kwargs = {
"ssl_context": ssl_context,
"server_hostname": sni_hostname
or self._remote_origin.host.decode("ascii"),
or self._remote_origin.host.decode("ascii").rstrip("."),
"timeout": timeout,
}
with Trace("start_tls", logger, request, kwargs) as trace:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_trailing_dot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Tests for trailing-dot FQDN hostname normalisation (issue #1063)."""

import pytest
import httpcore


def test_origin_str_strips_trailing_dot():
"""Origin.__str__ must strip the trailing dot from FQDNs.

'myhost.internal.' is a valid FQDN but TLS certificates use
'myhost.internal' (without the dot). Passing the raw hostname
to ssl_wrap_socket would cause CERTIFICATE_VERIFY_FAILED.
"""
origin = httpcore.Origin(b"https", b"myhost.internal.", 443)
assert str(origin) == "https://myhost.internal:443"


def test_origin_str_no_trailing_dot_unchanged():
"""Normal hostnames (no trailing dot) must not be modified."""
origin = httpcore.Origin(b"https", b"example.com", 443)
assert str(origin) == "https://example.com:443"


def test_url_host_strips_trailing_dot():
"""URL.host used for SNI should not carry the trailing dot."""
url = httpcore.URL("https://myhost.internal.:8443/")
assert url.host == b"myhost.internal." # raw host preserved
# but str(origin) strips it for TLS
origin = httpcore.Origin(b"https", url.host, url.port)
assert str(origin) == "https://myhost.internal:8443"
78 changes: 78 additions & 0 deletions tests/test_write_performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Tests for zero-copy write optimisation using memoryview (issue #1029)."""

import pytest
import tracemalloc


def _simulate_write_bytes(data: bytes, chunk_size: int = 65536) -> int:
"""Simulate the OLD write loop (bytes slicing — copies on each iteration)."""
copies = 0
buf = data
while buf:
_chunk = buf[:chunk_size] # consumed by socket.send() in production
buf = buf[chunk_size:]
copies += 1
return copies


def _simulate_write_memoryview(data: bytes, chunk_size: int = 65536) -> int:
"""Simulate the NEW write loop (memoryview — zero-copy slicing)."""
copies = 0
view = memoryview(data)
while view:
_chunk = view[:chunk_size] # consumed by socket.send() in production
view = view[chunk_size:]
copies += 1
return copies


def test_memoryview_slicing_zero_copy():
"""memoryview slicing must not allocate new bytes objects.

bytes slicing (buffer = buffer[n:]) copies the remaining bytes on every
iteration — O(n²) total allocation for a large payload.
memoryview slicing is zero-copy and runs in O(n) memory.
"""
data = b"x" * (4 * 1024 * 1024) # 4 MB

tracemalloc.start()
snap_before = tracemalloc.take_snapshot()
_ = _simulate_write_memoryview(data)
snap_after = tracemalloc.take_snapshot()
tracemalloc.stop()

# Measure net allocation delta
stats = snap_after.compare_to(snap_before, "lineno")
allocated = sum(s.size_diff for s in stats if s.size_diff > 0)

# memoryview approach should allocate essentially nothing (< 1 MB overhead)
assert allocated < 1024 * 1024, (
f"memoryview write allocated {allocated / 1024:.0f} KB — "
"expected near-zero (zero-copy), got unexpected allocation"
)


def test_write_loop_correct_iteration_count():
"""Both loops must iterate the same number of times for the same payload."""
chunk = 65536
for size in [1024, chunk, chunk * 5, chunk * 100]:
data = b"0" * size
n_bytes = _simulate_write_bytes(data, chunk)
n_mv = _simulate_write_memoryview(data, chunk)
assert n_bytes == n_mv, (
f"size={size}: bytes loop={n_bytes} iters, "
f"memoryview loop={n_mv} iters — must be equal"
)


def test_write_loop_handles_empty_buffer():
"""Empty payload should result in zero iterations (no write attempted)."""
assert _simulate_write_bytes(b"", 65536) == 0
assert _simulate_write_memoryview(b"", 65536) == 0


def test_write_loop_handles_sub_chunk_payload():
"""Payload smaller than one chunk should result in exactly one iteration."""
data = b"x" * 1000
assert _simulate_write_bytes(data, 65536) == 1
assert _simulate_write_memoryview(data, 65536) == 1
Loading