Repository: encode/httpx

Issue:
AsyncClient.request runtime error: "Attempted to send an sync request with an AsyncClient instance"
Ran into this issue as well. It was surprising to end up with a `RuntimeError: Attempted to send an sync request with an AsyncClient instance` error, when my actual issue had nothing to do with sync/async (in my case submitting the wrong type by accident through `data` kwarg). It feels like it might be a bug / that the type being submitted could be checked before trying to send the request?

### Discussed in https://github.com/encode/httpx/discussions/2339

<div type='discussions-op-text'>

<sup>Originally posted by **mdlincoln** August 16, 2022</sup>
I need to interact with an API that accepts `DELETE` requests requiring JSON content in the body. Since `AsyncClient`'s shortcut `delete()` method does not allow this, I followed the docs to instead use the `request()` method, but I received an unexpected error. Minimal example:

``` py
import asyncio
from httpx import AsyncClient

async_client = AsyncClient(
    base_url="http://localhost",
)

async def test_request_method():
    async with async_client as ac:
        response = await ac.request(method="DELETE", url="/", content={"foo": "bar"})

asyncio.run(test_request_method())
```

this raises a `RuntimeError: Attempted to send an sync request with an AsyncClient instance.` (full traceback in the `<details>` below.)

Am I missing the right way for calling the `request()` method with an `AsyncClient`?

<details>
<pre>
Traceback (most recent call last):
  File "*******/minimal.py", line 14, in <module>
    asyncio.run(test_request_method())
  File "/opt/homebrew/Cellar/python@3.9/3.9.13_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/opt/homebrew/Cellar/python@3.9/3.9.13_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/asyncio/base_events.py", line 647, in run_until_complete
    return future.result()
  File "*********minimal.py", line 11, in test_request_method
    response = await ac.request(method="DELETE", url="/", content={"foo": "bar"})
  File "*********/lib/python3.9/site-packages/httpx/_client.py", line 1527, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "*********/lib/python3.9/site-packages/httpx/_client.py", line 1614, in send
    response = await self._send_handling_auth(
  File "*********/lib/python3.9/site-packages/httpx/_client.py", line 1642, in _send_handling_auth
    response = await self._send_handling_redirects(
  File "*********/lib/python3.9/site-packages/httpx/_client.py", line 1679, in _send_handling_redirects
    response = await self._send_single_request(request)
  File "*********/lib/python3.9/site-packages/httpx/_client.py", line 1711, in _send_single_request
    raise RuntimeError(
RuntimeError: Attempted to send an sync request with an AsyncClient instance.
</pre>
</details>
</div>


Discussion:
> I need to interact with an API that accepts DELETE requests requiring JSON content in the body.

That's a really confusing error message that you're seeing, which I need to dig into.

But the short answer to your question is...

> "I need to interact with an API that accepts DELETE requests requiring JSON content in the body."

Okay, so use `json=...` to send JSON data...

```python
await ac.request(method="DELETE", url="/", json={"foo": "bar"})
```
To improve our UX here we ought to be raising a `TypeError` if a dictionary is passed in the `content=...` argument.
Because it *won't do what you're expecting it to do*.

In the async case you'll end up with this really confusing error message.

In the sync case, you'll end up with...

```python
# Not ideal. We've treated the argument as valid, and attempted to send a request with content "foo".
>>> httpx.post("https://www.example.com", content={"foo": "bar"})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 304, in post
    return request(
           ^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 100, in request
    return client.request(
           ^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 821, in request
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 908, in send
    response = self._send_handling_auth(
               ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 936, in _send_handling_auth
    response = self._send_handling_redirects(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 973, in _send_handling_redirects
    response = self._send_single_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 1009, in _send_single_request
    response = transport.handle_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_transports/default.py", line 218, in handle_request
    resp = self._pool.handle_request(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/connection_pool.py", line 253, in handle_request
    raise exc
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/connection_pool.py", line 237, in handle_request
    response = connection.handle_request(request)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/connection.py", line 90, in handle_request
    return self._connection.handle_request(request)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/http11.py", line 112, in handle_request
    raise exc
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/http11.py", line 82, in handle_request
    self._send_request_body(**kwargs)
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/http11.py", line 135, in _send_request_body
    self._send_event(event, timeout=timeout)
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/httpcore/_sync/http11.py", line 142, in _send_event
    bytes_to_send = self._h11_state.send(event)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/venv/lib/python3.11/site-packages/h11/_connection.py", line 516, in send
    return b"".join(data_list)
           ^^^^^^^^^^^^^^^^^^^
TypeError: sequence item 1: expected a bytes-like object, str found
```

This is because `content=...` should be a str, bytes, or bytes iterator. Because `dict` instances *happen* to be iterators, the internals assume a bytes-iterator has been passed, and treat it as a valid argument.

Really we'd like the same behaviour that we currently have if you pass some *other* incorrect type to `content=...`, eg:

```python
# Look, this error message makes sense...
>>> httpx.post("https://www.example.com", content=123)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 304, in post
    return request(
           ^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 100, in request
    return client.request(
           ^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 808, in request
    request = self.build_request(
              ^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 360, in build_request
    return Request(
           ^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_models.py", line 339, in __init__
    headers, stream = encode_request(
                      ^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_content.py", line 205, in encode_request
    return encode_content(content)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_content.py", line 130, in encode_content
    raise TypeError(f"Unexpected type for 'content', {type(content)!r}")
TypeError: Unexpected type for 'content', <class 'int'>
```

It would be good if we switch the behaviour for the `dict` case to match this...

```python
# This would be a neater behaviour...
>>> httpx.post("https://www.example.com", content={"a": "b"})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 304, in post
    return request(
           ^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_api.py", line 100, in request
    return client.request(
           ^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 808, in request
    request = self.build_request(
              ^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_client.py", line 360, in build_request
    return Request(
           ^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_models.py", line 339, in __init__
    headers, stream = encode_request(
                      ^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_content.py", line 205, in encode_request
    return encode_content(content)
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tomchristie/GitHub/encode/httpx/httpx/_content.py", line 130, in encode_content
    raise TypeError(f"Unexpected type for 'content', {type(content)!r}")
TypeError: Unexpected type for 'content', <class 'dict'>
```

Resolving patch:
diff --git a/httpx/_content.py b/httpx/_content.py
--- a/httpx/_content.py
+++ b/httpx/_content.py
@@ -114,7 +114,11 @@ def encode_content(
         headers = {"Content-Length": str(content_length)} if body else {}
         return headers, ByteStream(body)
 
-    elif isinstance(content, Iterable):
+    elif isinstance(content, Iterable) and not isinstance(content, dict):
+        # `not isinstance(content, dict)` is a bit oddly specific, but it
+        # catches a case that's easy for users to make in error, and would
+        # otherwise pass through here, like any other bytes-iterable,
+        # because `dict` happens to be iterable. See issue #2491.
         content_length_or_none = peek_filelike_length(content)
 
         if content_length_or_none is None:


Test patch:
diff --git a/tests/test_content.py b/tests/test_content.py
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -360,6 +360,9 @@ def test_invalid_argument():
     with pytest.raises(TypeError):
         httpx.Request(method, url, content=123)  # type: ignore
 
+    with pytest.raises(TypeError):
+        httpx.Request(method, url, content={"a": "b"})  # type: ignore
+
 
 @pytest.mark.asyncio
 async def test_multipart_multiple_files_single_input_content():