> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Python gather Failed. Why Are the Other Tasks Still Running?
- URL: https://debugly.dev/python-gather-taskgroup-partial-results/
- Published: 2026-11-04T02:46:00.000Z
- Updated: 2026-11-04T02:46:00.000Z
- Description: An asyncio exception can reach the caller while sibling tasks keep working. Separate task lifetime, expected provider failures and cancellation before choosing gather or TaskGroup.
- Author: Rohit Bhadani
- Tags: Python, Concurrency, Debugging, Backend

**Editorial disclosure:** This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The scenarios and code are illustrative and have not been executed for this article. See the [editorial policy](https://debugly.dev/editorial-policy/).

One API call fails. Your Python handler catches the exception. Meanwhile, the other calls are still busy doing exactly what you asked them to do.

That can look like a cancellation bug. With default `asyncio.gather()`, it can simply be the contract: the first exception reaches the caller while sibling awaitables continue. **Use `TaskGroup` when related work needs a shared lifetime; handle expected per-call failures inside each child when you want partial results.** Neither choice rolls back work a remote service has already accepted.

The examples below use APIs available since Python 3.11\. The linked Python 3.14 documentation is the reference for the behavior discussed here; this isn't a guide to newer, version-specific task-group APIs.

## The error arrived. The work didn't leave.

Picture a request that asks three providers for independent pieces of information. One fails quickly. You catch its error and return a fallback. A sibling completes later and writes to a cache, uses a connection, or records a billable response.

The important clue isn't that an exception was caught. It's which object became complete at that moment. Python's [gather documentation](https://docs.python.org/3.14/library/asyncio-task.html?ref=debugly.dev#running-tasks-concurrently) distinguishes the aggregate's first exception from its children's completion. Cancelling that aggregate after it has already propagated the error doesn't retroactively cancel its siblings.

So this illustrative pattern has the wrong cleanup assumption:

```
batch = asyncio.gather(fetch_a(), fetch_b(), fetch_c())
try:
    return await batch
except ProviderError:
    batch.cancel()  # May already be done after propagating the error.
    raise
```

`ProviderError` here stands for the actual exception your client uses. The comment is the problem: a completed aggregate isn't a remote control for the work it used to represent. Calling `cancel()` with confidence doesn't make it more persuasive.

Before changing primitives, write down what the request owns. Which children must finish before its connections close? Which results are optional? Which errors mean the entire operation is broken? Those answers are more useful than a blanket rule that one concurrency API is always better.

## Shared lifetime and partial success are different axes

A [TaskGroup](https://docs.python.org/3.11/library/asyncio-task.html?ref=debugly.dev#task-groups) waits for its registered children when the context exits. A non-cancellation failure in a child triggers cancellation of its remaining siblings, followed by waiting for them. If failures remain, they are reported together rather than discarded behind the first one.

That gives related operations an owner. It doesn't require every unsuccessful provider response to escape as an exception.

A September 9–13, 2026 [Python community discussion](https://discuss.python.org/t/best-practices-for-handling-concurrent-async-calls-to-multiple-llm-apis-asyncio-gather-vs-taskgroup/108966?ref=debugly.dev) asks exactly this for concurrent LLM calls: can a task group fit a request that needs partial results and individual timeouts? The useful design question is where an expected failure becomes a normal result. The thread is evidence of that question, not a benchmark or a community consensus.

| Desired behavior                                              | Design to consider                                                      | What still needs care                                        |
| ------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------ |
| Wait for independent results, including failures              | gather(return\_exceptions=True)                                         | Inspect every result; cancellation is a distinct outcome.    |
| Related children share the request's lifetime                 | TaskGroup                                                               | Unexpected failures cancel siblings; cleanup must cooperate. |
| Shared lifetime, but an expected provider failure is optional | Catch that specific failure inside the child and return a typed outcome | Don't turn programming errors into empty data.               |
| Work must outlive this request or process                     | A separately owned durable job                                          | Persistence, retries and idempotency need their own design.  |

## Keep expected failures inside their boundary

Here's an illustrative adapter for independent, read-only provider calls. It gives each call a timeout and converts only declared recoverable failures into data. Everything else can still fail the group.

```
import asyncio
from dataclasses import dataclass
from typing import Any, Awaitable, Callable

class ProviderUnavailable(Exception):
    pass  # Map your client's documented recoverable errors to this.

@dataclass(frozen=True)
class Outcome:
    provider: str
    value: Any = None
    error: str | None = None

async def read_one(
    provider: str,
    call: Callable[[], Awaitable[Any]],
    seconds: float,
) -> Outcome:
    try:
        async with asyncio.timeout(seconds):
            value = await call()
        return Outcome(provider, value=value)
    except TimeoutError:
        return Outcome(provider, error="timeout")
    except ProviderUnavailable:
        return Outcome(provider, error="unavailable")

async def collect(calls):
    async with asyncio.TaskGroup() as group:
        tasks = [
            group.create_task(read_one(name, call, 3.0), name=name)
            for name, call in calls
        ]
    return [task.result() for task in tasks]
```

`calls` is an iterable of provider names and zero-argument async call factories. Passing a factory lets the adapter create the awaitable inside its timeout boundary. It doesn't provide a concurrency limit: for a large or unbounded input, add bounded admission rather than creating one task per record.

The timeout handler sits outside the `async with`. As the [timeout documentation](https://docs.python.org/3.11/library/asyncio-task.html?ref=debugly.dev#timeouts) explains, the context translates its cancellation into `TimeoutError` when leaving the context. Don't rely on catching that translated exception inside the block that hasn't exited yet.

This wrapper also catches a `TimeoutError` raised by the client itself. If you need to distinguish a client timeout from this budget expiring, retain the timeout context object and inspect its `expired()` state, and preserve the client's error classification. A single timeout label is a deliberate simplification here, not proof of which clock fired.

Keep authentication failures, malformed responses and rate limits distinct when they call for different action. A missing credential shouldn't quietly turn your entire dashboard into “no data.” Catching `Exception` everywhere is a surprisingly effective way to make a broken integration look peaceful.

## Don't convert cancellation into a provider result

[CancelledError](https://docs.python.org/3.14/library/asyncio-exceptions.html?ref=debugly.dev#asyncio.CancelledError) inherits from `BaseException`, not `Exception`. If you intercept it for cleanup, normally re-raise it. The adapter above doesn't catch it.

That matters when a user disconnects, a request deadline expires, or an unexpected sibling error ends the group. “This provider was unavailable” is application data. “The operation owning this work is going away” is a lifetime signal. Treating both as an empty result loses the distinction your cleanup depends on.

Also inspect what the child actually awaits. A synchronous blocking call can stop the event loop from delivering cancellation promptly. Debugly's [asyncio blocking-call investigation](https://debugly.dev/python-asyncio-deadlock-blocking-calls/) covers that separate cause; changing the container around the tasks doesn't make blocking code cooperative.

## A group of errors needs more than the first traceback

Unexpected errors can surface as an exception group. [PEP 654](https://peps.python.org/pep-0654/?ref=debugly.dev) explains why concurrent failures need a structure that preserves separate exceptions, and why `except*` matches selected types within that structure.

When instrumenting the caller, keep provider/task identity and the leaf exceptions, rather than logging only the group's summary message. Handle only the types you can actually recover from. A broad handler that logs “batch failed” and returns an empty list defeats the distinction between expected partial failure and a programming fault.

If the group exits exceptionally, don't blindly read every task's `result()` afterward: some children may be cancelled or failed. The successful return path in the adapter is reached only after a normal group exit.

## Test the lifetime, not just the returned list

For a development check, have one fake provider fail in a controlled way while another waits on an event. Verify that an expected failure becomes an `Outcome` and the other result remains available. Then replace the expected error with a programming error and verify the sibling enters cleanup before the enclosing operation finishes.

Separately cancel the parent. Assert that cancellation propagates instead of producing a normal “unavailable” result, and that the shared client isn't closed while its registered children still use it. These are proposed checks, not claimed results from a test run.

Finally, test the remote side-effect boundary. A local cancelled task doesn't prove a provider stopped processing, a charge was avoided, or a write was undone. For mutations, use the service's documented idempotency and reconciliation behavior; don't retry simply because the local task didn't return a value.

Put the ownership rule beside the code: expected per-provider failures become explicit outcomes; unexpected faults end related work; cancellation stays cancellation. That sentence gives the next reviewer something concrete to check before a green result list distracts everyone.

More investigations: [Python debugging](https://debugly.dev/tag/python/). Author background: [Rohit Bhadani](https://debugly.dev/about/).