4

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
George
  • 643
  • 9
  • 23
  • You have analytics, so apparently the device in question was able to access the internet - but what if it couldn't access your specific URL? Are your analytics going to the same endpoint as the URL you're trying to reach? I work in a corporate environment where our network will let us access some URLs but not others. Perhaps the user is in an environment where the analytics can get through, but whatever URL these requests are trying to access is blocked. So the session fails. – Scott Thompson Oct 26 '21 at 18:50
  • What type of DispatchQueue is DisoatchQueue.background? – LuLuGaGa Oct 26 '21 at 19:14
  • @LuLuGaGa DisoatchQueue.background is just `static let background = DispatchQueue.global(qos: .background)` in extension of DisoatchQueue – George Oct 27 '21 at 08:02
  • @ScottThompson you are noted it right - analytics is third party and it works. But at the same time we have logs on our server for exact api call so client clearly can reach our server, but in some reason it drops the connection on half way while server generates the response. Most of this request vary from 2 to 200 ms and no one exceed 3 seconds, so there are no reason for timeout since we use URLSession.shared which default timeout is 60 sec. – George Oct 27 '21 at 08:06

1 Answers1

2

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:

DispatchQueue.background.async {
    URLSession.shared.dataTask(with: request) { data, response, error in
        <#code#>
    }
}

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.

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 Fail(error: TheError.Network.cantEncodeParameters).eraseToAnyPublisher() // your failure here is way more complex than it needs to be. A simple Fail will do what you need here.
    }

    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase

    return URLSession.shared
        .dataTaskPublisher(for: request)
        // you don't need the `subscribe(on:)` here.
        .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()
}
Daniel T.
  • 32,821
  • 6
  • 50
  • 72
  • I had experimented with removing cancellables before request completion with a demo server and was not able to reproduce any error. Request just hang forever and sink closure was never executed while in real life example users observer the alert with the error message. But there are maybe some caveats I do not make right in a demo environment. – George Oct 27 '21 at 07:59
  • Based on my understanding (very raw indeed) of publishers I thought `subscribe(on)` should set all downstream schedulers to a specified thread, so we can perform parsing, mapping, and other operation in the background thread until the last sink to present the result to UI. Turns out I was wrong. I will remove this call and if the error will go out I will come back and accept your answer. Thanks. – George Oct 27 '21 at 07:59
  • What you are describing is the `receive(on:)` method, not `subscribe(on:)`. The `receive(on:)` method sets the downstream schedulers. But even that isn't necessary in this case because `dataTaskPublisher` already emits on a background thread. – Daniel T. Oct 27 '21 at 11:19