The Fix
Upgrade to version 0.13.0 or later.
Based on closed encode/httpx issue #422 · PR/commit linked
Production note: Watch p95/p99 latency and retry volume; timeouts can turn into retry storms and duplicate side-effects.
@@ -6,15 +6,36 @@ Using a Client instance to make requests will give you HTTP connection pooling,
all outgoing requests.
-A Client instance is equivalent to a Session instance in `requests`.
+!!! hint
+ A Client instance is equivalent to a Session instance in `requests`.
# project/tmdb.py
import os
import typing
import httpx
from flask import Flask, g
TMDB_API_KEY = os.environ["TMDB_API_KEY"]
def get_tmdb() -> httpx.Client:
if "tmdb" not in g:
# Create a client.
# Note that this hints at keeping `Client.__enter__()` in
# its current state - not doing anything particular.
# (This is similar to the `open()` built-in not requiring us to manually call `__enter__()`
# to use it without the `with` statement.)
g.tmdb = httpx.Client(
base_url="https://api.themoviedb.org/3", headers={"x-api-key": TMDB_API_KEY}
)
return g.tmdb
def close_tmdb() -> None:
# Ensure the client is closed if `get_tmdb()` was called.
tmdb: httpx.Client = g.pop("tmdb", None)
if tmdb is not None:
tmdb.close()
def init_app(app: Flask) -> None:
app.teardown_appcontext(close_tmdb)
# project/movies.py
from flask import Blueprint, jsonify, request
from .tmdb import get_tmdb
bp = Blueprint("movies", __name__, url_prefix="movies")
@bp.route("/search")
def search_movies():
q = request.args.get("q")
with get_tmdb() as tmdb:
# If we made multiple requests to the TMDb API here,
# and assuming TMDb supports HTTP/2,
# then we'd be reusing the connection across requests.
# Would it be desirable to share connections across views, anyway?
r = tmdb.get("/search/tv", params={"query": q, "page": 1})
r.raise_for_status()
rows = r.json()
shows = [{"id": row["id"], "title": row["name"]} for row in rows]
return jsonify(shows)
# project/app.py
from flask import Flask
def create_app() -> Flask:
from . import tmdb
from . import movies
app = Flask(__name__)
tmdb.init_app(app)
app.register_blueprint(movies.bp)
return app
if __name__ == "__main__":
app = create_app()
app.run()
Re-run the minimal reproduction on your broken version, then apply the fix and re-run.
Option A — Upgrade to fixed release\nUpgrade to version 0.13.0 or later.\nWhen NOT to use: Avoid using a context-managed Client if you need to maintain a persistent connection across multiple requests.\n\n
Why This Fix Works in Production
- Trigger: A good point of reference here would be to see how browsers treat HTTP/2 connections to hosts with open tabs - Do they continually ping them in the background?…
- Mechanism: The documentation did not clarify the usage of Client as a context manager and the close() method
- Why the fix works: Documents the usage of the Client as a context manager and the alternative close() method, addressing issue #422. (first fixed release: 0.13.0).
- If left unfixed, tail latency can spike under load and surface as timeouts/retries (amplifying incident impact).
Why This Breaks in Prod
- The documentation did not clarify the usage of Client as a context manager and the close() method
- Production symptom (often without a traceback): A good point of reference here would be to see how browsers treat HTTP/2 connections to hosts with open tabs - Do they continually ping them in the background? Does the behavior change for inactive tabs?
Proof / Evidence
- GitHub issue: #422
- Fix PR: https://github.com/encode/httpx/pull/487
- First fixed release: 0.13.0
- Reproduced locally: No (not executed)
- Last verified: 2026-02-09
- Confidence: 0.85
- Did this fix it?: Yes (upstream fix exists)
- Own content ratio: 0.45
Discussion
High-signal excerpts from the issue thread (symptoms, repros, edge-cases).
“Since it seems we’ve reached a common understanding, are there any actions points to take away from this issue before closing? Documentation updates maybe?”
“We ought to: * Document with Client() as client, which we support, but don't currently document”
“This thread https://github.com/python-trio/urllib3/issues/125 from @njsmith is nicely timed. Pretty much in line with how I'd see it.”
“That thread couldn't be any better on the timing. I like the global pool with a system level watcher. :)”
Failure Signature (Search String)
- A good point of reference here would be to see how browsers treat HTTP/2 connections to hosts with open tabs - Do they continually ping them in the background? Does the behavior
- It'd be a fairly reasonable policy to allow connections to lapse with ping timeouts in cases where we're not performing _any_ network activity with the client at all.
Copy-friendly signature
Failure Signature
-----------------
A good point of reference here would be to see how browsers treat HTTP/2 connections to hosts with open tabs - Do they continually ping them in the background? Does the behavior change for inactive tabs?
It'd be a fairly reasonable policy to allow connections to lapse with ping timeouts in cases where we're not performing _any_ network activity with the client at all.
Error Message
Signature-only (no traceback captured)
Error Message
-------------
A good point of reference here would be to see how browsers treat HTTP/2 connections to hosts with open tabs - Do they continually ping them in the background? Does the behavior change for inactive tabs?
It'd be a fairly reasonable policy to allow connections to lapse with ping timeouts in cases where we're not performing _any_ network activity with the client at all.
Minimal Reproduction
# project/tmdb.py
import os
import typing
import httpx
from flask import Flask, g
TMDB_API_KEY = os.environ["TMDB_API_KEY"]
def get_tmdb() -> httpx.Client:
if "tmdb" not in g:
# Create a client.
# Note that this hints at keeping `Client.__enter__()` in
# its current state - not doing anything particular.
# (This is similar to the `open()` built-in not requiring us to manually call `__enter__()`
# to use it without the `with` statement.)
g.tmdb = httpx.Client(
base_url="https://api.themoviedb.org/3", headers={"x-api-key": TMDB_API_KEY}
)
return g.tmdb
def close_tmdb() -> None:
# Ensure the client is closed if `get_tmdb()` was called.
tmdb: httpx.Client = g.pop("tmdb", None)
if tmdb is not None:
tmdb.close()
def init_app(app: Flask) -> None:
app.teardown_appcontext(close_tmdb)
# project/movies.py
from flask import Blueprint, jsonify, request
from .tmdb import get_tmdb
bp = Blueprint("movies", __name__, url_prefix="movies")
@bp.route("/search")
def search_movies():
q = request.args.get("q")
with get_tmdb() as tmdb:
# If we made multiple requests to the TMDb API here,
# and assuming TMDb supports HTTP/2,
# then we'd be reusing the connection across requests.
# Would it be desirable to share connections across views, anyway?
r = tmdb.get("/search/tv", params={"query": q, "page": 1})
r.raise_for_status()
rows = r.json()
shows = [{"id": row["id"], "title": row["name"]} for row in rows]
return jsonify(shows)
# project/app.py
from flask import Flask
def create_app() -> Flask:
from . import tmdb
from . import movies
app = Flask(__name__)
tmdb.init_app(app)
app.register_blueprint(movies.bp)
return app
if __name__ == "__main__":
app = create_app()
app.run()
What Broke
Users may experience resource leaks or connection issues when not properly managing Client instances.
Why It Broke
The documentation did not clarify the usage of Client as a context manager and the close() method
Fix Options (Details)
Option A — Upgrade to fixed release Safe default (recommended)
Upgrade to version 0.13.0 or later.
Use when you can deploy the upstream fix. It is usually lower-risk than long-lived workarounds.
Option D — Guard side-effects with OnceOnly Guardrail for side-effects
Mitigate duplicate external side-effects under retries/timeouts/agent loops by gating the operation before calling external systems.
- Place OnceOnly between your code/agent and real side-effects (Stripe, emails, CRM, APIs).
- Use a stable key per side-effect (e.g., customer_id + action + idempotency_key).
- Fail-safe: configure fail-open vs fail-closed based on blast radius and spend risk.
Show example snippet (optional)
from onceonly import OnceOnly
import os
once = OnceOnly(api_key=os.environ["ONCEONLY_API_KEY"], fail_open=True)
# Stable idempotency key per real side-effect.
# Use a request id / job id / webhook delivery id / Stripe event id, etc.
event_id = "evt_..." # replace
key = f"stripe:webhook:{event_id}"
res = once.check_lock(key=key, ttl=3600)
if res.duplicate:
return {"status": "already_processed"}
# Safe to execute the side-effect exactly once.
handle_event(event_id)
Fix reference: https://github.com/encode/httpx/pull/487
First fixed release: 0.13.0
Last verified: 2026-02-09. Validate in your environment.
When NOT to Use This Fix
- Avoid using a context-managed Client if you need to maintain a persistent connection across multiple requests.
- Do not use this to hide logic bugs or data corruption. Use it to block duplicate external side-effects and enforce tool permissions/spend caps.
Verify Fix
Re-run the minimal reproduction on your broken version, then apply the fix and re-run.
Did This Fix Work in Your Case?
Quick signal helps us prioritize which fixes to verify and improve.
Prevention
- Add a CI check that diffs key outputs after upgrades (OpenAPI schema snapshots, JSON payload shapes, CLI output).
- Upgrade behind a canary and run integration tests against the canary before 100% rollout.
- Make timeouts explicit and test them (unit + integration) to avoid silent behavior changes.
- Instrument retries (attempt count + reason) and alert on spikes to catch dependency slowdowns.
Version Compatibility Table
| Version | Status |
|---|---|
| 0.13.0 | Fixed |
Related Issues
No related fixes found.
Sources
We don’t republish the full GitHub discussion text. Use the links above for context.