[Pitch] `withTimeout` method

Hey everyone,

I have drafted up a pitch proposal for swift-async-algorithms to introduce a new withTimeout method.

You can find the whole pitch here

Introduction

This proposal introduces withTimeout, a function that executes an asynchronous operation with a specified time limit. If the operation completes before the timeout expires, the function returns the result; if the timeout expires first, the operation is cancelled and a TimeoutError is thrown once the operation completed.

Motivation

Asynchronous operations in Swift can run indefinitely, which creates several problems in real-world applications:

  1. Network operations may hang when servers become unresponsive, consuming resources and degrading user experience.
  2. Server-side applications need predictable request handling times to maintain service level agreements and prevent resource exhaustion.
  3. Batch processing requires mechanisms to prevent individual tasks from blocking entire workflows.
  4. Resource management becomes difficult when operations lack time bounds, leading to connection pool exhaustion and memory leaks.

Currently, developers must implement timeout logic manually using task groups and clock sleep operations, resulting in verbose, error-prone code that's difficult to compose with surrounding async contexts. Each implementation must carefully handle cancellation, error propagation, and race conditions between the operation and timer.

Proposed solution

This proposal introduces withTimeout, a function that executes an asynchronous operation with a time limit. The solution provides a clean, composable API that handles cancellation and error propagation automatically:

do {
    let result = try await withTimeout(in: .seconds(5)) {
        try await fetchDataFromServer()
    }
    print("Data received: \(result)")
} catch let error as TimeoutError<NetworkError> {
    print("Request timed out: \(error.underlying)")
}

The solution is safer than manual implementations because it handles all race conditions between the operation and timeout timer, ensures proper cleanup through structured concurrency, and provides clear semantics for cancellation behavior.

Detailed design

TimeoutError

/// An error that wraps an underlying error when an operation times out.
public struct TimeoutError<UnderylingError: Error>: Error {
  /// The error thrown by the timed-out operation.
  public var underlying: UnderylingError

  /// Creates a timeout error with the specified underlying error.
  ///
  /// - Parameter underlying: The error thrown by the operation that timed out.
  public init(underlying: UnderylingError) {
    self.underlying = underlying
  }
}

TimeoutError wraps the original error type thrown by the timed-out operation, preserving type information for error handling. This allows callers to access the underlying error while clearly indicating that a timeout occurred.

withTimeout Function

/// Executes an asynchronous operation with a specified timeout duration.
///
/// Use this function to limit the execution time of an asynchronous operation. If the operation
/// completes before the timeout expires, this function returns the result. If the timeout expires
/// first, this function cancels the operation and throws a ``TimeoutError``.
///
/// The following example demonstrates using a timeout to limit a network request:
///
/// ```swift
/// do {
///     let result = try await withTimeout(in: .seconds(5)) {
///         try await fetchDataFromServer()
///     }
///     print("Data received: \(result)")
/// } catch let error as TimeoutError<NetworkError> {
///     print("Request timed out: \(error.underlying)")
/// }
/// ```
///
/// - Important: This function cancels the operation when the timeout expires, but waits for the operation
/// to return. The function may run longer than the specified timeout duration if the operation doesn't respond
/// to cancellation immediately.
///
/// - Parameters:
///   - timeout: The maximum duration to wait for the operation to complete.
///   - tolerance: The tolerance used for the sleep.
///   - clock: The clock to use for measuring time. The default is `ContinuousClock()`.
///   - body: The asynchronous operation to execute within the timeout period.
///
/// - Returns: The result of the operation if it completes before the timeout expires.
///
/// - Throws: A ``TimeoutError`` containing the underlying error if the operation throws or times out.
nonisolated(nonsending) public func withTimeout<Return, Failure: Error, Clock: _Concurrency.Clock>(
  in timeout: Clock.Instant.Duration,
  tolerance: Clock.Instant.Duration? = nil,
  clock: Clock = .continuous,
  body: nonisolated(nonsending) () async throws(Failure) -> Return
) async throws(TimeoutError<Failure>) -> Return {

Non-escaping nonisolated(nonsending) operation closure

Many existing withTimeout implementations require a @Sendable and @escaping closure which makes it hard to compose in isolated context and use non-Sendable types. This design ensures that the closure is both non-escaping and nonisolated(nonsending) for composability:

actor DataProcessor {
    var cache: [String: Data] = [:]
    
    func fetchWithTimeout(url: String) async throws {
        // The closure can access actor-isolated state because it's nonisolated(nonsending)
        let data = try await withTimeout(in: .seconds(5)) {
            if let cached = cache[url] {
                return cached
            }
            return try await URLSession.shared.data(from: URL(string: url)!)
        }
        cache[url] = data
    }
}

If the closure were @Sendable, it couldn't access actor-isolated state like cache. The nonisolated(nonsending) annotation allows the closure to compose with surrounding code regardless of isolation context, while maintaining safety guarantees.

Implementation Details

The implementation uses structured concurrency with task groups to race the operation against a timeout timer:

  1. Two tasks are created: one executes the operation, the other sleeps for the timeout duration
  2. The first task to complete determines the result
  3. When either task completes, cancelAll() cancels the other task
  4. If the timeout expires first, the operation is cancelled but the function waits for it to return
  5. The function handles both the operation's result and any errors thrown

Important behavioral note: The function cancels the operation when the timeout expires, but waits for the operation to return. This means withTimeout may run longer than the specified timeout duration if the operation doesn't respond to cancellation immediately. This design ensures proper cleanup and prevents resource leaks from abandoned tasks.

Effect on API resilience

This is an additive API and no existing systems are changed, however it will introduce a few new types that will need to be maintained as ABI interfaces.

Alternatives considered

@Sendable and @escaping Closure

An earlier design considered using @Sendable and @escaping for the closure parameter:

public func withTimeout<Return: Sendable, Failure: Error, Clock: _Concurrency.Clock>(
  in timeout: Clock.Duration,
  clock: Clock = ContinuousClock(),
  body: @Sendable @escaping () async throws(Failure) -> Return
) async throws(TimeoutError<Failure>) -> Return

Looking forward to feedback and questions!

Franz

25 Likes

A big +1, I've had to copy-paste a similar implementation in too many projects at this point.

One possible enhancement: can we add a withDeadline flavor, and withTimeout can just call through to withDeadline? Sometimes you have the deadline time instance rather than the duration, so it'd be good not to have to do the calculation and risk unpredictable drift.

6 Likes

I think this is a good idea, everyone ends up needing this at some point, and it's nice to have it carefully done in a commonly-used package.

The caveat that just about everyone misses when they first see this kind of function, is that if the body doesn't respond promptly to cancellation, withTimeout doesn't return promptly after the desired duration. This is correct, and necessary, but not intuitive to most people.

Particular cases where it can be dangerous are ObjC completionHandler methods that've been imported as async, and situations where people have used withCheckedContinuation without withTaskCancellationHandler.

2 Likes

I think that makes total sense and adding a withDeadline method that takes an Clock.Instant instead of a Clock.Instant.Duration is easy to add.

Yes, that's right. This is due to how structured concurrency works and that there is no way to "force" async code to return right away. It needs to run until its natural end. I tried to call this out in the docs of the method explicitly:

/// - Important: This function cancels the operation when the timeout expires, but waits for the operation
/// to return. The function may run longer than the specified timeout duration if the operation doesn't respond
/// to cancellation immediately.
2 Likes

This is really nice — nonsending & nonescaping make this API a pleasure to use

First of all, love to see this pitched. Having this at a more central and official place is something I appreciate and I will be able to sunset my version of this algorithm. I added some comments in the PR, although I think my second comment I will repeat here:


I think the Duration variant of withTimeout could take any Clock<Duration> rather than some clock.

Imagine this model:

class Model {

  func doSomething() async throws {
    try await withTimeout(in: .seconds(5)) { ... }
  }
}

If you were to unit test doSomething, you would normally try to inject a custom clock, to not make the unit test wait for 5 seconds.

Either with some sort of dependency injection, or with simply passing it along the initializer you could do this:

class Model {

  let clock: any Clock<Duration>

  func doSomething() async throws {
    try await withTimeout(in: .seconds(5), clock: clock) { ... }
  }
}

At call site:

let model1 = Model(clock: ContinuousClock())
let model2 = Model(clock: TestClock())

When using some clock, you would have to make Model generic over the clock used for timeouts, which is inconvenient. Kind of the same motivation as in SE-0374.


The implementation assumes a clock only ever throws when cancelled, is this okay to assume so? I previously had my implementation explicitly not as typed throws because of that.


+1

1 Like

I have referred to these as timeouts and used withThrowingTimeout for my implementations but another possibility is using withCancellation to express that the task running the closure is cancelled and the regular cooperative cancellation rules apply.

I replied in the PR to this but adding here as well. I don't think taking any Clock<Duration> is necessary to make your dependency injection work. You can do this already by opening the existential by passing the any Clock<Duration> to a some Clock<Duration> like this:

@Test(arguments: [ContinuousClock()])
func dependencyInjection(clock: any Clock<Duration>) async throws {
  try await self.concreteClock(clock: clock)
}

private func concreteClock(clock: some Clock<Duration>) async throws {
  try await withTimeout(
    in: .seconds(10),
    clock: clock
  ) {
    try await Task.sleep(for: .milliseconds(10))
  }
}

It's a fair question. Right now I think every clock implementation adheres to this but nothing forces it. Sadly, we can't change the Clock protocol without breaking ABI AFAIK.

Sorry about the noise.

Ah, forgot this works. Thanks.

Obvious +1 and yes please for me. I've got one of those in every single project I've ever done with Swift Concurrency.

3 Likes

looks very good overall, a very welcome addition!

@FranzBusch
Could you explain a bit how the various combinations of errors/returns are expressed?

I guess it can be:

  • returns, no timeout
  • returns, but canceled by timeout
  • throws, no timeout
  • throws, canceled by timeout

Does it indicate this? How would "just threw and error" look like?

I think I see what you are getting at. The method right now always throws a TimeoutError even in the case when no timeout occurred and the operation just threw. I think TimeoutError needs a slight adjustment so that it indicates what threw the error, something like this:

enum TimeoutError<OperationError: Error>: Error {
  case timedOut(OperationError)
  case operationFailed(OperationError)
}

Which would mean the method behaves like this:

  1. (no-timeout, operation returns X) -> return X
  2. (no-timeout, operation throws E) -> throw TimeoutError.operationFailed(E)
  3. (timeout cancels operation, operation returns X) -> return X
  4. (timeout cancels operation, operation throws E) -> throw TimeoutError.timedOut(E)
4 Likes

So is there no way to know whether the operation timed out if the callback provided isn't throwing?

Correct. This proposal treats an operation that returns successfully always as successful, even in the case where it returned after the timeout triggered. In my opinion, this is the only right thing to do since we can't generically say much about the return value itself. It might be that the operation noticed the cancellation, saved off the state, and returned a partial successful state. Generally speaking, I think almost any async method should also be throwing, especially if that method wants to indicate that it exited early due to cancellation.

2 Likes

People who have read my posts in the past know me as the person who says "not everything needs to go in the standard library, we should take advantage of Swift's rich package ecosystem."

...so here I am doing a 180. I'm curious, why is this being pitched for inclusion in swift-async-algorithms instead of the _Concurrency module? At a glance, most of the APIs in swift-async-algorithms deal with sequences and values at a higher level, whereas this feels like one of those small-but-impactful fundamental building blocks that deserves inclusion in the standard library. But I suppose the counterargument to that would be that this can already be built with the fundamental building blocks in _Concurrency; i.e., it requires no new APIs that talk to the runtime?

Either way, I think it's a great addition, but I'm interested in the general thought process of what goes where.

12 Likes

It's a good question. My motivation for pitching this to swift-async-algorithms is twofold:

  1. It makes it available more broadly since it isn't tied to a specific Swift version or platform availability.
  2. While it is possible to implement this entirely with a public API, an implementation inside the _Concurrency module might want to tie this even closer with executors to make this even more performant. Such an implementation is more complex, and there are many open questions how that would work exactly. I think, however, we shouldn't hold back the entire ecosystem with having a perfect solution in the _Concurrency module and instead providing something in the package ecosystem until then. Similar to how swift-collections provides useful data structures outside the stdlib.

Whether swift-async-algorithms should contain more than just algorithms outside of AsyncSequences is a good question. In my personal opinion, yes, because nothing in the package name indicates a connection to AsyncSequence, and additional packages always bring an additional maintenance overhead. That's just my opinion though and @Philippe_Hausler most likely has thoughts on this as well.

4 Likes

So the algorithms housed there are not per-se strictly relegated to just async sequence; the original intent was to introduce things like this type of algorithm here (hence the name). However the catch is that most of the non-async-sequence APIs that would be introduced need runtime level support.

Also there is the question of: is any of the proposed concepts possibly belonging in _Concurrency itself? e.g. should you be required to type out import to use it?

To the pitch itself: timeouts are inherently non-composable. Consider for example a HTTP request - how does one compose the timeout for a dns resolution (which rightfully should be async and have a temporal concept of failure) and the temporal concept of failure for writing out data, and the temporal concept of getting a complete message back? Instead the composable version is for a deadline to be passed instead - this permits an instant to be passed when the whole thing is due by. Sub items that participate in the temporal concept of failure can choose to a) use the deadline as is, b) minimum with some other value they have, c) not participate. The key option there is the determinate of a minimum. @ktoso likely has deeper thoughts on this since we have spoken about this at length and I am just paraphrasing his logic (which I found quite compelling in the past).

The other nicety of deadlines is that you can not only make references to Douglass Adams, but they could interact at a task-local level but also have shorthand to expose as a timeout that creates a deadline. So all in all I would not be in favor of timeouts but strongly in favor of deadlines.

3 Likes

Swift concurrency is fundamentally cooperative, and if this is not intuitive, then we failed to establish that fact. However, I think in this case we do a good job of making this clear.

+1 for @Philippe_Hausler’s idea of a deadline approach instead. I think timeouts don’t fit very well into Swift’s concurrency model.

I was wondering the exact same thing - how to differentiate a timeout error from an underlying thrown error from whatever processing I was doing. Huge +1 to this whole concept, and yes please for the adjustment to the TimeoutError to allow it to be differentiated on need.

Another commonly needed thing that we should consider is making the TimeoutError generic upon the clock and carry the deadline so that folks can extract the information around the given deadline when a failure occurs.

2 Likes