Repository: encode/httpcore

Issue:
Allow socket options to be configured.
We should add a `socket_options` configuration to the `ConnectionPool` and `HTTPProxy` classes.

This would need to be passed through to the network backends `connect_tcp(...)` and `connect_unix_socket()` methods.

For example the sync case, here...

https://github.com/encode/httpcore/blob/21450a75323c715555bf147a941968fa6d662ef5/httpcore/backends/sync.py#L80-L97

...which we would adapt to something like this...

```python
def connect_tcp( 
    self, 
    host: str, 
    port: int, 
    timeout: typing.Optional[float] = None, 
    local_address: typing.Optional[str] = None,
    socket_options: typing.Sequence[typing.Tuple[int, int, typing.Union[int, bytes]]] = ()
 ) -> NetworkStream: 
    address = (host, port) 
    source_address = None if local_address is None else (local_address, 0) 
    exc_map: ExceptionMapping = { 
        socket.timeout: ConnectTimeout, 
        OSError: ConnectError, 
    }
    with map_exceptions(exc_map): 
        sock = socket.create_connection( 
            address, timeout, source_address=source_address 
        )

    # See https://docs.python.org/3/library/socket.html#socket.socket.setsockopt
    for option in socket_options:
        sock.setsockopt(*option)

    return SyncStream(sock) 
```

This would resolve https://github.com/encode/httpcore/pull/651 and https://github.com/encode/httpx/discussions/2635

We *could* also consider changing our defaults for `TCP_NODELAY`, but we should consider that option as a follow-up.

Discussion:
karosis88: If we want to support socket options for async interfaces, we need to change a lot of things. Currently, the 'get asynclib' function is used to get a specific backend interface provided by anyio, then the "connect tcp" and "connect unix" asynchronous functions of that module are used to create a connection; how can we override these methods while keeping it as simple as possible?
karosis88: As a simple solution, we can associate each library with its function and then call that function.

Old
``` python
async def connect_unix(path: Union[str, "PathLike[str]"]) -> UNIXSocketStream:
    path = str(Path(path))
    return await get_asynclib().connect_unix(path)
```
New
``` python
async def connect_unix(path: Union[str, "PathLike[str]"]) -> UNIXSocketStream:
    path = str(Path(path))
    library = sniffio.current_async_library()
    map = {
        "trio": trio_connect_tcp,
        "asyncio": asyncio_connect_tcp,
    }
    return map[library](path)
```
tomchristie: > If we want to support socket options for async interfaces, we need to change a lot of things. Currently, the 'get_asynclib' function is used to get a specific backend interface provided by anyio, then the "connect_tcp" and "connect_unix" asynchronous functions of that module are used to create a connection

It took me a little while to track down what you're referring to here.

> how can we override these methods while keeping it as simple as possible?

We'd need to work this through on a case-by-case basis.

The three cases to cover are `sync`, `trio`, and `asyncio` via `anyio`.

We'd want the PR to start by adapting each of...

* `httpcore/backends/sync.py`
* `httpcore/backends/trio.py`
* `httpcore/backends/asyncio.py`

...and then work through from there.

Let's link to each of these here so it's easier to review how we'd handle this...

https://github.com/encode/httpcore/blob/82466e18bdc4eb7f442cbc52d51deb55b2063273/httpcore/backends/sync.py#L94-L96

https://github.com/encode/httpcore/blob/82466e18bdc4eb7f442cbc52d51deb55b2063273/httpcore/backends/trio.py#L127-L129

https://github.com/encode/httpcore/blob/82466e18bdc4eb7f442cbc52d51deb55b2063273/httpcore/backends/asyncio.py#L111-L115

You've already demonstrated how we'd adapt the sync case.

The `trio` case would require less duplication to resolve if https://github.com/python-trio/trio/issues/281 was addressed, but otherwise [needs their `connect_tcp()` function to be duplicated](https://github.com/python-trio/trio/blob/4f17d2b2693d961f2c12df76a5ce4f80ec3f3178/trio/_highlevel_open_tcp_stream.py#L175-L178).

I think you're pointing out that the asyncio-via-anyio case is actually really awkward to resolve. And yes, that looks correct to me. It does look like duplicating [the anyio `connect_tcp` implementation](https://github.com/agronholm/anyio/blob/master/src/anyio/_core/_sockets.py#L122-L250) would be a bit awkward.

🤔 Parking this for a bit.

tomchristie: Related, and possibly helpful here - it might be worthwhile taking a look at what `aiohttp` use for their `connect_tcp` implementation.
karosis88: There is an elegant solution, which is to ignore the socket option, which affects the connect method itself.

Our issue is that the socket bind and connect methods behave differently depending on socket options, such as `socket.SO_REUSEADDR` can help us in connecting to a socket that was previously force closed and is now waiting 1-2 minutes for the socket to be released.

That is very fun, but trio does not support local address binding...

There is also REUSEPORT, which allows you to listen to an IP port pair with multiple sockets, but it is far from httpcore because httpcore is a client library and does not support REUSEPORT at all.

I looked for options that affect bind and connect methods and that httpcore or httpx users can use in their applications, but I couldn't find anything.

I believe it is better to ignore such options and set options after the backend library has been created a connection.
This solution avoids code duplication while keeping code as simple as possible.

This is how the updated function could look.
``` python
class Backend(AsyncNetworkBackend):
    async def connect_tcp(
         ...,
         socket_options
    ):
        ...
        stream = await backend.connect_tcp(
                    remote_host=host,
                    remote_port=port,
                    local_host=local_address,
                )
        for option in socket_options:
             stream._socket.setsockopt(*option)
        return AsyncIOStream(stream)
```

karosis88: Also, because we don't need options affecting bind or connect, we can use socket.create_connection instead of our newly created one to avoid code duplication
tomchristie: Okay, setting socket options after the connect is *probably(?)* okay for our use-case.

For the async case I'd suggest we push the *implementation* into `httpcore/backends/trio.py` and 
`httpcore/backends/asyncio.py`, with `httpcore/backends/auto.py` calling through to them.
karosis88: > We should also take a call on _if we really do want this_, or if what we actually need is just a better default for the sync backed so that it includes `TCP_NODELAY`.
> 
> See [#651 (comment)](https://github.com/encode/httpcore/pull/651#issuecomment-1554579913)

I think we should make TCP_NODELAY the default, but also make socket options configurable; there are many options in unix sockets that developers can use, and this can be a powerful feature for developers **who know** what they're doing.

Additionally, fixing https://github.com/encode/httpx/discussions/2635 requires these configurable sockets. which initially inspired me to open this PR  :D

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c81df5..30f8d17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 - Improve logging with per-module logger names. (#690)
 - Resolve race condition during import of `anyio` package. (#692)
+- Add `socket_options` argument to `ConnectionPool` and `HTTProxy` classes classes. (#668)
 - Enable TCP_NODELAY for all synchronous sockets. (#651)
 - Add `sni_hostname` request extension. (#696)
 
diff --git a/docs/connection-pools.md b/docs/connection-pools.md
index 3171e92..cb461a9 100644
--- a/docs/connection-pools.md
+++ b/docs/connection-pools.md
@@ -81,6 +81,7 @@ The connection pool instance is also the main point of configuration. Let's take
                    will connect using an `AF_INET6` address (IPv6).
 * `uds`: Path to a Unix Domain Socket to use instead of TCP sockets.
 * `network_backend`: A backend instance to use for handling network I/O.
+* `socket_options`: Socket options that have to be included in the TCP socket when the connection was established.
 
 ## Pool lifespans
 
diff --git a/httpcore/_async/connection.py b/httpcore/_async/connection.py
index 0c8127a..ecf2546 100644
--- a/httpcore/_async/connection.py
+++ b/httpcore/_async/connection.py
@@ -2,7 +2,7 @@ import itertools
 import logging
 import ssl
 from types import TracebackType
-from typing import Iterator, Optional, Type
+from typing import Iterable, Iterator, Optional, Type
 
 from .._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout
 from .._models import Origin, Request, Response
@@ -10,7 +10,7 @@ from .._ssl import default_ssl_context
 from .._synchronization import AsyncLock
 from .._trace import Trace
 from ..backends.auto import AutoBackend
-from ..backends.base import AsyncNetworkBackend, AsyncNetworkStream
+from ..backends.base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
 from .http11 import AsyncHTTP11Connection
 from .interfaces import AsyncConnectionInterface
 
@@ -38,6 +38,7 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[AsyncNetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._origin = origin
         self._ssl_context = ssl_context
@@ -54,6 +55,7 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
         self._connection: Optional[AsyncConnectionInterface] = None
         self._connect_failed: bool = False
         self._request_lock = AsyncLock()
+        self._socket_options = socket_options
 
     async def handle_async_request(self, request: Request) -> Response:
         if not self.can_handle_request(request.url.origin):
@@ -109,6 +111,7 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
                         "port": self._origin.port,
                         "local_address": self._local_address,
                         "timeout": timeout,
+                        "socket_options": self._socket_options,
                     }
                     async with Trace("connect_tcp", logger, request, kwargs) as trace:
                         stream = await self._network_backend.connect_tcp(**kwargs)
@@ -117,6 +120,7 @@ class AsyncHTTPConnection(AsyncConnectionInterface):
                     kwargs = {
                         "path": self._uds,
                         "timeout": timeout,
+                        "socket_options": self._socket_options,
                     }
                     async with Trace(
                         "connect_unix_socket", logger, request, kwargs
diff --git a/httpcore/_async/connection_pool.py b/httpcore/_async/connection_pool.py
index a667cbd..37be0c2 100644
--- a/httpcore/_async/connection_pool.py
+++ b/httpcore/_async/connection_pool.py
@@ -1,13 +1,13 @@
 import ssl
 import sys
 from types import TracebackType
-from typing import AsyncIterable, AsyncIterator, List, Optional, Type
+from typing import AsyncIterable, AsyncIterator, Iterable, List, Optional, Type
 
 from .._exceptions import ConnectionNotAvailable, UnsupportedProtocol
 from .._models import Origin, Request, Response
 from .._synchronization import AsyncEvent, AsyncLock
 from ..backends.auto import AutoBackend
-from ..backends.base import AsyncNetworkBackend
+from ..backends.base import SOCKET_OPTION, AsyncNetworkBackend
 from .connection import AsyncHTTPConnection
 from .interfaces import AsyncConnectionInterface, AsyncRequestInterface
 
@@ -54,6 +54,7 @@ class AsyncConnectionPool(AsyncRequestInterface):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[AsyncNetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         """
         A connection pool for making HTTP requests.
@@ -81,6 +82,8 @@ class AsyncConnectionPool(AsyncRequestInterface):
                 `local_address="::"` will connect using an `AF_INET6` address (IPv6).
             uds: Path to a Unix Domain Socket to use instead of TCP sockets.
             network_backend: A backend instance to use for handling network I/O.
+            socket_options: Socket options that have to be included
+             in the TCP socket when the connection was established.
         """
         self._ssl_context = ssl_context
 
@@ -109,6 +112,7 @@ class AsyncConnectionPool(AsyncRequestInterface):
         self._network_backend = (
             AutoBackend() if network_backend is None else network_backend
         )
+        self._socket_options = socket_options
 
     def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
         return AsyncHTTPConnection(
@@ -121,6 +125,7 @@ class AsyncConnectionPool(AsyncRequestInterface):
             local_address=self._local_address,
             uds=self._uds,
             network_backend=self._network_backend,
+            socket_options=self._socket_options,
         )
 
     @property
diff --git a/httpcore/_async/http_proxy.py b/httpcore/_async/http_proxy.py
index 42d0020..3dd1cb4 100644
--- a/httpcore/_async/http_proxy.py
+++ b/httpcore/_async/http_proxy.py
@@ -1,7 +1,7 @@
 import logging
 import ssl
 from base64 import b64encode
-from typing import List, Mapping, Optional, Sequence, Tuple, Union
+from typing import Iterable, List, Mapping, Optional, Sequence, Tuple, Union
 
 from .._exceptions import ProxyError
 from .._models import (
@@ -16,7 +16,7 @@ from .._models import (
 from .._ssl import default_ssl_context
 from .._synchronization import AsyncLock
 from .._trace import Trace
-from ..backends.base import AsyncNetworkBackend
+from ..backends.base import SOCKET_OPTION, AsyncNetworkBackend
 from .connection import AsyncHTTPConnection
 from .connection_pool import AsyncConnectionPool
 from .http11 import AsyncHTTP11Connection
@@ -73,6 +73,7 @@ class AsyncHTTPProxy(AsyncConnectionPool):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[AsyncNetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         """
         A connection pool for making HTTP requests.
@@ -119,6 +120,7 @@ class AsyncHTTPProxy(AsyncConnectionPool):
             retries=retries,
             local_address=local_address,
             uds=uds,
+            socket_options=socket_options,
         )
         self._ssl_context = ssl_context
         self._proxy_url = enforce_url(proxy_url, name="proxy_url")
@@ -160,11 +162,13 @@ class AsyncForwardHTTPConnection(AsyncConnectionInterface):
         proxy_headers: Union[HeadersAsMapping, HeadersAsSequence, None] = None,
         keepalive_expiry: Optional[float] = None,
         network_backend: Optional[AsyncNetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._connection = AsyncHTTPConnection(
             origin=proxy_origin,
             keepalive_expiry=keepalive_expiry,
             network_backend=network_backend,
+            socket_options=socket_options,
         )
         self._proxy_origin = proxy_origin
         self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers")
@@ -223,11 +227,13 @@ class AsyncTunnelHTTPConnection(AsyncConnectionInterface):
         http1: bool = True,
         http2: bool = False,
         network_backend: Optional[AsyncNetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._connection: AsyncConnectionInterface = AsyncHTTPConnection(
             origin=proxy_origin,
             keepalive_expiry=keepalive_expiry,
             network_backend=network_backend,
+            socket_options=socket_options,
         )
         self._proxy_origin = proxy_origin
         self._remote_origin = remote_origin
diff --git a/httpcore/_sync/connection.py b/httpcore/_sync/connection.py
index 2068a04..d40b155 100644
--- a/httpcore/_sync/connection.py
+++ b/httpcore/_sync/connection.py
@@ -2,7 +2,7 @@ import itertools
 import logging
 import ssl
 from types import TracebackType
-from typing import Iterator, Optional, Type
+from typing import Iterable, Iterator, Optional, Type
 
 from .._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout
 from .._models import Origin, Request, Response
@@ -10,7 +10,7 @@ from .._ssl import default_ssl_context
 from .._synchronization import Lock
 from .._trace import Trace
 from ..backends.sync import SyncBackend
-from ..backends.base import NetworkBackend, NetworkStream
+from ..backends.base import SOCKET_OPTION, NetworkBackend, NetworkStream
 from .http11 import HTTP11Connection
 from .interfaces import ConnectionInterface
 
@@ -38,6 +38,7 @@ class HTTPConnection(ConnectionInterface):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[NetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._origin = origin
         self._ssl_context = ssl_context
@@ -54,6 +55,7 @@ class HTTPConnection(ConnectionInterface):
         self._connection: Optional[ConnectionInterface] = None
         self._connect_failed: bool = False
         self._request_lock = Lock()
+        self._socket_options = socket_options
 
     def handle_request(self, request: Request) -> Response:
         if not self.can_handle_request(request.url.origin):
@@ -109,6 +111,7 @@ class HTTPConnection(ConnectionInterface):
                         "port": self._origin.port,
                         "local_address": self._local_address,
                         "timeout": timeout,
+                        "socket_options": self._socket_options,
                     }
                     with Trace("connect_tcp", logger, request, kwargs) as trace:
                         stream = self._network_backend.connect_tcp(**kwargs)
@@ -117,6 +120,7 @@ class HTTPConnection(ConnectionInterface):
                     kwargs = {
                         "path": self._uds,
                         "timeout": timeout,
+                        "socket_options": self._socket_options,
                     }
                     with Trace(
                         "connect_unix_socket", logger, request, kwargs
diff --git a/httpcore/_sync/connection_pool.py b/httpcore/_sync/connection_pool.py
index 379a5fc..d30a1c5 100644
--- a/httpcore/_sync/connection_pool.py
+++ b/httpcore/_sync/connection_pool.py
@@ -1,13 +1,13 @@
 import ssl
 import sys
 from types import TracebackType
-from typing import Iterable, Iterator, List, Optional, Type
+from typing import Iterable, Iterator, Iterable, List, Optional, Type
 
 from .._exceptions import ConnectionNotAvailable, UnsupportedProtocol
 from .._models import Origin, Request, Response
 from .._synchronization import Event, Lock
 from ..backends.sync import SyncBackend
-from ..backends.base import NetworkBackend
+from ..backends.base import SOCKET_OPTION, NetworkBackend
 from .connection import HTTPConnection
 from .interfaces import ConnectionInterface, RequestInterface
 
@@ -54,6 +54,7 @@ class ConnectionPool(RequestInterface):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[NetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         """
         A connection pool for making HTTP requests.
@@ -81,6 +82,8 @@ class ConnectionPool(RequestInterface):
                 `local_address="::"` will connect using an `AF_INET6` address (IPv6).
             uds: Path to a Unix Domain Socket to use instead of TCP sockets.
             network_backend: A backend instance to use for handling network I/O.
+            socket_options: Socket options that have to be included
+             in the TCP socket when the connection was established.
         """
         self._ssl_context = ssl_context
 
@@ -109,6 +112,7 @@ class ConnectionPool(RequestInterface):
         self._network_backend = (
             SyncBackend() if network_backend is None else network_backend
         )
+        self._socket_options = socket_options
 
     def create_connection(self, origin: Origin) -> ConnectionInterface:
         return HTTPConnection(
@@ -121,6 +125,7 @@ class ConnectionPool(RequestInterface):
             local_address=self._local_address,
             uds=self._uds,
             network_backend=self._network_backend,
+            socket_options=self._socket_options,
         )
 
     @property
diff --git a/httpcore/_sync/http_proxy.py b/httpcore/_sync/http_proxy.py
index 229993a..f282f66 100644
--- a/httpcore/_sync/http_proxy.py
+++ b/httpcore/_sync/http_proxy.py
@@ -1,7 +1,7 @@
 import logging
 import ssl
 from base64 import b64encode
-from typing import List, Mapping, Optional, Sequence, Tuple, Union
+from typing import Iterable, List, Mapping, Optional, Sequence, Tuple, Union
 
 from .._exceptions import ProxyError
 from .._models import (
@@ -16,7 +16,7 @@ from .._models import (
 from .._ssl import default_ssl_context
 from .._synchronization import Lock
 from .._trace import Trace
-from ..backends.base import NetworkBackend
+from ..backends.base import SOCKET_OPTION, NetworkBackend
 from .connection import HTTPConnection
 from .connection_pool import ConnectionPool
 from .http11 import HTTP11Connection
@@ -73,6 +73,7 @@ class HTTPProxy(ConnectionPool):
         local_address: Optional[str] = None,
         uds: Optional[str] = None,
         network_backend: Optional[NetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         """
         A connection pool for making HTTP requests.
@@ -119,6 +120,7 @@ class HTTPProxy(ConnectionPool):
             retries=retries,
             local_address=local_address,
             uds=uds,
+            socket_options=socket_options,
         )
         self._ssl_context = ssl_context
         self._proxy_url = enforce_url(proxy_url, name="proxy_url")
@@ -160,11 +162,13 @@ class ForwardHTTPConnection(ConnectionInterface):
         proxy_headers: Union[HeadersAsMapping, HeadersAsSequence, None] = None,
         keepalive_expiry: Optional[float] = None,
         network_backend: Optional[NetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._connection = HTTPConnection(
             origin=proxy_origin,
             keepalive_expiry=keepalive_expiry,
             network_backend=network_backend,
+            socket_options=socket_options,
         )
         self._proxy_origin = proxy_origin
         self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers")
@@ -223,11 +227,13 @@ class TunnelHTTPConnection(ConnectionInterface):
         http1: bool = True,
         http2: bool = False,
         network_backend: Optional[NetworkBackend] = None,
+        socket_options: Optional[Iterable[SOCKET_OPTION]] = None,
     ) -> None:
         self._connection: ConnectionInterface = HTTPConnection(
             origin=proxy_origin,
             keepalive_expiry=keepalive_expiry,
             network_backend=network_backend,
+            socket_options=socket_options,
         )
         self._proxy_origin = proxy_origin
         self._remote_origin = remote_origin
diff --git a/httpcore/backends/asyncio.py b/httpcore/backends/asyncio.py
index 23f7dce..70a2207 100644
--- a/httpcore/backends/asyncio.py
+++ b/httpcore/backends/asyncio.py
@@ -13,7 +13,7 @@ from .._exceptions import (
     map_exceptions,
 )
 from .._utils import is_socket_readable
-from .base import AsyncNetworkBackend, AsyncNetworkStream
+from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
 
 
 class AsyncIOStream(AsyncNetworkStream):
@@ -100,7 +100,10 @@ class AsyncIOBackend(AsyncNetworkBackend):
         port: int,
         timeout: typing.Optional[float] = None,
         local_address: typing.Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
+        if socket_options is None:
+            socket_options = []  # pragma: no cover
         exc_map = {
             TimeoutError: ConnectTimeout,
             OSError: ConnectError,
@@ -113,11 +116,19 @@ class AsyncIOBackend(AsyncNetworkBackend):
                     remote_port=port,
                     local_host=local_address,
                 )
+                # By default TCP sockets opened in `asyncio` include TCP_NODELAY.
+                for option in socket_options:
+                    stream._raw_socket.setsockopt(*option)  # type: ignore[attr-defined] # pragma: no cover
         return AsyncIOStream(stream)
 
     async def connect_unix_socket(
-        self, path: str, timeout: typing.Optional[float] = None
+        self,
+        path: str,
+        timeout: typing.Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:  # pragma: nocover
+        if socket_options is None:
+            socket_options = []
         exc_map = {
             TimeoutError: ConnectTimeout,
             OSError: ConnectError,
@@ -126,6 +137,8 @@ class AsyncIOBackend(AsyncNetworkBackend):
         with map_exceptions(exc_map):
             with anyio.fail_after(timeout):
                 stream: anyio.abc.ByteStream = await anyio.connect_unix(path)
+                for option in socket_options:
+                    stream._raw_socket.setsockopt(*option)  # type: ignore[attr-defined] # pragma: no cover
         return AsyncIOStream(stream)
 
     async def sleep(self, seconds: float) -> None:
diff --git a/httpcore/backends/auto.py b/httpcore/backends/auto.py
index 62087d8..f4766ab 100644
--- a/httpcore/backends/auto.py
+++ b/httpcore/backends/auto.py
@@ -1,8 +1,9 @@
+import typing
 from typing import Optional
 
 import sniffio
 
-from .base import AsyncNetworkBackend, AsyncNetworkStream
+from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
 
 
 class AutoBackend(AsyncNetworkBackend):
@@ -24,17 +25,27 @@ class AutoBackend(AsyncNetworkBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         await self._init_backend()
         return await self._backend.connect_tcp(
-            host, port, timeout=timeout, local_address=local_address
+            host,
+            port,
+            timeout=timeout,
+            local_address=local_address,
+            socket_options=socket_options,
         )
 
     async def connect_unix_socket(
-        self, path: str, timeout: Optional[float] = None
+        self,
+        path: str,
+        timeout: Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:  # pragma: nocover
         await self._init_backend()
-        return await self._backend.connect_unix_socket(path, timeout=timeout)
+        return await self._backend.connect_unix_socket(
+            path, timeout=timeout, socket_options=socket_options
+        )
 
     async def sleep(self, seconds: float) -> None:  # pragma: nocover
         await self._init_backend()
diff --git a/httpcore/backends/base.py b/httpcore/backends/base.py
index 6854db6..6cadedb 100644
--- a/httpcore/backends/base.py
+++ b/httpcore/backends/base.py
@@ -2,6 +2,12 @@ import ssl
 import time
 import typing
 
+SOCKET_OPTION = typing.Union[
+    typing.Tuple[int, int, int],
+    typing.Tuple[int, int, typing.Union[bytes, bytearray]],
+    typing.Tuple[int, int, None, int],
+]
+
 
 class NetworkStream:
     def read(self, max_bytes: int, timeout: typing.Optional[float] = None) -> bytes:
@@ -32,11 +38,15 @@ class NetworkBackend:
         port: int,
         timeout: typing.Optional[float] = None,
         local_address: typing.Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         raise NotImplementedError()  # pragma: nocover
 
     def connect_unix_socket(
-        self, path: str, timeout: typing.Optional[float] = None
+        self,
+        path: str,
+        timeout: typing.Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         raise NotImplementedError()  # pragma: nocover
 
@@ -77,11 +87,15 @@ class AsyncNetworkBackend:
         port: int,
         timeout: typing.Optional[float] = None,
         local_address: typing.Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         raise NotImplementedError()  # pragma: nocover
 
     async def connect_unix_socket(
-        self, path: str, timeout: typing.Optional[float] = None
+        self,
+        path: str,
+        timeout: typing.Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         raise NotImplementedError()  # pragma: nocover
 
diff --git a/httpcore/backends/mock.py b/httpcore/backends/mock.py
index 0dab4ee..f7aefeb 100644
--- a/httpcore/backends/mock.py
+++ b/httpcore/backends/mock.py
@@ -3,7 +3,13 @@ import typing
 from typing import Optional
 
 from .._exceptions import ReadError
-from .base import AsyncNetworkBackend, AsyncNetworkStream, NetworkBackend, NetworkStream
+from .base import (
+    SOCKET_OPTION,
+    AsyncNetworkBackend,
+    AsyncNetworkStream,
+    NetworkBackend,
+    NetworkStream,
+)
 
 
 class MockSSLObject:
@@ -59,11 +65,15 @@ class MockBackend(NetworkBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         return MockStream(list(self._buffer), http2=self._http2)
 
     def connect_unix_socket(
-        self, path: str, timeout: Optional[float] = None
+        self,
+        path: str,
+        timeout: Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         return MockStream(list(self._buffer), http2=self._http2)
 
@@ -116,11 +126,15 @@ class AsyncMockBackend(AsyncNetworkBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         return AsyncMockStream(list(self._buffer), http2=self._http2)
 
     async def connect_unix_socket(
-        self, path: str, timeout: Optional[float] = None
+        self,
+        path: str,
+        timeout: Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         return AsyncMockStream(list(self._buffer), http2=self._http2)
 
diff --git a/httpcore/backends/sync.py b/httpcore/backends/sync.py
index f5de5f0..a4c85f0 100644
--- a/httpcore/backends/sync.py
+++ b/httpcore/backends/sync.py
@@ -14,7 +14,7 @@ from .._exceptions import (
     map_exceptions,
 )
 from .._utils import is_socket_readable
-from .base import NetworkBackend, NetworkStream
+from .base import SOCKET_OPTION, NetworkBackend, NetworkStream
 
 
 class SyncStream(NetworkStream):
@@ -83,27 +83,42 @@ class SyncBackend(NetworkBackend):
         port: int,
         timeout: typing.Optional[float] = None,
         local_address: typing.Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
+        # Note that we automatically include `TCP_NODELAY`
+        # in addition to any other custom socket options.
+        if socket_options is None:
+            socket_options = []  # pragma: no cover
         address = (host, port)
         source_address = None if local_address is None else (local_address, 0)
         exc_map: ExceptionMapping = {
             socket.timeout: ConnectTimeout,
             OSError: ConnectError,
         }
+
         with map_exceptions(exc_map):
             sock = socket.create_connection(
-                address, timeout, source_address=source_address
+                address,
+                timeout,
+                source_address=source_address,
             )
+            for option in socket_options:
+                sock.setsockopt(*option)  # pragma: no cover
             sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
         return SyncStream(sock)
 
     def connect_unix_socket(
-        self, path: str, timeout: typing.Optional[float] = None
+        self,
+        path: str,
+        timeout: typing.Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:  # pragma: nocover
         if sys.platform == "win32":
             raise RuntimeError(
                 "Attempted to connect to a UNIX socket on a Windows system."
             )
+        if socket_options is None:
+            socket_options = []
 
         exc_map: ExceptionMapping = {
             socket.timeout: ConnectTimeout,
@@ -111,6 +126,8 @@ class SyncBackend(NetworkBackend):
         }
         with map_exceptions(exc_map):
             sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+            for option in socket_options:
+                sock.setsockopt(*option)
             sock.settimeout(timeout)
             sock.connect(path)
         return SyncStream(sock)
diff --git a/httpcore/backends/trio.py b/httpcore/backends/trio.py
index 951016c..b1626d2 100644
--- a/httpcore/backends/trio.py
+++ b/httpcore/backends/trio.py
@@ -13,7 +13,7 @@ from .._exceptions import (
     WriteTimeout,
     map_exceptions,
 )
-from .base import AsyncNetworkBackend, AsyncNetworkStream
+from .base import SOCKET_OPTION, AsyncNetworkBackend, AsyncNetworkStream
 
 
 class TrioStream(AsyncNetworkStream):
@@ -115,7 +115,12 @@ class TrioBackend(AsyncNetworkBackend):
         port: int,
         timeout: typing.Optional[float] = None,
         local_address: typing.Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
+        # By default for TCP sockets, trio enables TCP_NODELAY.
+        # https://trio.readthedocs.io/en/stable/reference-io.html#trio.SocketStream
+        if socket_options is None:
+            socket_options = []  # pragma: no cover
         timeout_or_inf = float("inf") if timeout is None else timeout
         exc_map: ExceptionMapping = {
             trio.TooSlowError: ConnectTimeout,
@@ -127,11 +132,18 @@ class TrioBackend(AsyncNetworkBackend):
                 stream: trio.abc.Stream = await trio.open_tcp_stream(
                     host=host, port=port, local_address=local_address
                 )
+                for option in socket_options:
+                    stream.setsockopt(*option)  # type: ignore[attr-defined] # pragma: no cover
         return TrioStream(stream)
 
     async def connect_unix_socket(
-        self, path: str, timeout: typing.Optional[float] = None
+        self,
+        path: str,
+        timeout: typing.Optional[float] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:  # pragma: nocover
+        if socket_options is None:
+            socket_options = []
         timeout_or_inf = float("inf") if timeout is None else timeout
         exc_map: ExceptionMapping = {
             trio.TooSlowError: ConnectTimeout,
@@ -141,6 +153,8 @@ class TrioBackend(AsyncNetworkBackend):
         with map_exceptions(exc_map):
             with trio.fail_after(timeout_or_inf):
                 stream: trio.abc.Stream = await trio.open_unix_socket(path)
+                for option in socket_options:
+                    stream.setsockopt(*option)  # type: ignore[attr-defined] # pragma: no cover
         return TrioStream(stream)
 
     async def sleep(self, seconds: float) -> None:


Test patch:
diff --git a/tests/_async/test_connection.py b/tests/_async/test_connection.py
index 90e69f5..77ab3db 100644
--- a/tests/_async/test_connection.py
+++ b/tests/_async/test_connection.py
@@ -7,7 +7,7 @@ import hyperframe.frame
 import pytest
 
 from httpcore import AsyncHTTPConnection, ConnectError, ConnectionNotAvailable, Origin
-from httpcore.backends.base import AsyncNetworkStream
+from httpcore.backends.base import SOCKET_OPTION, AsyncNetworkStream
 from httpcore.backends.mock import AsyncMockBackend
 
 
@@ -143,6 +143,7 @@ class NeedsRetryBackend(AsyncMockBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         if self._connect_tcp_failures > 0:
             self._connect_tcp_failures -= 1
diff --git a/tests/_async/test_connection_pool.py b/tests/_async/test_connection_pool.py
index e5c76f0..3556283 100644
--- a/tests/_async/test_connection_pool.py
+++ b/tests/_async/test_connection_pool.py
@@ -1,4 +1,5 @@
 import logging
+import typing
 from typing import List, Optional
 
 import pytest
@@ -11,7 +12,7 @@ from httpcore import (
     ReadError,
     UnsupportedProtocol,
 )
-from httpcore.backends.base import AsyncNetworkStream
+from httpcore.backends.base import SOCKET_OPTION, AsyncNetworkStream
 from httpcore.backends.mock import AsyncMockBackend
 
 
@@ -186,7 +187,7 @@ async def test_debug_request(caplog):
         (
             "httpcore.connection",
             logging.DEBUG,
-            "connect_tcp.started host='example.com' port=80 local_address=None timeout=None",
+            "connect_tcp.started host='example.com' port=80 local_address=None timeout=None socket_options=None",
         ),
         (
             "httpcore.connection",
@@ -282,6 +283,7 @@ async def test_connection_pool_with_connect_exception():
             port: int,
             timeout: Optional[float] = None,
             local_address: Optional[str] = None,
+            socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
         ) -> AsyncNetworkStream:
             raise ConnectError("Could not connect")
 
diff --git a/tests/_async/test_http_proxy.py b/tests/_async/test_http_proxy.py
index 348043b..55ac88e 100644
--- a/tests/_async/test_http_proxy.py
+++ b/tests/_async/test_http_proxy.py
@@ -1,4 +1,5 @@
 import ssl
+import typing
 from typing import Optional
 
 import hpack
@@ -6,7 +7,7 @@ import hyperframe.frame
 import pytest
 
 from httpcore import AsyncHTTPProxy, Origin, ProxyError
-from httpcore.backends.base import AsyncNetworkStream
+from httpcore.backends.base import SOCKET_OPTION, AsyncNetworkStream
 from httpcore.backends.mock import AsyncMockBackend, AsyncMockStream
 
 
@@ -140,6 +141,7 @@ class HTTP1ThenHTTP2Backend(AsyncMockBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> AsyncNetworkStream:
         return HTTP1ThenHTTP2Stream(list(self._buffer))
 
diff --git a/tests/_sync/test_connection.py b/tests/_sync/test_connection.py
index 9c18cc0..40990c3 100644
--- a/tests/_sync/test_connection.py
+++ b/tests/_sync/test_connection.py
@@ -7,7 +7,7 @@ import hyperframe.frame
 import pytest
 
 from httpcore import HTTPConnection, ConnectError, ConnectionNotAvailable, Origin
-from httpcore.backends.base import NetworkStream
+from httpcore.backends.base import SOCKET_OPTION, NetworkStream
 from httpcore.backends.mock import MockBackend
 
 
@@ -143,6 +143,7 @@ class NeedsRetryBackend(MockBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         if self._connect_tcp_failures > 0:
             self._connect_tcp_failures -= 1
diff --git a/tests/_sync/test_connection_pool.py b/tests/_sync/test_connection_pool.py
index f05ac9f..c804a48 100644
--- a/tests/_sync/test_connection_pool.py
+++ b/tests/_sync/test_connection_pool.py
@@ -1,4 +1,5 @@
 import logging
+import typing
 from typing import List, Optional
 
 import pytest
@@ -11,7 +12,7 @@ from httpcore import (
     ReadError,
     UnsupportedProtocol,
 )
-from httpcore.backends.base import NetworkStream
+from httpcore.backends.base import SOCKET_OPTION, NetworkStream
 from httpcore.backends.mock import MockBackend
 
 
@@ -186,7 +187,7 @@ def test_debug_request(caplog):
         (
             "httpcore.connection",
             logging.DEBUG,
-            "connect_tcp.started host='example.com' port=80 local_address=None timeout=None",
+            "connect_tcp.started host='example.com' port=80 local_address=None timeout=None socket_options=None",
         ),
         (
             "httpcore.connection",
@@ -282,6 +283,7 @@ def test_connection_pool_with_connect_exception():
             port: int,
             timeout: Optional[float] = None,
             local_address: Optional[str] = None,
+            socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
         ) -> NetworkStream:
             raise ConnectError("Could not connect")
 
diff --git a/tests/_sync/test_http_proxy.py b/tests/_sync/test_http_proxy.py
index 575382e..f00995e 100644
--- a/tests/_sync/test_http_proxy.py
+++ b/tests/_sync/test_http_proxy.py
@@ -1,4 +1,5 @@
 import ssl
+import typing
 from typing import Optional
 
 import hpack
@@ -6,7 +7,7 @@ import hyperframe.frame
 import pytest
 
 from httpcore import HTTPProxy, Origin, ProxyError
-from httpcore.backends.base import NetworkStream
+from httpcore.backends.base import SOCKET_OPTION, NetworkStream
 from httpcore.backends.mock import MockBackend, MockStream
 
 
@@ -140,6 +141,7 @@ class HTTP1ThenHTTP2Backend(MockBackend):
         port: int,
         timeout: Optional[float] = None,
         local_address: Optional[str] = None,
+        socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None,
     ) -> NetworkStream:
         return HTTP1ThenHTTP2Stream(list(self._buffer))