Introduction
When a Combine URLSession.DataTaskPublisher subscription is cancelled before the network request completes, it emits a URLError.cancelled error downstream. This is different from a request failure and should typically be handled differently. Treating cancellation as an error causes unnecessary error reporting, retry attempts, and user-facing error messages for what is actually a normal lifecycle event (like a user navigating away from a screen).
Symptoms
URLError.cancelledlogged as an error when user navigates away- Retry operator retries after user cancels the request
- Error tracking service reports cancelled requests as failures
- View disappears but network error alert still shows
sink(receiveCompletion:)receives.failure(.cancelled)for normal cancellation
Error output:
``
Network request failed: URLError(_nsError: NSError {
domain: NSURLErrorDomain,
code: -999,
description: "cancelled"
})
Common Causes
- Not distinguishing cancellation from actual network errors
- Retry logic that retries cancelled requests
- Error reporting that does not filter cancellation errors
- View lifecycle cancelling subscriptions that are still in-flight
- TaskGroup or async/await cancellation propagating to Combine
Step-by-Step Fix
- 1.Filter cancellation errors from error handling:
- 2.```swift
- 3.import Combine
class APIClient { private var cancellables = Set<AnyCancellable>()
func fetchUserData(userId: String) { URLSession.shared.dataTaskPublisher( for: URL(string: "https://api.example.com/users/\(userId)")! ) .map(\.data) .decode(type: User.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in switch completion { case .finished: break case .failure(let error): // Filter out cancellation errors if (error as? URLError)?.code == .cancelled { // Normal: user navigated away, do not report return } self?.handleNetworkError(error) } }, receiveValue: { user in // Handle successful response } ) .store(in: &cancellables) }
private func handleNetworkError(_ error: Error) { // Only log/report real errors print("Network error: \(error.localizedDescription)") } } ```
- 1.**Use replaceError for graceful cancellation handling":
- 2.```swift
- 3.func fetchWithGracefulCancellation() -> AnyPublisher<Data, Never> {
- 4.URLSession.shared.dataTaskPublisher(for: url)
- 5..map(\.data)
- 6.// Replace ALL errors (including cancellation) with empty data
- 7..replaceError(with: Data())
- 8.// Or replace only cancellation errors
- 9..catch { error -> AnyPublisher<Data, Never> in
- 10.if (error as? URLError)?.code == .cancelled {
- 11.return Just(Data()).eraseToAnyPublisher()
- 12.}
- 13.return Fail(error: error).eraseToAnyPublisher()
- 14.}
- 15..eraseToAnyPublisher()
- 16.}
- 17.
` - 18.**Handle cancellation in SwiftUI view lifecycle":
- 19.```swift
- 20.struct UserDetailView: View {
- 21.@StateObject private var viewModel = UserDetailViewModel()
- 22.@State private var cancellable: AnyCancellable?
var body: some View { VStack { if let user = viewModel.user { UserProfileView(user: user) } else if viewModel.isLoading { ProgressView() } else if let error = viewModel.error { Text("Error: \(error.localizedDescription)") } } .onAppear { viewModel.loadUser() } // No need to manually cancel - @StateObject handles it } }
class UserDetailViewModel: ObservableObject { @Published var user: User? @Published var isLoading = false @Published var error: Error?
private var cancellable: AnyCancellable?
func loadUser() { isLoading = true error = nil
cancellable = URLSession.shared.dataTaskPublisher(for: url) .map(\.data) .decode(type: User.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink( receiveCompletion: { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { // Only set error for non-cancellation guard (error as? URLError)?.code != .cancelled else { return } self?.error = error } }, receiveValue: { [weak self] user in self?.user = user } ) }
deinit { // Cancellation happens automatically when cancellable is deallocated cancellable?.cancel() } } ```
Prevention
- Always check for
URLError.cancelledin error handling code - Use
.ignoreFailure()or.replaceError()for non-critical requests - Do not retry cancelled requests
- Do not report cancellations to error tracking services
- Let
@StateObjectmanage subscription lifecycle in SwiftUI - Use
Task.isCancelledcheck before processing async results