Skip to content

fix(client): reopen a closed client on use instead of raising ClientClosed - #106

Merged
thomasht86 merged 2 commits into
mainfrom
fix/reopen-after-close
Sep 10, 2026
Merged

thomasht86 merged 2 commits into
mainfrom
fix/reopen-after-close

Conversation

@thomasht86

Copy link
Copy Markdown
Owner

Restores compatibility with every released pyvespa, which 0.7.0 and 0.7.1 broke (see pyvespa#1347). Intended for 0.7.2, after which 0.7.0 and 0.7.1 should be yanked on PyPI.

Problem

0.7.0 made close() release the connection pool and turned use after close into ClientClosed. Vespa.visit() and Vespa.query(streaming=True) return lazy generators from inside a with VespaSync(...) block, so every released pyvespa consumes them after the client is closed. That worked for a year because close() was a no-op. Published pyvespa releases allow any httpr>=0.4.0, so only httpr can fix the field.

Change

A request on a closed client now reopens it (requests.Session semantics):

  • RClient::begin_request rebuilds the reqwest::Client from the stored ClientConfig with a fresh CancellationToken (the old one was cancelled by close()), installs it via the new ClientState::reopen(), and emits httpr.ClientReopenedWarning, a ResourceWarning subclass: silent for end users, shown in test runs, and warnings.simplefilter("error", httpr.ClientReopenedWarning) restores httpx's strict behaviour.
  • close() still releases the pool immediately; the lifecycle fix from close() / aclose() / __exit__ are no-ops — connections never released #88 is intact. is_closed is true from close() until the next request.
  • Headers, params, auth, proxy and timeout live on RClient and carry over; the rebuilt client starts with an empty cookie store.
  • client.proxy = ... on a closed client records the proxy for the rebuild instead of raising.
  • AsyncClient creates its thread pool on demand and drops it on close, so a reopened client gets a new one. An open stream keeps reading after aclose() since it holds its own pool handle.
  • ClientClosed stays exported (still a RuntimeError) but is only raised if a close() races the reopen itself.

Downstream CI job

New downstream-pyvespa job in CI.yml: checks out pyvespa master, installs it with [unittest], swaps in the wheel built from the PR with --no-deps, and runs pyvespa's unit suite. Runs on PRs, main and tags.

Verification

  • httpr unit suite: 240 passed, 2 skipped. ruff, mypy, cargo fmt, clippy clean.
  • pyvespa origin/master unit suite against this wheel, with -W error::httpr.ClientReopenedWarning: 693 passed, 140 subtests.
  • pyvespa origin/master Docker integration tests that use visit() (the ones that fail on 0.7.1): running, results in a comment below.

🤖 Generated with Claude Code

…losed

0.7.0 made close() release the connection pool and turned use after close
into ClientClosed. Every released pyvespa consumes Vespa.visit() and
streaming-query generators after the owning `with VespaSync` block has
closed the client, which worked for a year because close() was a no-op,
so 0.7.0 and 0.7.1 broke them all (pyvespa PR #1347 fixes the pattern
going forward, but published releases allow any httpr >= 0.4.0).

A request on a closed client now rebuilds the reqwest client from the
constructor settings, installs it with a fresh cancellation token (the
old one was cancelled by close()) and emits ClientReopenedWarning, a
ResourceWarning subclass: silent for end users, visible in test runs,
and `warnings.simplefilter("error", httpr.ClientReopenedWarning)`
restores httpx's strict behaviour. close() still releases the pool
immediately. The rebuilt client starts with an empty cookie store;
headers, params, auth, proxy and timeout live on the RClient and carry
over. Assigning client.proxy on a closed client records the proxy for
the rebuild instead of raising.

AsyncClient creates its thread pool on demand and drops it on close, so
a reopened client gets a new pool too. An open stream keeps reading
after aclose() since it holds its own handle to the pool.

ClientClosed stays exported (still a RuntimeError) but is only raised
when a close() races the reopen itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@thomasht86

Copy link
Copy Markdown
Owner Author

Downstream verification against unmodified pyvespa origin/master (i.e. without pyvespa#1347), wheel built from this branch:

Suite Result
tests/unit with -W error::httpr.ClientReopenedWarning 693 passed, 140 subtests
tests/integration/test_integration_docker.py -k "test_retries_sync or test_document_expiry" (both drive app.visit()) 2 passed in 93 s

Each Docker test emitted ClientReopenedWarning exactly once, from Vespa.visit() consuming its generator after the with VespaSync block closed the client. That is the path that raises ClientClosed on 0.7.1, so the reopen is doing what it is for. The unit suite stays silent under the warning-as-error filter because it mocks the client.

@codspeed

codspeed Bot commented Sep 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 35 untouched benchmarks


Comparing fix/reopen-after-close (1728606) with main (10d49a2)

Open in CodSpeed

…find the vespa CLI

48 tests in pyvespa's tests/unit/test_deployment.py generate a throwaway
Vespa Cloud cert/key pair with the `vespa` CLI and fail with
"Certificate and key not found" when the binary is not on PATH. Locally
the CLI is installed globally, on the runner it only exists in the venv.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@thomasht86
thomasht86 merged commit a9a80dc into main Sep 10, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant