# Timeout

> Give every remote call a deadline shorter than the caller's own budget, cancel the work when it expires, and surface the timeout as a distinct error.

- kind: pattern
- category: resilience
- aliases: Deadline
- canonical: https://aipatterns.dev/design-patterns/timeout
- inspired-by: https://learn.microsoft.com/en-us/azure/architecture/best-practices/transient-faults

## Intent

Every call that crosses a process boundary gets a maximum wait. When the deadline passes, the call fails with a distinct timeout error and the resources it held are released. The caller then decides: retry, fall back, or report. Without a deadline, a hung dependency converts your healthy process into a parked one.

## Forces

- **Unbounded waits** vs **finite capacity**: a worker blocked forever on a dead socket is a worker you have lost, and the queue behind it grows.
- **One hung dependency** vs **whole-system failure**: without deadlines, slowness propagates upstream until every caller of the slow one is stuck.
- **Callee's design** vs **caller's budget**: the dependency may be built for 30-second responses while your user journey allows 2.
- **Cancel** vs **abandon**: cancelling stops the downstream work; abandoning only stops waiting while the work keeps running and consuming.

## Therefore

**Set an explicit deadline on every remote or expensive call, shorter than the caller's own budget, cancel the underlying work when it expires, and raise a distinct timeout error.**

The deadline is a property of the caller, not of the dependency: each layer subtracts its own overhead from what it was given and passes the remainder down. A timeout is not a fix for slowness - it converts slow failure into fast failure so the rest of the system can react.

## Structure

```mermaid
classDiagram
  class QuoteSource {
    <<interface>>
    +quote(postcode, signal) Number
  }
  class RemoteQuotes {
    +quote(postcode, signal) Number
  }
  class BoundedQuotes {
    -Duration limit
    +quote(postcode, signal) Number
  }
  QuoteSource <|.. RemoteQuotes
  QuoteSource <|.. BoundedQuotes
  BoundedQuotes --> QuoteSource : inner
```

### Participants

- **Deadline holder** (`BoundedQuotes`): starts a timer per call, and cancels the call when it fires. A thin wrapper - the same shape as a [Decorator](https://aipatterns.dev/design-patterns/decorator.md).
- **Cancellable operation** (`QuoteSource`): every layer accepts a cancellation token (abort signal, context, interrupt flag) so the deadline can reach the actual I/O.
- **Dependency** (`RemoteQuotes`): does the work and honours the cancellation signal.
- **Caller**: receives `TimeoutError` as a type it can branch on.

## Use it when

- A call crosses a network, another process, or any resource with its own latency profile: HTTP, RPC, database, queue, file system.
- The caller has its own budget: a user is waiting, a request deadline exists, or a job has a `maxDuration`.
- You are about to add retries: each attempt needs a deadline, or one hung attempt hangs the whole retry loop.
- Code calls a library whose defaults are absent or generous (no socket timeout, 60-second HTTP client default).

## Do not use it when

- The call is in-process and cannot hang. A function that throws on bad input needs no deadline.
- The operation is legitimately long-running. Model it as an async job with status polling instead of a 30-second synchronous call you will inevitably cancel.
- The platform already propagates deadlines (gRPC deadline propagation, Go `context.Context`, .NET `CancellationToken`). Use the platform mechanism; do not build a parallel timer layer next to it.
- You are using a timeout to excuse a slow dependency. It stops the bleeding; capacity work or a [Fallback](https://aipatterns.dev/design-patterns/fallback.md) fixes the experience.
- Every caller would pick the same number and the client library exposes one cleanly. Configure the library's timeout option instead of wrapping each call site.

## Application grill

**Inspect first:** every remote call's deadline today (HTTP clients default to none or minutes - find each one); the platform's own limits (serverless execution caps, load balancer idle timeouts, database statement_timeout); the caller's budget chain, since every timeout must be shorter than its caller's remaining budget.

**Missing pieces:** none to buy: `AbortController`, context deadlines, and statement timeouts are platform features. The infrastructure work is alignment - setting database, proxy and load balancer timeouts to agree with the code's, so no layer quietly outlives another.

**Ask before you build:**

- **Scale:** what per-dependency latency budget fits the workload - base deadlines on measured p99, not hope?
- **Performance:** does cancellation propagate - when a parent times out, do child calls abort (context linking)?
- **Reliability:** on timeout, is the server-side work cancelled or orphaned (zombie requests keep spending)?
- **Budget:** a timeout times a retry multiplies worst-case latency - who owns the total budget per request?
- **Implementation:** is a timeout a distinct error type callers can classify, or an indistinguishable failure?

## Implementation steps

1. Inventory the remote calls in the path. Note which have explicit timeouts today; most have none or an OS default measured in minutes.
2. Derive each deadline from the caller's budget: if the request may take 2 s and makes three calls, none may take more than its share. Write the number down next to the call.
3. Use the platform's cancellation primitive - `AbortSignal`, `context.Context`, `CancellationToken`, `Future.get(timeout)` - so expiry cancels the work instead of merely abandoning it.
4. Raise a distinct error type (`TimeoutError`, `context.DeadlineExceeded`) so callers can distinguish "slow" from "broken" from "rejected".
5. Propagate remaining time downstream: pass the context or deadline through, so a dependency cannot outlive its caller's budget.
6. Test the timeout path with a fake dependency that sleeps past the deadline, and assert the caller recovers.

## Example

A checkout service asks a shipping API for delivery quotes. The API usually answers in 20 ms, but when its database stalls it answers in never - and without a deadline, checkout workers pile up behind it.

```ts title="checkout.ts"
// Distinct type so callers can branch on "slow" without string matching.
class TimeoutError extends Error {
  constructor(ms: number) {
    super(`no answer within ${ms} ms`);
    this.name = "TimeoutError";
  }
}

interface QuoteSource {
  quote(postcode: string, signal: AbortSignal): Promise<number>;
}

// The dependency: a shipping API that hangs when its database stalls.
class RemoteQuotes implements QuoteSource {
  quote(postcode: string, signal: AbortSignal): Promise<number> {
    const hang = postcode === "XXXXX";
    // Real client: fetch(url, { signal }). Fake: a timer that honours the signal.
    return new Promise((resolve, reject) => {
      const done = setTimeout(() => resolve(4.99), hang ? 10_000 : 20);
      signal.addEventListener(
        "abort",
        () => {
          clearTimeout(done);
          reject(signal.reason ?? new Error("cancelled"));
        },
        { once: true },
      );
    });
  }
}

// The deadline: each call gets its own abort controller with a timer.
function withTimeout(inner: QuoteSource, ms: number): QuoteSource {
  return {
    quote(postcode) {
      const controller = new AbortController();
      const timer = setTimeout(
        () => controller.abort(new TimeoutError(ms)),
        ms,
      );
      return inner
        .quote(postcode, controller.signal)
        .finally(() => clearTimeout(timer));
    },
  };
}

const quotes = withTimeout(new RemoteQuotes(), 500);

const fast = await quotes.quote("90210");
console.log(`quote: $${fast.toFixed(2)}`); // quote: $4.99

try {
  await quotes.quote("XXXXX"); // hangs; the deadline fires at 500 ms
} catch (e) {
  console.log(`${(e as Error).name}: ${(e as Error).message}`);
  // TimeoutError: no answer within 500 ms
}
```

```python title="checkout.py"
# Python 3.11+: asyncio.timeout cancels the work for real. On 3.10 use asyncio.wait_for.
import asyncio
from typing import Protocol

class RemoteQuotes:
    """A shipping API that hangs when its database stalls."""

    async def quote(self, postcode: str) -> float:
        hang = postcode == "XXXXX"
        await asyncio.sleep(10 if hang else 0.02)
        return 4.99

def with_timeout(inner: Protocol, seconds: float):
    """Bound every call; the deadline cancels the inner work on expiry."""

    class Bounded:
        async def quote(self, postcode: str) -> float:
            async with asyncio.timeout(seconds):
                return await inner.quote(postcode)

    return Bounded()

async def main() -> None:
    quotes = with_timeout(RemoteQuotes(), 0.5)

    fast = await quotes.quote("90210")
    print(f"quote: ${fast:.2f}")  # quote: $4.99

    try:
        await quotes.quote("XXXXX")  # hangs; the inner coroutine is cancelled at 0.5 s
    except TimeoutError:  # asyncio.TimeoutError; TimeoutError in 3.11+
        print("gave up: shipping quote timed out")

asyncio.run(main())
```

```go title="checkout.go"
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type QuoteSource interface {
	Quote(ctx context.Context, postcode string) (float64, error)
}

// RemoteQuotes is a shipping API that hangs when its database stalls.
type RemoteQuotes struct{}

func (RemoteQuotes) Quote(ctx context.Context, postcode string) (float64, error) {
	delay := 20 * time.Millisecond
	if postcode == "XXXXX" {
		delay = 10 * time.Second
	}
	select {
	case <-time.After(delay):
		return 4.99, nil
	case <-ctx.Done(): // the deadline fired: stop the work
		return 0, ctx.Err()
	}
}

// BoundedQuotes enforces a per-call deadline. ctx carries it down, so the
// dependency's work is cancelled, not abandoned.
type BoundedQuotes struct {
	inner QuoteSource
	limit time.Duration
}

func (b BoundedQuotes) Quote(ctx context.Context, postcode string) (float64, error) {
	ctx, cancel := context.WithTimeout(ctx, b.limit)
	defer cancel()
	return b.inner.Quote(ctx, postcode)
}

func main() {
	quotes := BoundedQuotes{inner: RemoteQuotes{}, limit: 500 * time.Millisecond}

	if q, err := quotes.Quote(context.Background(), "90210"); err != nil {
		fmt.Println("err:", err)
	} else {
		fmt.Printf("quote: $%.2f\n", q) // quote: $4.99
	}
	if _, err := quotes.Quote(context.Background(), "XXXXX"); errors.Is(err, context.DeadlineExceeded) {
		fmt.Println("gave up:", err) // gave up: context deadline exceeded
	}
}
```

```java title="Checkout.java"
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class Checkout {
    interface QuoteSource {
        double quote(String postcode) throws Exception;
    }

    /** A shipping API that hangs when its database stalls. */
    static final class RemoteQuotes implements QuoteSource {
        public double quote(String postcode) throws Exception {
            Thread.sleep("XXXXX".equals(postcode) ? 10_000 : 20);
            return 4.99;
        }
    }

    static final class QuoteTimeoutException extends RuntimeException {
        QuoteTimeoutException(long ms) {
            super("no answer within " + ms + " ms");
        }
    }

    /** Bounds each call and interrupts the worker when the deadline passes. */
    record BoundedQuotes(QuoteSource inner, ExecutorService pool, long ms)
            implements QuoteSource {
        public double quote(String postcode) throws Exception {
            Future<Double> job = pool.submit(() -> inner.quote(postcode));
            try {
                return job.get(ms, TimeUnit.MILLISECONDS);
            } catch (TimeoutException e) {
                job.cancel(true); // interrupt the worker; do not abandon it
                throw new QuoteTimeoutException(ms);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newCachedThreadPool();
        QuoteSource quotes = new BoundedQuotes(new RemoteQuotes(), pool, 500);
        try {
            System.out.printf("quote: $%.2f%n", quotes.quote("90210")); // quote: $4.99
            quotes.quote("XXXXX");
        } catch (QuoteTimeoutException e) {
            System.out.println("gave up: " + e.getMessage());
        } finally {
            pool.shutdownNow();
        }
    }
}
```

Go needs no wrapper at all when callers create their own contexts: `context.WithTimeout` at the call site is the pattern. The wrapper earns its place when you want one number to apply to every caller of a client.

## Trade-offs

**Gains**
- Latency becomes bounded and predictable; capacity planning gets possible.
- Hung dependencies release workers instead of accumulating them.
- Slow and broken become distinguishable errors.

**Costs**
- A call that would have succeeded at 501 ms is cancelled at 500 ms and its work is wasted.
- Too-low deadlines convert a slow dependency into a failing one.
- Cancellation is per language and per client; where the primitive is weak (threads that ignore interrupts), you abandon instead of cancel and the work still runs.

## Relations

- [Retry](https://aipatterns.dev/design-patterns/retry.md) repeats the operation - but every attempt must carry its own timeout, or one hung attempt hangs the loop.
- [Circuit Breaker](https://aipatterns.dev/design-patterns/circuit-breaker.md) counts failures to decide when to stop calling; timeouts must be classified as failures or hangs never trip it.
- [Exponential Backoff](https://aipatterns.dev/design-patterns/exponential-backoff.md) spaces re-attempts; the timeout bounds each attempt within the schedule.
- [Fallback](https://aipatterns.dev/design-patterns/fallback.md) is what the caller serves when the deadline expires with no answer.
- [Decorator](https://aipatterns.dev/design-patterns/decorator.md) is the shape the wrapper takes: same interface, added behaviour, composed at the call site.

## Agent checklist

- [ ] Every remote call in the changed path has an explicit, written-down deadline.
- [ ] The deadline is shorter than the caller's own budget, including downstream calls it triggers.
- [ ] Expiry cancels the underlying work through the platform primitive, not just the wait.
- [ ] Timeout is a distinct error type callers branch on, not a generic `Error`.
- [ ] The timeout path has a test with a dependency that sleeps past the deadline.
