httpx 1.0

In case you weren't aware, there's quite a debate over httpx Python library, especially maintainer of both openai-python and anthropic-sdk-python showed up and decided to switch to httpx2, a fork of httpx that maintains v0.28 styles

httpx is a library that can handle both sync/async http, replacing requests (sync-only) and aiohttp (async-opnly).

1. verify= and cert= → explicit ssl.SSLContext

for version < v1.0

# Boolean on/off — still fine, not deprecated
httpx.Client(verify=True)   # default: verify using certifi's CA bundle
httpx.Client(verify=False)  # danger: accept any certificate

# String path — NOW DEPRECATED, raises a warning
httpx.Client(verify="/path/to/custom-ca-bundle.pem")

# cert= for a client certificate (mutual TLS) — NOW DEPRECATED
httpx.Client(cert=("/path/to/client.pem", "/path/to/client.key"))

v1.0

import ssl, certifi, httpx

# Equivalent to default verify=True
ctx = ssl.create_default_context(cafile=certifi.where())
httpx.Client(verify=ctx)

# Custom CA bundle
ctx = ssl.create_default_context(cafile="/path/to/custom-ca-bundle.pem")
httpx.Client(verify=ctx)

# Client certificate (mutual TLS) is now loaded onto the context itself
ctx = ssl.create_default_context(cafile=certifi.where())
ctx.load_cert_chain("/path/to/client.pem", "/path/to/client.key")
httpx.Client(verify=ctx)

2. proxies=proxy= / mounts=

Before:

# Old dict-based per-scheme mapping
httpx.Client(proxies={"http://": "http://localhost:8030", "https://": "http://localhost:8031"})

After:

# Single proxy for everything
httpx.Client(proxy="http://localhost:8030")

# Per-scheme routing via mounts (a dict of URL-pattern -> Transport)
httpx.Client(mounts={
    "http://": httpx.HTTPTransport(proxy="http://localhost:8030"),
    "https://": httpx.HTTPTransport(proxy="http://localhost:8031"),
})

3. app= shortcut → explicit transport=

What app= was for: letting you point a Client directly at a WSGI or ASGI Python callable instead of a URL, so you could test your web app without spinning up a real server.

Before:

httpx.Client(app=my_asgi_app, base_url="http://testserver")

After:

httpx.Client(transport=httpx.ASGITransport(app=my_asgi_app), base_url="http://testserver")
# or for WSGI:
httpx.Client(transport=httpx.WSGITransport(app=my_wsgi_app), base_url="http://testserver")

4. allow_redirects=follow_redirects=, default flipped to False

What changed: in the 0.20 release, redirects stopped being followed automatically by default.

Before (pre-0.20 behavior, and the old kwarg name):

httpx.get("http://api.github.com/")  
# silently redirects http -> https, meaning every request was actually sent twice

After:

httpx.get("http://api.github.com/", follow_redirects=True)  # opt-in explicitly
# or
httpx.Client(follow_redirects=True)  # opt-in for the whole client

The maintainers gave a concrete example — a client configured for http://api.github.com/ was silently sending every single request twice (once to get redirected, once to the real HTTPS URL), wasting a round-trip nobody asked for. They decided implicit redirect-following causes more surprise bugs (accidentally hitting an endpoint twice, e.g. a payment POST) than it saves typing, so it became opt-in. This was explicitly flagged as "no universally right answer, just a trade-off" in their own release notes.

5. Compact JSON request bodies

Before: json={"a": 1, "b": 2} serialized with the stdlib default spacing → {"a": 1, "b": 2} (space after : and ,).

After: same call now serializes to {"a":1,"b":2} (no extra spaces).

This is a good change!

6. Query-string percent-encoding rules changed

Before: spaces in query params encoded as +, and / inside a query value encoded as %2F (this is the old application/x-www-form-urlencoded-style behavior, same as requests).

After: spaces encoded as %20, and / left unescaped in the query portion (matches how Chrome/Safari/Firefox actually build URLs, per the WHATWG spec rather than the older RFC 3986 reading).

# requests-style / old httpx:
"?q=hello+world&path=a%2Fb"
# new httpx:
"?q=hello%20world&path=a/b"

This is a case where IETF RFC3986 vs. WHATWG's disagree, and real browsers follow WHATWG.

7. Automatic .netrc handling → explicit httpx.NetRCAuth()

Before: if you had a ~/.netrc file, httpx would silently pick up and use those credentials on any matching request.

After:

httpx.get(url, auth=httpx.NetRCAuth())

Silently reading a credentials file from disk shouldn't be done by default.

8. response.iter_lines() behavior

Before: yielded lines including trailing newline characters ("line one\n").

After: matches Python's own str.splitlines() / file-iteration convention — newlines stripped ("line one"), and a related performance bug fixed.

Matching stdlib conventions (for line in file: style) is less surprising than inventing your own line-splitting semantics.

9. QueryParams became immutable

Before (implied by the old API): client.params.update(...) mutated in place.

After:

client.params = client.params.merge({"key": "value"})
# or the more granular:
params.set("key", "value")
params.add("key", "value")     # allow duplicate keys
params.remove("key")

Mutable shared state (like a dict) attached to a client is a classic source of bugs

10. Sync and async split into two separate installable packages

Background: today, one package gives you both a blocking (Client) and non-blocking (AsyncClient) interface, because under the hood both share the same code via a code-generation trick (unasync) that turns the async source into the sync version automatically at build time.

Before:

import httpx
r = httpx.get("https://example.org")          # sync

async def main():
    async with httpx.AsyncClient() as cli:
        r = await cli.get("https://example.org")   # async

After (previewed):

# pip install httpx
import httpx
r = httpx.get("https://example.org")           # sync only

# pip install ahttpx   <-- a *different* package
import ahttpx
r = await ahttpx.get("https://example.org")    # async only

The maintainers reasoned that if you only ever use the sync client, you're still forced to install anyio and sniffio (async-support dependencies) that you never touch. Splitting the packages lets sync-only users have a genuinely smaller dependency tree, and removes the slightly awkward Client/AsyncClient naming duplication in favor of one name per package.

The controversy (worth knowing about): this is the most contested item in the whole redesign. Simon Willison and several SDK maintainers (including Anthropic's and OpenAI's own Python SDK maintainers, both of which depend on httpx<1) argued in the public discussion that Python's inability to install two versions of the same package side-by-side means a hard breaking split could fragment the ecosystem for a long transition window, similar to what happened with Pydantic 1→2. Proposed alternatives floated in that thread include shipping the new design under a different name entirely (httpx2) or keeping both old and new APIs inside one package under a versioned namespace (httpx.v1). None of this is resolved as of the latest visible discussion activity.

My take: The whole reason why ppl chose httpx over aiohttp is because it can handle both.

11. json=, data=, files= shortcuts replaced by typed content= objects

Background: today httpx (like requests before it) offers three different keyword arguments depending on what kind of body you're sending, and picks the right Content-Type header for you based on which one you used.

Before:

client.post(url, json={"key": "value"})                       # application/json
client.post(url, data={"key": "value"})                       # form-urlencoded
client.post(url, files={"upload": open("report.pdf", "rb")})  # multipart/form-data
client.post(url, data={"name": "a"}, files={"upload": f})      # mixed form + file

After (previewed):

client.post(url, content=httpx.JSON({"key": "value"}))
client.post(url, content=httpx.Form({"key": "value"}))
client.post(url, content=httpx.Files({"upload": httpx.File("report.pdf")}))
client.post(url, content=httpx.MultiPart(
    form={"name": "a"},
    files={"upload": httpx.File("report.pdf")},
))

Java boi's fantacy to please the type checker.

12. Stricter typing on flexible parameters (discussed, not fully decided)

Background: currently timeout= accepts a bare float, a tuple of floats, or a Timeout instance; proxy= accepts a string, a URL, or a Proxy instance; similar flexibility exists for auth= and headers=.

The design-call notes floated constraining these to fewer accepted shapes for clarity, but a maintainer pushed back in the same conversation that "it's not clear tightening the API types is a better user experience and could cause churn" — so this one is explicitly unresolved, not a committed change.


# `proxy=` → `ProxyTypes`
httpx.Client(proxy="http://localhost:8030")                 # plain string
httpx.Client(proxy=httpx.URL("http://localhost:8030"))       # URL object
httpx.Client(proxy=httpx.Proxy("http://localhost:8030"))     # Proxy object, needed if you
                                                              # also want proxy auth/headers
# `timeout=` → `TimeoutTypes`
httpx.Client(timeout=10.0)                 # single float applied to connect/read/write/pool
httpx.Client(timeout=None)                 # disable timeouts entirely
httpx.Client(timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0))  # explicit object

# `auth=` → `AuthTypes`
httpx.get(url, auth=("username", "password"))   # 2-tuple -> Basic auth
httpx.get(url, auth=httpx.DigestAuth("username", "password"))  # explicit Auth subclass
httpx.get(url, auth=my_callable)                 # any callable(request) -> request, via FunctionAuth

# `headers=` → `HeaderTypes`
httpx.get(url, headers={"User-Agent": "my-app"})              # plain dict
httpx.get(url, headers=[("User-Agent", "my-app")])             # list of tuples (allows duplicate keys)
httpx.get(url, headers=httpx.Headers({"User-Agent": "my-app"}))  # explicit Headers object

tl;dr Giving type system a handjob like Java

Comments