11import asyncio
22import re
33from abc import ABC , abstractmethod
4- from typing import Any , Dict , Literal , Tuple , cast
4+ from typing import Any , Literal , Tuple , cast
55
66import httpx
77from dag_cbor .ipld import IPLDKind
@@ -210,27 +210,43 @@ def __init__(
210210 self .gateway_base_url : str = gateway_base_url
211211 """@private"""
212212
213- self ._client_per_loop : Dict [asyncio .AbstractEventLoop , httpx .AsyncClient ] = {}
214-
215213 if client is not None :
216- # user supplied → bind it to *their* current loop
217- self ._client_per_loop [ asyncio . get_running_loop ()] = client
218- self ._owns_client : bool = False
214+ # A client was supplied by the user. We don't own it.
215+ self ._owns_client = False
216+ self ._client_per_loop = { asyncio . get_running_loop (): client }
219217 else :
220- self ._owns_client = True # we'll create clients lazily
218+ # No client supplied. We will own any clients we create.
219+ self ._owns_client = True
220+ self ._client_per_loop = {}
221+
222+ # The instance is never closed on initialization.
223+ self ._closed = False
221224
222225 # store for later use by _loop_client()
223226 self ._default_headers = headers
224227 self ._default_auth = auth
225228
226229 self ._sem : asyncio .Semaphore = asyncio .Semaphore (concurrency )
227- self ._closed : bool = False
228230
229231 # --------------------------------------------------------------------- #
230232 # helper: get or create the client bound to the current running loop #
231233 # --------------------------------------------------------------------- #
232234 def _loop_client (self ) -> httpx .AsyncClient :
233- """Get or create a client for the current event loop."""
235+ """Get or create a client for the current event loop.
236+
237+ If the instance was previously closed but owns its clients, a fresh
238+ client mapping is lazily created on demand. Users that supplied their
239+ own ``httpx.AsyncClient`` still receive an error when the instance has
240+ been closed, as we cannot safely recreate their client.
241+ """
242+ if self ._closed :
243+ if not self ._owns_client :
244+ raise RuntimeError ("KuboCAS is closed; create a new instance" )
245+ # We previously closed all internally-owned clients. Reset the
246+ # state so that new clients can be created lazily.
247+ self ._closed = False
248+ self ._client_per_loop = {}
249+
234250 loop : asyncio .AbstractEventLoop = asyncio .get_running_loop ()
235251 try :
236252 return self ._client_per_loop [loop ]
@@ -241,7 +257,7 @@ def _loop_client(self) -> httpx.AsyncClient:
241257 headers = self ._default_headers ,
242258 auth = self ._default_auth ,
243259 limits = httpx .Limits (max_connections = 64 , max_keepalive_connections = 32 ),
244- # Uncomment when they finally support Robost HTTP/2 GOAWAY responses
260+ # Uncomment when they finally support Robust HTTP/2 GOAWAY responses
245261 # http2=True,
246262 )
247263 self ._client_per_loop [loop ] = client
@@ -251,18 +267,20 @@ def _loop_client(self) -> httpx.AsyncClient:
251267 # graceful shutdown: close **all** clients we own #
252268 # --------------------------------------------------------------------- #
253269 async def aclose (self ) -> None :
254- """Close all internally-created clients."""
255- if not self ._owns_client :
256- # User supplied the client; they are responsible for closing it.
270+ """
271+ Closes all internally-created clients. Must be called from an async context.
272+ """
273+ if self ._owns_client is False : # external client → caller closes
257274 return
258275
276+ # This method is async, so we can reliably await the async close method.
277+ # The complex sync/async logic is handled by __del__.
259278 for client in list (self ._client_per_loop .values ()):
260279 if not client .is_closed :
261280 try :
262281 await client .aclose ()
263282 except Exception :
264- # Best-effort cleanup; ignore errors during shutdown
265- pass
283+ pass # best-effort cleanup
266284
267285 self ._client_per_loop .clear ()
268286 self ._closed = True
@@ -277,23 +295,44 @@ async def __aexit__(self, *exc: Any) -> None:
277295
278296 def __del__ (self ) -> None :
279297 """Best-effort close for internally-created clients."""
298+ if not hasattr (self , "_owns_client" ) or not hasattr (self , "_closed" ):
299+ return
300+
280301 if not self ._owns_client or self ._closed :
281302 return
282303
283304 # Attempt proper cleanup if possible
284305 try :
285306 loop = asyncio .get_running_loop ()
286307 except RuntimeError :
287- loop = None
308+ # No running loop - can't do async cleanup
309+ # Just clear the client references synchronously
310+ if hasattr (self , "_client_per_loop" ):
311+ # We can't await client.aclose() without a loop,
312+ # so just clear the references
313+ self ._client_per_loop .clear ()
314+ self ._closed = True
315+ return
288316
317+ # If we get here, we have a running loop
289318 try :
290- if loop is None or not loop .is_running ():
291- asyncio .run (self .aclose ())
292- else :
319+ if loop .is_running ():
320+ # Schedule cleanup in the existing loop
293321 loop .create_task (self .aclose ())
322+ else :
323+ # Loop exists but not running - try asyncio.run
324+ coro = self .aclose () # Create the coroutine
325+ try :
326+ asyncio .run (coro )
327+ except Exception :
328+ # If asyncio.run fails, we need to close the coroutine properly
329+ coro .close () # This prevents the RuntimeWarning
330+ raise # Re-raise to hit the outer except block
294331 except Exception :
295- # Suppress all errors during interpreter shutdown or loop teardown
296- pass
332+ # If all else fails, just clear references
333+ if hasattr (self , "_client_per_loop" ):
334+ self ._client_per_loop .clear ()
335+ self ._closed = True
297336
298337 # --------------------------------------------------------------------- #
299338 # save() – now uses the per-loop client #
0 commit comments