After switching our API Client to Combine we start to receive reports from our users about error "The operation couldn’t be completed (NSURLErrorDomain -1.)" which is the error.localizedDescription forwarded to UI from our API client.
Top level api call looks like this:
class SomeViewModel {
private let serviceCategories: ICategoriesService
private var cancellables = [AnyCancellable]()
init(service: ICategoriesService) {
self.serviceCategories = service
}
// ...
// Yes, the block is ugly. We are only on the half way of the migration to Combine
func syncData(force: Bool = false, _ block: @escaping VoidBlock) {
serviceCategories
.fetch(force: force)
.combineLatest(syncOrders(ignoreCache: force))
.receive(on: DispatchQueue.main)
.sink { [unowned self] completion in
// bla-bla-bla
// show alert on error
}
.store(in: &cancellables)
}
}
Low level API Client call looks like:
func fetch<R>(_ type: R.Type, at endpoint: Endpoint, page: Int, force: Bool) -> AnyPublisher<R, TheError> where R : Decodable {
guard let request = request(for: endpoint, page: page, force: force) else {
return Deferred { Future { $0(.failure(TheError.Network.cantEncodeParameters)) } }.eraseToAnyPublisher()
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return URLSession.shared
.dataTaskPublisher(for: request)
.subscribe(on: DispatchQueue.background)
.tryMap { element in
guard
let httpResponse = element.response as? HTTPURLResponse,
httpResponse.statusCode == 200 else
{ throw URLError(.badServerResponse) }
return element.data
}
.decode(type: type, decoder: decoder)
.mapError { error in
// We map error to present in UI
switch error {
case is Swift.DecodingError:
return TheError.Network.cantDecodeResponse
default:
return TheError(title: nil, description: error.localizedDescription, status: -2)
}
}
.eraseToAnyPublisher()
}
In our analytics we can clearly see chain of events:
- application updated
- application opened
- main screen shown
- alert shown (NSURLErrorDomain -1)
- application backgrounded then user fall into loop "opened, alert, backgrounded" trying to restart or reinstall the app without success.
First sought was it may be some garbage sent from backend to the client, but our server logs have records for api calls correlated to analytics logs by date and time with http status code 499.
So we can clearly determine this is not a server problem.
We also do not have reports or analytics records from users before this update.
All points to new API client switched to Combine.
It looks like session dropped by the client for some reason but at the same time it does not relates to a memory release cycle since if cancellable where released sink closure will never be executed and alert message will not be shown.
Questions:
- What can be wrong with this URLSession setup?
- Did you faced similar behavior and managed to solve it?
- Do you have ideas how to reproduce or at least simulate such error with URLSession?
Notes:
- We do not use SwiftUI
- iOS version vary from 14.8 to 15.0
- From 5 to 10% of users affected
- We never faced such error during development or testing
I don't know for sure but I see a couple of issues in the code you presented... I commented below.
A 499 implies that your Cancellable is getting deleted before the network request completes. Maybe that will help you track it down.
Also, you don't need the
subscribe(on:)and it likely doesn't do what you think it does anyway. It could be causing the problem but there's no way to know for sure.Using
subscribe(on:)there is like doing this:If you understand about how URLSession works, you will see that dispatch is completely unnecessary and doesn't affect what thread the data task will emit on.