tomchristie: Nice one!

I think we should probably look at using `anyio`'s TaskGroups for the asyncio implementation... https://anyio.readthedocs.io/en/latest/tasks.html - that ensures we've got a *proper* nursery like primitive there.

(I essentially want to start *only* using TaskGroups for any `asyncio` branching, since *its the right approach*™️

We can also be stronger on our wording wrt. `MultiError`, it's not that "we don't support it yet" it's that we *specifically only care about raising one exception type*. It's enough for the API to expose that eg. "A read timeout occured" or "A write timeout occured". There's no need for us to support exposing the case that "A read timeout and a write timeout occured". *either one of those* is sufficient for the operation as a whole to be cancelled, and it's actually okay in this circumstance for us to just return either indeterminatly.
florimondmanca: Are we okay adding a dependency on anyio for this? We don’t *need* it — correctly handling errors in the case of 2 coroutines is easy enough, and the current implementation yields the expected behavior.
tomchristie: Yeah, I'm actually pretty okay with it...

I'm strongly enough of the camp that `asyncio` circa Python 3.8 does *not* provide suitable primitives for async task branching, and that a reasonable policy at this point in time is to *never* use the low level APIs there.

There's been talk of introducing a `TaskSupervisor` class into Python, which might also have a backport-installable package, in which case we could switch to that. Until that point in time the only sensible nursery-like-primitive for asyncio is the `anyio` implementation.

If we stick with that then we *know* we're properly adhering to the structued concurrency constraints, even in our asyncio implementation.

Once we've got some specifc async docs back in again I'd also like to help us lead our users down the structured concurrency path, and have us recommend `anyio`'s TaskGroups to end users for task-branching with `asyncio`.
tomchristie: If we think that's being overly zealous, then I *guess we could* stick with a plain asyncio implementation there, but I'd be more comfortable with us leaning on `anyio`'s TaskGroups.
tomchristie: Let's leave it *as an open option*.

This is looking great as it stands so we'll get this in, the later tweak the comment note on `MutliError`, and consider `anyio`. (It could also be that we don't strictly need it ourselves, but could highly recommend TaskGroups to end users)
diff --git a/httpx/__init__.py b/httpx/__init__.py
index 686359a..b6cd6df 100644
--- a/httpx/__init__.py
+++ b/httpx/__init__.py
@@ -3,12 +3,7 @@ from .api import delete, get, head, options, patch, post, put, request, stream
 from .auth import BasicAuth, DigestAuth
 from .client import Client
 from .concurrency.asyncio import AsyncioBackend
-from .concurrency.base import (
-    BaseBackgroundManager,
-    BasePoolSemaphore,
-    BaseSocketStream,
-    ConcurrencyBackend,
-)
+from .concurrency.base import BasePoolSemaphore, BaseSocketStream, ConcurrencyBackend
 from .config import (
     USER_AGENT,
     CertTypes,
@@ -89,7 +84,6 @@ __all__ = [
     "VerifyTypes",
     "HTTPConnection",
     "BasePoolSemaphore",
-    "BaseBackgroundManager",
     "ConnectionPool",
     "HTTPProxy",
     "HTTPProxyMode",
diff --git a/httpx/concurrency/asyncio.py b/httpx/concurrency/asyncio.py
index a759716..eee4ec3 100644
--- a/httpx/concurrency/asyncio.py
+++ b/httpx/concurrency/asyncio.py
@@ -3,12 +3,10 @@ import functools
 import ssl
 import sys
 import typing
-from types import TracebackType
 
 from ..config import PoolLimits, Timeout
 from ..exceptions import ConnectTimeout, PoolTimeout, ReadTimeout, WriteTimeout
 from .base import (
-    BaseBackgroundManager,
     BaseEvent,
     BasePoolSemaphore,
     BaseSocketStream,
@@ -317,34 +315,30 @@ class AsyncioBackend(ConcurrencyBackend):
         finally:
             self._loop = loop
 
+    async def fork(
+        self,
+        coroutine1: typing.Callable,
+        args1: typing.Sequence,
+        coroutine2: typing.Callable,
+        args2: typing.Sequence,
+    ) -> None:
+        task1 = self.loop.create_task(coroutine1(*args1))
+        task2 = self.loop.create_task(coroutine2(*args2))
+
+        try:
+            await asyncio.gather(task1, task2)
+        finally:
+            pending: typing.Set[asyncio.Future[typing.Any]]  # Please mypy.
+            _, pending = await asyncio.wait({task1, task2}, timeout=0)
+            for task in pending:
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
     def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
         return PoolSemaphore(limits)
 
     def create_event(self) -> BaseEvent:
         return typing.cast(BaseEvent, asyncio.Event())
-
-    def background_manager(
-        self, coroutine: typing.Callable, *args: typing.Any
-    ) -> "BackgroundManager":
-        return BackgroundManager(coroutine, args)
-
-
-class BackgroundManager(BaseBackgroundManager):
-    def __init__(self, coroutine: typing.Callable, args: typing.Any) -> None:
-        self.coroutine = coroutine
-        self.args = args
-
-    async def __aenter__(self) -> "BackgroundManager":
-        loop = asyncio.get_event_loop()
-        self.task = loop.create_task(self.coroutine(*self.args))
-        return self
-
-    async def __aexit__(
-        self,
-        exc_type: typing.Type[BaseException] = None,
-        exc_value: BaseException = None,
-        traceback: TracebackType = None,
-    ) -> None:
-        await self.task
-        if exc_type is None:
-            self.task.result()
diff --git a/httpx/concurrency/auto.py b/httpx/concurrency/auto.py
index 3dd31a8..3b57e56 100644
--- a/httpx/concurrency/auto.py
+++ b/httpx/concurrency/auto.py
@@ -5,7 +5,6 @@ import sniffio
 
 from ..config import PoolLimits, Timeout
 from .base import (
-    BaseBackgroundManager,
     BaseEvent,
     BasePoolSemaphore,
     BaseSocketStream,
@@ -52,8 +51,3 @@ class AutoBackend(ConcurrencyBackend):
 
     def create_event(self) -> BaseEvent:
         return self.backend.create_event()
-
-    def background_manager(
-        self, coroutine: typing.Callable, *args: typing.Any
-    ) -> BaseBackgroundManager:
-        return self.backend.background_manager(coroutine, *args)
diff --git a/httpx/concurrency/base.py b/httpx/concurrency/base.py
index f32501a..ff5f72f 100644
--- a/httpx/concurrency/base.py
+++ b/httpx/concurrency/base.py
@@ -1,6 +1,5 @@
 import ssl
 import typing
-from types import TracebackType
 
 from ..config import PoolLimits, Timeout
 
@@ -154,27 +153,20 @@ class ConcurrencyBackend:
     def create_event(self) -> BaseEvent:
         raise NotImplementedError()  # pragma: no cover
 
-    def background_manager(
-        self, coroutine: typing.Callable, *args: typing.Any
-    ) -> "BaseBackgroundManager":
-        raise NotImplementedError()  # pragma: no cover
-
-
-class BaseBackgroundManager:
-    async def __aenter__(self) -> "BaseBackgroundManager":
-        raise NotImplementedError()  # pragma: no cover
-
-    async def __aexit__(
+    async def fork(
         self,
-        exc_type: typing.Type[BaseException] = None,
-        exc_value: BaseException = None,
-        traceback: TracebackType = None,
+        coroutine1: typing.Callable,
+        args1: typing.Sequence,
+        coroutine2: typing.Callable,
+        args2: typing.Sequence,
     ) -> None:
-        raise NotImplementedError()  # pragma: no cover
+        """
+        Run two coroutines concurrently.
+
+        This should start 'coroutine1' with '*args1' and 'coroutine2' with '*args2',
+        and wait for them to finish.
 
-    async def close(self, exception: BaseException = None) -> None:
-        if exception is None:
-            await self.__aexit__(None, None, None)
-        else:
-            traceback = exception.__traceback__  # type: ignore
-            await self.__aexit__(type(exception), exception, traceback)
+        In case one of the coroutines raises an exception, cancel the other one then
+        raise. If the other coroutine had also raised an exception, ignore it (for now).
+        """
+        raise NotImplementedError()  # pragma: no cover
diff --git a/httpx/concurrency/trio.py b/httpx/concurrency/trio.py
index 4af4242..f1fc7c4 100644
--- a/httpx/concurrency/trio.py
+++ b/httpx/concurrency/trio.py
@@ -1,14 +1,12 @@
 import functools
 import ssl
 import typing
-from types import TracebackType
 
 import trio
 
 from ..config import PoolLimits, Timeout
 from ..exceptions import ConnectTimeout, PoolTimeout, ReadTimeout, WriteTimeout
 from .base import (
-    BaseBackgroundManager,
     BaseEvent,
     BasePoolSemaphore,
     BaseSocketStream,
@@ -204,17 +202,31 @@ class TrioBackend(ConcurrencyBackend):
             functools.partial(coroutine, **kwargs) if kwargs else coroutine, *args
         )
 
+    async def fork(
+        self,
+        coroutine1: typing.Callable,
+        args1: typing.Sequence,
+        coroutine2: typing.Callable,
+        args2: typing.Sequence,
+    ) -> None:
+        try:
+            async with trio.open_nursery() as nursery:
+                nursery.start_soon(coroutine1, *args1)
+                nursery.start_soon(coroutine2, *args2)
+        except trio.MultiError as exc:
+            # NOTE: asyncio doesn't handle multi-errors yet, so we must align on its
+            # behavior here, and need to arbitrarily decide which exception to raise.
+            # We may want to add an 'httpx.MultiError', manually add support
+            # for this situation in the asyncio backend, and re-raise
+            # an 'httpx.MultiError' from trio's here.
+            raise exc.exceptions[0]
+
     def get_semaphore(self, limits: PoolLimits) -> BasePoolSemaphore:
         return PoolSemaphore(limits)
 
     def create_event(self) -> BaseEvent:
         return Event()
 
-    def background_manager(
-        self, coroutine: typing.Callable, *args: typing.Any
-    ) -> "BackgroundManager":
-        return BackgroundManager(coroutine, *args)
-
 
 class Event(BaseEvent):
     def __init__(self) -> None:
@@ -233,25 +245,3 @@ class Event(BaseEvent):
         # trio.Event.clear() was deprecated in Trio 0.12.
         # https://github.com/python-trio/trio/issues/637
         self._event = trio.Event()
-
-
-class BackgroundManager(BaseBackgroundManager):
-    def __init__(self, coroutine: typing.Callable, *args: typing.Any) -> None:
-        self.coroutine = coroutine
-        self.args = args
-        self.nursery_manager = trio.open_nursery()
-        self.nursery: typing.Optional[trio.Nursery] = None
-
-    async def __aenter__(self) -> "BackgroundManager":
-        self.nursery = await self.nursery_manager.__aenter__()
-        self.nursery.start_soon(self.coroutine, *self.args)
-        return self
-
-    async def __aexit__(
-        self,
-        exc_type: typing.Type[BaseException] = None,
-        exc_value: BaseException = None,
-        traceback: TracebackType = None,
-    ) -> None:
-        assert self.nursery is not None
-        await self.nursery_manager.__aexit__(exc_type, exc_value, traceback)
diff --git a/httpx/dispatch/http2.py b/httpx/dispatch/http2.py
index 09368c8..471ba9b 100644
--- a/httpx/dispatch/http2.py
+++ b/httpx/dispatch/http2.py
@@ -65,9 +65,23 @@ class HTTP2Connection:
         self.timeout_flags[stream_id] = TimeoutFlag()
         self.window_update_received[stream_id] = self.backend.create_event()
 
-        task, args = self.send_request_data, [stream_id, request.stream(), timeout]
-        async with self.backend.background_manager(task, *args):
+        status_code: typing.Optional[int] = None
+        headers: typing.Optional[list] = None
+
+        async def receive_response(stream_id: int, timeout: Timeout) -> None:
+            nonlocal status_code, headers
             status_code, headers = await self.receive_response(stream_id, timeout)
+
+        await self.backend.fork(
+            self.send_request_data,
+            [stream_id, request.stream(), timeout],
+            receive_response,
+            [stream_id, timeout],
+        )
+
+        assert status_code is not None
+        assert headers is not None
+
         content = self.body_iter(stream_id, timeout)
         on_close = functools.partial(self.response_closed, stream_id=stream_id)
 

Drop background manager (in favour of a two-function fork primitive).
I think we're running against the grain with our current `read`, `write`, `background_manager` API on the backends.

Switching to the same style as the `hip` project is using for its concurrency backends is likely a smart thing for us to do here... https://github.com/python-trio/hip/tree/master/src/urllib3/_backends

What would it take for us to switch over to a `send_and_receive_for_a_while` API?

@florimondmanca - I seem to remember you did some work on scoping this out at some point?

diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index 4fecaa6..0898dd4 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -3,7 +3,7 @@ import trio
 
 from httpx import AsyncioBackend, SSLConfig, Timeout
 from httpx.concurrency.trio import TrioBackend
-from tests.concurrency import run_concurrently
+from tests.concurrency import run_concurrently, sleep
 
 
 def get_asyncio_cipher(stream):
@@ -110,3 +110,49 @@ async def test_concurrent_read(server, backend):
         )
     finally:
         await stream.close()
+
+
+async def test_fork(backend):
+    ok_counter = 0
+
+    async def ok(delay: int) -> None:
+        nonlocal ok_counter
+        await sleep(backend, delay)
+        ok_counter += 1
+
+    async def fail(message: str, delay: int) -> None:
+        await sleep(backend, delay)
+        raise RuntimeError(message)
+
+    await backend.fork(ok, [0], ok, [0])
+    assert ok_counter == 2
+
+    with pytest.raises(RuntimeError, match="Oops"):
+        await backend.fork(ok, [0], fail, ["Oops", 0.01])
+
+    assert ok_counter == 3
+
+    with pytest.raises(RuntimeError, match="Oops"):
+        await backend.fork(ok, [0.01], fail, ["Oops", 0])
+
+    assert ok_counter == 3
+
+    with pytest.raises(RuntimeError, match="Oops"):
+        await backend.fork(fail, ["Oops", 0.01], ok, [0])
+
+    assert ok_counter == 4
+
+    with pytest.raises(RuntimeError, match="Oops"):
+        await backend.fork(fail, ["Oops", 0], ok, [0.01])
+
+    assert ok_counter == 4
+
+    with pytest.raises(RuntimeError, match="My bad"):
+        await backend.fork(fail, ["My bad", 0], fail, ["Oops", 0.01])
+
+    with pytest.raises(RuntimeError, match="Oops"):
+        await backend.fork(fail, ["My bad", 0.01], fail, ["Oops", 0])
+
+    # No 'match', since we can't know which will be raised first.
+    with pytest.raises(RuntimeError):
+        await backend.fork(fail, ["My bad", 0], fail, ["Oops", 0])

pip install -e .
parse_log_pytest
requirements.txt
3.7
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aspy.refactor-imports==3.0.2
attrs==24.2.0
autoflake==2.1.1
Babel==2.14.0
black==23.3.0
brotlipy==0.7.0
cached-property==1.5.2
certifi @ file:///croot/certifi_1671487769961/work/certifi
cffi==1.15.1
chardet==3.0.4
charset-normalizer==3.4.1
click==8.1.8
colorama==0.4.6
coverage==7.2.7
cryptography==44.0.2
exceptiongroup==1.2.2
flake8==2.3.0
flake8-bugbear==16.7.0
flake8-pie==0.16.0
ghp-import==2.1.0
h11==0.8.1
h2==3.2.0
hpack==3.0.0
hstspreload==2025.1.1
-e git+https://github.com/encode/httpx.git@bb6e52f3566a79978f590e5a677eb057a68ad3a9#egg=httpx
hyperframe==5.2.0
idna==2.10
importlib-metadata==6.7.0
iniconfig==2.0.0
isort==5.11.5
Jinja2==3.1.6
Markdown==3.4.4
MarkupSafe==2.1.5
mccabe==0.7.0
mergedeep==1.3.4
mkautodoc==0.2.0
mkdocs==1.5.3
mkdocs-material==9.2.7
mkdocs-material-extensions==1.2
mypy==1.4.1
mypy-extensions==1.0.0
outcome==1.3.0.post0
packaging==24.0
paginate==0.5.7
pathspec==0.11.2
pep8==1.7.1
platformdirs==4.0.0
pluggy==1.2.0
pycparser==2.21
pyflakes==3.0.1
Pygments==2.17.2
pymdown-extensions==10.2.1
pytest==7.4.4
pytest-asyncio==0.21.2
pytest-cov==4.1.0
pytest-trio==0.8.0
python-dateutil==2.9.0.post0
pytz==2025.2
PyYAML==6.0.1
pyyaml_env_tag==0.1
regex==2022.10.31
requests==2.31.0
rfc3986==1.5.0
seed-isort-config==2.2.0
six==1.17.0
sniffio==1.3.1
sortedcontainers==2.4.0
tomli==2.0.1
trio==0.22.2
trustme==1.0.0
typed-ast==1.5.5
typing_extensions==4.7.1
urllib3==2.0.7
uvicorn==0.22.0
watchdog==3.0.0
zipp==3.15.0

name: httpx
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=22.3.1=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aspy-refactor-imports==3.0.2
      - attrs==24.2.0
      - autoflake==2.1.1
      - babel==2.14.0
      - black==23.3.0
      - brotlipy==0.7.0
      - cached-property==1.5.2
      - cffi==1.15.1
      - chardet==3.0.4
      - charset-normalizer==3.4.1
      - click==8.1.8
      - colorama==0.4.6
      - coverage==7.2.7
      - cryptography==44.0.2
      - exceptiongroup==1.2.2
      - flake8==2.3.0
      - flake8-bugbear==16.7.0
      - flake8-pie==0.16.0
      - ghp-import==2.1.0
      - h11==0.8.1
      - h2==3.2.0
      - hpack==3.0.0
      - hstspreload==2025.1.1
      - hyperframe==5.2.0
      - idna==2.10
      - importlib-metadata==6.7.0
      - iniconfig==2.0.0
      - isort==5.11.5
      - jinja2==3.1.6
      - markdown==3.4.4
      - markupsafe==2.1.5
      - mccabe==0.7.0
      - mergedeep==1.3.4
      - mkautodoc==0.2.0
      - mkdocs==1.5.3
      - mkdocs-material==9.2.7
      - mkdocs-material-extensions==1.2
      - mypy==1.4.1
      - mypy-extensions==1.0.0
      - outcome==1.3.0.post0
      - packaging==24.0
      - paginate==0.5.7
      - pathspec==0.11.2
      - pep8==1.7.1
      - platformdirs==4.0.0
      - pluggy==1.2.0
      - pycparser==2.21
      - pyflakes==3.0.1
      - pygments==2.17.2
      - pymdown-extensions==10.2.1
      - pytest==7.4.4
      - pytest-asyncio==0.21.2
      - pytest-cov==4.1.0
      - pytest-trio==0.8.0
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyyaml==6.0.1
      - pyyaml-env-tag==0.1
      - regex==2022.10.31
      - requests==2.31.0
      - rfc3986==1.5.0
      - seed-isort-config==2.2.0
      - six==1.17.0
      - sniffio==1.3.1
      - sortedcontainers==2.4.0
      - tomli==2.0.1
      - trio==0.22.2
      - trustme==1.0.0
      - typed-ast==1.5.5
      - typing-extensions==4.7.1
      - urllib3==2.0.7
      - uvicorn==0.22.0
      - watchdog==3.0.0
      - zipp==3.15.0
prefix: /opt/conda/envs/httpx

tests/test_concurrency.py::test_fork[AsyncioBackend] tests/test_concurrency.py::test_fork[TrioBackend]
tests/test_concurrency.py::test_start_tls_on_tcp_socket_stream[backend0-get_asyncio_cipher] tests/test_concurrency.py::test_start_tls_on_tcp_socket_stream[backend1-get_trio_cipher] tests/test_concurrency.py::test_start_tls_on_uds_socket_stream[backend0-get_asyncio_cipher] tests/test_concurrency.py::test_start_tls_on_uds_socket_stream[backend1-get_trio_cipher]
tests/test_concurrency.py::test_concurrent_read[AsyncioBackend] tests/test_concurrency.py::test_concurrent_read[TrioBackend]
httpx/__init__.py httpx/concurrency/auto.py httpx/concurrency/trio.py httpx/concurrency/asyncio.py httpx/dispatch/http2.py httpx/concurrency/base.py
httpx/__init__.py httpx/concurrency/auto.py httpx/concurrency/trio.py httpx/concurrency/asyncio.py httpx/dispatch/http2.py httpx/concurrency/base.py