skip to content

When exposing a Kotlin suspend function to Swift via @Throws, how should CancellationException be handled, and why is it special?

level: seniorimportance: should knowfreq 35%

answer

  1. suspend → Swift async/completion that can carry an error
  2. Cancellation = CancellationException thrown cooperatively
  3. Never swallow CancellationException; rethrow it
  4. Cleanup in finally + withContext(NonCancellable)
  5. Unlisted exceptions still crash, even in suspend

basics

~20 s

Suspend functions become Swift async calls with a completion that can deliver an error. You should mark them so cancellation can be reported to Swift; otherwise a cancelled coroutine could crash instead of telling Swift it was cancelled.

solid answer

~40 s

Kotlin/Native exports a `suspend` function to Swift as an `async`/completion-handler method that can deliver an error. Just like non-suspend functions, only exceptions whose types are in `@Throws` bridge; unlisted ones crash. The subtlety is `kotlinx.coroutines.CancellationException`: when the coroutine is cancelled, the suspend function completes by throwing it. If `CancellationException` is not bridgeable, that throw can become a fatal boundary error. Kotlin/Native has special handling so cancellation is delivered to Swift as an error (mapped toward Swift structured-concurrency cancellation), but you must ensure your `@Throws` set and call style let it propagate as an error rather than crash. Best practice: list the recoverable domain exceptions explicitly, rely on the runtime's cancellation bridging, and keep cleanup in `finally`/`withContext(NonCancellable)` so resources release even when the coroutine is cancelled.

code

kotlin · 10 lines
kotlin
@Throws(ApiException::class)
suspend fun search(q: String): List<Hit> {
    try {
        return client.query(q) // suspends; cancellation throws CancellationException
    } catch (e: CancellationException) {
        throw e // MUST rethrow so cancellation propagates to Swift
    } finally {
        withContext(NonCancellable) { metrics.flush() } // runs even if cancelled
    }
}

go deeper

for a junior

Knows suspend functions show up as async in Swift and can report errors.

for a middle

Knows the unlisted-crash rule still applies and that errors arrive via the completion.

for a senior

Handles CancellationException correctly (rethrow), uses NonCancellable cleanup, and curates the @Throws set for suspend APIs.

for a principal

Defines structured-concurrency conventions across the KMP boundary so cancellation and resource cleanup are consistent and crash-free in iOS clients.

## How suspend functions cross to Swift A Kotlin `suspend` function is exported to Objective-C/Swift as an **async method with a completion handler** (Swift surfaces it as `async`). The completion can carry either a result or an **error**, so the `@Throws` machinery still applies: only **listed** exception types are delivered as errors; **unlisted** ones reaching the boundary are fatal. ```kotlin @Throws(ApiException::class, CancellationException::class) suspend fun fetch(id: String): Dto { ... } ``` ## Why CancellationException is special In `kotlinx.coroutines`, cancellation is **cooperative** and signaled by throwing `kotlinx.coroutines.CancellationException` from suspension points. Inside Kotlin this is normal control flow — you must **not** swallow it. But at the **language boundary** it is just another throwable, so if it is not bridgeable it could crash the process when a coroutine is cancelled. Kotlin/Native's interop has **dedicated handling for cancellation**: when the Swift side cancels (or the coroutine is cancelled), the completion is finished with a cancellation error rather than aborting. This lets Swift's structured concurrency observe the cancellation. The mechanism relies on `CancellationException` being recognized as a cancellation signal, so it is delivered as an error, not a crash. ## What you must get right - **Don't catch-and-swallow `CancellationException`** in your Kotlin code; rethrow it. Swallowing it breaks cooperative cancellation and confuses the bridge. - **Resource cleanup** belongs in `finally`; if cleanup itself suspends, wrap it in `withContext(NonCancellable)` so it runs even after cancellation. - **List your recoverable domain exceptions** in `@Throws`; let the runtime handle cancellation delivery. - Anything you do NOT list (e.g. a stray `IllegalStateException`) still crashes — so audit suspend bodies for leaks of unexpected exception types. ```kotlin @Throws(ApiException::class) suspend fun upload(file: File): Url { try { return remote.put(file) // may throw ApiException, or CancellationException on cancel } finally { withContext(NonCancellable) { tmp.delete() } // cleanup survives cancellation } } ``` ## Mental model - Non-suspend `@Throws`: synchronous `NSError**` channel. - suspend `@Throws`: async completion that can carry an error; cancellation maps to Swift cancellation. - The rule "unlisted ⇒ crash" is unchanged for both.

  • What happens if you catch CancellationException and return a default value instead of rethrowing?
    You break cooperative cancellation: the coroutine appears to complete normally, the bridge can't report cancellation to Swift, and parent scope cancellation semantics get corrupted.
  • How is a suspend function exported to Swift?
    As an async method (completion-handler based) that delivers either a result or an error; the @Throws rules for which exceptions bridge still apply.

Cancellation is like a 'stop' note passed up the chain; swallow it and the worker keeps running blindly, which jams the whole assembly line at the border.

saying these in an interview costs you the question

  • Swallowing CancellationException and returning a fallback
  • Thinking suspend functions bypass the unlisted-crash rule
  • Putting cancellable cleanup outside NonCancellable so it never runs
  • Believing cancellation always crashes the Swift app
  • Not knowing suspend maps to a Swift async/completion API

context