aio-libs/aiohttp · error · CleanupError
Multiple errors on cleanup stage
Error message
Multiple errors on cleanup stage
What it means
Raised as aiohttp.CleanupError (a RuntimeError subclass) when Application shutdown runs the registered cleanup contexts and two or more of them raise during their __aexit__. aiohttp collects every cleanup exception (instead of aborting on the first) so you see all failures, then wraps them into one CleanupError whose .exceptions property exposes the original list. Only when a single cleanup fails does aiohttp re-raise that exception as-is; the combined form is reserved for the multi-failure case.
Source
Thrown at aiohttp/web_app.py:447
if not isinstance(ctx, AbstractAsyncContextManager):
ctx = asynccontextmanager(cb)(app) # type: ignore[arg-type]
await ctx.__aenter__()
self._exits.append(ctx)
async def _on_cleanup(self, app: Application) -> None:
errors = []
for it in reversed(self._exits):
try:
await it.__aexit__(None, None, None)
except (Exception, asyncio.CancelledError) as exc:
errors.append(exc)
if errors:
if len(errors) == 1:
raise errors[0]
else:
raise CleanupError("Multiple errors on cleanup stage", errors)
View on GitHub (pinned to c0ef574e29)
Solutions
- Read the CleanupError.exceptions list (err.exceptions) in your top-level except to see each underlying cause, instead of only str(err) which just says 'Multiple errors on cleanup stage'.
- Make each cleanup context's __aexit__ defensive: log and swallow non-fatal errors, or wrap risky teardown in try/except so one failure cannot cascade into others.
- Order cleanup contexts so dependent resources shut down before their owners (cleanup_ctx runs teardown in reverse registration order).
- Reproduce shutdown in isolation with app.cleanup() in a test to identify which contexts actually throw.
Example fix
// before
async def main():
app = web.Application()
app.cleanup_ctx.append(db_ctx)
app.cleanup_ctx.append(cache_ctx)
runner = web.AppRunner(app)
await runner.setup()
...
await runner.cleanup() # raises CleanupError, originals lost
# after
try:
await runner.cleanup()
except CleanupError as err:
for exc in err.exceptions:
logger.error("cleanup failed: %r", exc)
raise Defensive patterns
Strategy: try-catch
Try / catch
try:
await runner.cleanup()
except CleanupError as err:
for exc in err.exceptions:
logger.error("cleanup failure: %r", exc)
# decide whether to re-raise the first underlying exception
raise err.exceptions[0] if len(err.exceptions) == 1 else err Prevention
- Make every cleanup context's __aexit__ defensive so a single failure cannot cascade.
- Log the .exceptions list rather than str(err) which only shows the static message.
- Register cleanup_ctx in dependency order; teardown runs reverse.
- Test app.cleanup() in isolation to surface latent teardown bugs.
When it happens
Trigger: Multiple async cleanup context managers (registered via app.cleanup_ctx.add(...) or via the cleanup_ctx list) whose __aexit__ methods raise during Application.cleanup(). Each registered CleanupContext that throws contributes one entry; if the count is > 1, CleanupError is raised from CleanupContext._on_cleanup at aiohttp/web_app.py:447.
Common situations: During app shutdown / test teardown with two independent background tasks (e.g. a database engine pool and a redis pool) that both fail to close, or when a cleanup handler raises because the resource it tracks was never successfully started. Also appears in test suites that shut down the app while the event loop is closing, surfacing latent bugs masked during normal startup.
Related errors
- Connector is closed.
- Connector is closed
- Site {site} is not registered in runner {self}
- Cannot write to closing transport
- Session is closed
AI-assisted analysis of aio-libs/aiohttp@c0ef574e29 (2026-08-04).
Data as JSON: /data/errors/7517e0be9de55f73.json.
Report an issue: GitHub.