Representative interview topic

Coding Interview: How Do You Manage Concurrent Failure with Python asyncio.TaskGroup?

CodingHard
Offer.cc Editorial TeamPublished Updated

Question

Use Python asyncio.TaskGroup to load user, order, and recommendation data concurrently. Cancel sibling tasks when one fails, retain diagnosable errors, and clean up after caller cancellation or timeout. Explain the difference from asyncio.gather and which work should not live in the request scope.

Prompt and context

An async API loads user, order, and recommendation data concurrently. A failure in a required task should cancel siblings, while a client disconnect or total timeout must not leave orphan coroutines. Design the implementation with asyncio.TaskGroup and explain exception aggregation, cancellation, and resource cleanup.

Python’s documentation describes TaskGroup as structured concurrency: tasks finish before the scope exits; a non-cancellation exception cancels the remaining tasks and is raised as an ExceptionGroup. The interview tests lifecycle reasoning rather than a pile of create_task() calls.

What the interviewer is assessing

Look for the parent-child task tree, sibling cancellation, CancelledError, ExceptionGroup, and async with joining. The candidate should pass cancellation into databases, HTTP, and files, and move work that must survive the response into a durable queue.

Clarifying questions

  • Are all three reads required, or may recommendations degrade?
  • Which tasks open connections, cursors, or temporary files?
  • Who creates the total timeout, and how does caller cancellation arrive?
  • Which errors need classification, retry, or a user-safe response?
  • Which jobs must continue after the HTTP response?

30-second answer

“Create three tasks inside async with TaskGroup() and wait for the scope to finish. A non-cancellation exception cancels siblings and raises an exception group; except* classifies known errors. Each task closes resources in finally. Wrap the group in one asyncio.timeout() or caller cancellation. Work that must survive the request goes to a durable queue.”

Step-by-step solution

Step 1: Define the task tree and result contract

Declare each result and its criticality. User and orders may be required while recommendations are optional; that choice determines whether one exception cancels the group. Every task belongs to the request scope.

python
async with asyncio.TaskGroup() as group:
    user_task = group.create_task(load_user(user_id))
    order_task = group.create_task(load_orders(user_id))
    rec_task = group.create_task(load_recommendations(user_id))

Leaving the scope joins the tasks. Reading an unfinished task outside the block is not a substitute for joining it.

Step 2: Handle failure with ExceptionGroup

A non-CancelledError exception cancels siblings, and TaskGroup raises an ExceptionGroup after all tasks finish. Use except* for expected dependency errors and preserve unknown errors for the outer handler.

python
try:
    async with asyncio.TaskGroup() as group:
        user = group.create_task(load_user(user_id))
        orders = group.create_task(load_orders(user_id))
except* RetryableDependencyError as errors:
    record_dependency_failures(errors.exceptions)
    raise ServiceUnavailable from errors

Do not catch BaseException and silently swallow cancellation.

Step 3: Push cancellation into real I/O

TaskGroup cancels Python tasks; HTTP, database, and file drivers need cancellation or timeout support. Each task closes connections, releases semaphores, removes temporary files, and stops consumption in finally.

python
async def load_orders(user_id: str):
    conn = await pool.acquire()
    try:
        return await conn.fetch("SELECT ...", user_id, timeout=1.5)
    finally:
        await pool.release(conn)

If a driver cannot interrupt a query, use a statement timeout, isolated connection, or bounded background job rather than relying only on task cancellation.

Step 4: Set one total timeout and distinguish external cancel

Wrap the entire group in asyncio.timeout() so all work shares one budget.

python
try:
    async with asyncio.timeout(2.0):
        result = await aggregate(user_id)
except TimeoutError:
    return degraded_response("deadline")
except asyncio.CancelledError:
    raise

External cancellation must continue upward, not become a successful response. Record timeout and user cancellation as different causes.

Step 5: Understand TaskGroup versus gather

asyncio.gather() normally propagates the first exception to its waiter, but sibling tasks are not necessarily cancelled; returning immediately can orphan them. TaskGroup binds task lifetime to a scope and cancels siblings on failure.

gather(return_exceptions=True) is useful for an explicit partial-failure contract, but every result must be inspected. It is not a substitute for structured cleanup, and a group must not create unbounded background tasks.

Step 6: Test failure order and cleanup

Inject user-first failure, recommendation-first failure, simultaneous failures, caller cancellation, total timeout, driver timeout, and a failing finally. Assert siblings receive cancellation, resources return, no tasks remain pending, and every exception can be tied to a child.

Log task name, request ID, duration, cancellation cause, exception type, and downstream call without private payloads. Repeat slow-I/O tests to expose races; all-success tests are insufficient.

Model answer

“I create three tasks inside TaskGroup, make user and orders required, and allow recommendations to degrade. The scope joins tasks; a non-cancellation failure cancels siblings and raises an ExceptionGroup, which except* classifies. Every I/O task passes a timeout and releases connections in finally.”

“An outer timeout supplies one shared budget and CancelledError propagates. Post-response work goes to a durable queue. I inject simultaneous failure, cancellation, timeout, and slow I/O and verify no orphan tasks or resource leaks.”

Common mistakes

  • Return after bare create_task orphan tasks → keep tasks in a TaskGroup scope.
  • Swallow CancelledError the parent cannot stop → clean up and re-raise.
  • Give each task a full timeout → total latency escapes → share one budget.
  • Treat ExceptionGroup as one error → parallel evidence is lost → classify with except*.
  • Cancel only Python tasks → database or HTTP work continues → use driver cancellation or timeouts.
  • Use gather for every case → partial failure and cleanup are ambiguous → define the degradation contract.

Follow-ups and responses

Follow-up 1: Does TaskGroup instantly stop underlying I/O?

No. The driver must support cancellation or a statement timeout; otherwise isolate the connection or move work to a bounded background job.

Follow-up 2: Why not cancel the group when recommendations fail?

Use the business contract. An optional recommendation can be caught and replaced with an empty result; security- or billing-critical work should let the exception escape and cancel siblings.

Follow-up 3: How do you map an ExceptionGroup to an API response?

Map known dependency errors to a safe 503 or degraded result and record child errors. Unknown errors go to the global handler; never return internal stacks to the client.

Follow-up 4: Which work belongs outside TaskGroup?

Email, indexing, and batch writes that must continue after the response should be persisted to a queue and retried by a worker. The request scope should contain only request-lifetime work.

Follow-up 5: What if finally raises during cancellation?

Keep cleanup small and observable, attach cleanup failures as context, and preserve the original cancellation or business exception so the root cause is not replaced.

Public sources

Related questions

Related interview tool

Use Screenshot for a coding prompt

Capture the problem, then work through the constraints, solution, code, edge cases, and complexity in order.

View the tool