Close Async Clients
Why Async Clients Must Be Closed
An AsyncAnthropic client keeps a pool of open network connections so that requests can reuse them (Lesson 72). Those connections hold resources: sockets, memory, and, with the aiohttp backend, an HTTP session object. Closing the client releases them.
If async clients are not closed:
| Problem | Symptom |
|---|---|
| Leaked connections | Resource usage grows in long-running services |
| Warnings at exit | Messages such as "Unclosed client session" (aiohttp backend) or unclosed-transport warnings |
| Errors at shutdown | Connections cleaned up after the event loop has closed can raise errors |
| Dirty tests | Each test leaves connections open, causing warnings and flaky behavior |
The rule is simple: the code that creates a client closes it, exactly once, when the client is no longer needed.
Three Ways to Close
1. async with (Recommended)
async with AsyncAnthropic() as client:
...
# Closed here, even if an exception occurred
async with calls the client's cleanup when the block exits, normally or because of an exception or cancellation.
2. await client.close()
When the client must live outside a single block, close it explicitly, in a finally block so it always runs:
client = AsyncAnthropic()
try:
await run_application(client)
finally:
await client.close()
close() is a coroutine on the async client, so it must be awaited. Calling client.close() without await creates a coroutine that never runs, and the client stays open.
3. Framework Lifecycle Hooks
In web frameworks, create the client at startup and close it at shutdown (Lesson 68):
@asynccontextmanager
async def lifespan(app):
app.state.claude = AsyncAnthropic()
yield
await app.state.claude.close()
Closing Streams vs. Closing Clients
These are two different levels:
| Closing a stream | Closing a client | |
|---|---|---|
| What it ends | One response | All connections of the client |
| When | When a response is complete or no longer needed (Lesson 60) | When the application, or component, is finished with Claude |
| How | Leave async with client.messages.stream(...) | Leave async with AsyncAnthropic(), or await client.close() |
| Frequency | Every request | Once per client lifetime |
Close streams often, after each response. Close the client rarely, at the end of its life. Closing the client after every request would throw away the connection pool and slow down every request.
Order of Shutdown
Close things in the reverse order of their dependencies:
1. Stop accepting new work
2. Let in-progress work finish (with a time limit), or cancel it
3. Wait for all tasks to finish
4. Close the Claude client
5. Close other resources (database connections, files)
Closing the client before tasks are done would make those tasks fail with connection errors in the middle of their work. Using the client inside async with, and awaiting all tasks inside that block, gets the order right automatically.
Closing Clients in Tests
Tests create clients too, and should close them. With pytest and the pytest-asyncio plugin, a fixture can create and close a client for each test:
import pytest_asyncio
from anthropic import AsyncAnthropic
@pytest_asyncio.fixture
async def claude():
async with AsyncAnthropic() as client:
yield client
The code after yield, here the end of the async with block, runs after each test that uses the fixture, so every test starts with a fresh client and leaves nothing open. Lessons 181–184 show how to test without real API calls.
Practical Example: A Worker Service with Graceful Shutdown
This long-running service processes incoming questions with async workers. When it receives a stop signal (Ctrl+C, or SIGTERM from a process manager such as systemd or Docker), it shuts down gracefully: it stops taking new work, lets in-progress work finish within a time limit, stops the workers, and closes the Claude client.
"""worker_service.py: a long-running async service with graceful shutdown."""
import asyncio
import itertools
import signal
import anthropic
from anthropic import AsyncAnthropic
WORKER_COUNT = 2
SHUTDOWN_GRACE_SECONDS = 30
SAMPLE_QUESTIONS = itertools.cycle([
"What is a Python iterator? One sentence.",
"What is type hinting? One sentence.",
"What is a context manager? One sentence.",
])
async def produce(queue: asyncio.Queue, stop: asyncio.Event) -> None:
"""Simulate incoming work: one new question every 2 seconds, until stopped."""
number = 0
while not stop.is_set():
number += 1
await queue.put((number, next(SAMPLE_QUESTIONS)))
await asyncio.sleep(2)
async def work(name: str, client: AsyncAnthropic, queue: asyncio.Queue) -> None:
while True:
number, question = await queue.get()
try:
message = await client.messages.create(
model="claude-haiku-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": question}],
)
text = "".join(b.text for b in message.content if b.type == "text").strip()
print(f"[{name}] #{number}: {text[:60]}")
except anthropic.APIError as error:
print(f"[{name}] #{number} failed: {type(error).__name__}")
finally:
queue.task_done()
async def main() -> None:
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except NotImplementedError:
pass # Windows: Ctrl+C raises KeyboardInterrupt instead (see below)
queue: asyncio.Queue = asyncio.Queue()
async with AsyncAnthropic() as client:
workers = [asyncio.create_task(work(f"worker-{n}", client, queue))
for n in range(1, WORKER_COUNT + 1)]
producer = asyncio.create_task(produce(queue, stop))
print("Service running. Press Ctrl+C to stop.")
try:
await stop.wait()
finally:
print("\nStopping: no new work accepted.")
producer.cancel()
try:
await asyncio.wait_for(queue.join(), timeout=SHUTDOWN_GRACE_SECONDS)
print("All in-progress work finished.")
except asyncio.TimeoutError:
print("Grace period over; cancelling remaining work.")
for task in workers:
task.cancel()
await asyncio.gather(producer, *workers, return_exceptions=True)
print("Claude client closed. Goodbye.")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Stopped.")
Example output:
Service running. Press Ctrl+C to stop.
[worker-1] #1: A Python iterator is an object that returns items one at a ti
[worker-2] #2: Type hinting adds optional type annotations to Python code to
[worker-1] #3: A context manager sets up and cleans up a resource around a b
^C
Stopping: no new work accepted.
All in-progress work finished.
Claude client closed. Goodbye.
How It Works
stopis anasyncio.Event, a flag that coroutines can wait for. The signal handlers registered withloop.add_signal_handler()set it when Ctrl+C (SIGINT) orSIGTERMarrives, instead of interrupting the program abruptly.produce()simulates incoming work. It stops adding questions as soon asstopis set.- Workers take questions from the queue and process them, as in Lesson 74.
task_done()infinallymarks each item as finished, even on errors. await stop.wait()keepsmain()waiting while the service runs. Thefinallyblock that follows performs the shutdown in the correct order:- stop new work by cancelling the producer,
- let current work finish with
queue.join(), limited to a grace period withwait_for(), - cancel the workers, which are otherwise waiting forever for new items, and wait for all tasks with
gather(..., return_exceptions=True).
- The client is closed last, when the
async with AsyncAnthropic()block ends, which happens only after every task has finished. No task can use a closed client. - On Windows,
add_signal_handler()is not available and raisesNotImplementedError, so Ctrl+C raisesKeyboardInterruptinstead.asyncio.run()then cancelsmain(). Thefinallyblock and theasync withstill run their cleanup, although the grace period may be cut short, and the outerexcept KeyboardInterruptprints a final message.
Common Mistakes
Calling close() Without await
What happens: the close coroutine never runs, and the client stays open. Python warns that the coroutine was never awaited.
Fix: await client.close(), or use async with.
Closing the Client While Tasks Still Use It
What happens: in-progress requests fail with connection errors during shutdown.
Fix: finish or cancel all tasks first, then close the client.
Closing the Client After Every Request
What happens: the connection pool is useless, and every request pays for a new connection.
Fix: close streams after each response, and the client once at the end.
Library Code Closing a Shared Client
What happens: other components that share the client fail.
Fix: only the code that created the client closes it (Lesson 72).
No Shutdown Plan for Services
What happens: stopping the service abandons requests midway, and leaves resources open.
Fix: handle stop signals, finish or cancel work within a grace period, then close resources.
Best Practices
- Prefer
async with AsyncAnthropic()so closing is automatic. - Use
await client.close()infinallywhenasync withis not possible. - Tie client lifetimes to application lifetimes in web frameworks.
- Shut down in order: stop new work, finish or cancel tasks, then close the client.
- Close streams per response, and clients once.
- Close clients in tests with fixtures.
Exercise
- Remove
async withfrom a small script, never close the client, and run it with the aiohttp backend (Lesson 72). What warnings appear? - Change
SHUTDOWN_GRACE_SECONDSto 1, press Ctrl+C while a request is in progress, and observe which message appears. - Rewrite
main()to create the client withclient = AsyncAnthropic()and close it withawait client.close()in afinallyblock, keeping the same shutdown order. - Explain why the
async with AsyncAnthropic()block inmain()must contain the whole shutdown sequence, rather than ending before it.
Step 3 (structure only):
client = AsyncAnthropic()
try:
workers = [...]
producer = ...
try:
await stop.wait()
finally:
... # Same shutdown steps: cancel producer, join queue, cancel workers, gather
finally:
await client.close()
The inner finally performs the shutdown of tasks; the outer finally closes the client afterward. The order is identical to the async with version.
Step 4: if the async with block ended before the shutdown sequence, the client would be closed while workers were still processing items during the grace period. Their requests would fail with connection errors. Keeping the whole shutdown inside the block guarantees that the client is closed only after every task has finished.