subscription cancels when receiveCompletion with error

Viewed 258

In my app which is designed in MVVM-pattern I have a loginView. Login may fail if there is either a network or authentication problem. My goal is to catch errors and display the corresponding alerts. I defined and enum for alerts as follow:

enum Alerts: Identifiable {
    var id: Int {
        return self.hashValue
    }

    case networkError
    case authenticationError
}

the implementation of the view is:

struct LoginView: View {

    @ObservedObject var viewModel = LoginViewModel()

    var body: some View {
    
        VStack {
            TextField("Enter e-mail address", text: $viewModel.email)
            SecureField("Enter password", text: $viewModel.password)
        
            Button("Log In") {
                viewModel.login()
            }
        }.alert(item: $viewModel.errorAlert, content: { alert in
            switch alert {
            case .networkError:
                return Alert(title: Text("Error"), message: Text("Check internet Connection"), dismissButton: .default(Text("Ok")))
            case .authenticationError:
                return Alert(title: Text("Error"), message: Text("Some error occured, please try again"), dismissButton: .default(Text("Ok")))
            }
        })
     }
 }

and the viewModel is:

class LoginViewModel: ObservableObject {

    @Published var email: String = ""
    @Published var password: String = ""

    @Published var errorAlert: Alerts? = nil
    @Published var token: Token? = nil

    var authentication = PassthroughSubject<User, WebserviceError>()

    var cancellables = Set<AnyCancellable>()

    init() {
    
        authentication.map { Webservice().authenticate($0) }.switchToLatest().print().sink { error in
            self.errorAlert = Alerts.networkError
        } receiveValue: { token in
            self.token = token
        }.store(in: &cancellables)

        token.map { KeychainWrapper.save(token: $0)}?.sink(receiveCompletion: { error in
            self.errorAlert = Alerts.authenticationError
        }, receiveValue: { _ in
            //
        }).store(in: &cancellables)
    }

    func login() {
        authentication.send(User(username: email, password: password))
    }
}

implementation of the Webservice

class Webservice {
    func authenticate(_ user: User) -> AnyPublisher<Token, WebserviceError> {
        return Future<Token, WebserviceError> { promis in
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                if user.username.lowercased() == "root" && user.password == "1234" {
                    promis(.success(Token(access: "asdasda", refresh: "sdfsdfsdf", exprationDate: Date().addingTimeInterval(120))))
                } else {
                    promis(.failure(.unknown))
                }
            }
        }.eraseToAnyPublisher()
    }
}

considering the following scenario: user runs the app for the first time. in the first try she/he puts the wrong username/password.

what happens is that the user will be shown the equivalent alert and can click ok to dismiss it. The problem is that from the 2nd time so on, after clicking the login button, nothing happen. looks like the authentication.map { .... } in Viewmodel is cancelled forever. why is that so?

3 Answers

According to the documentation

A publisher continues to emit elements until it completes normally or fails.

Which means if your publisher (authentication: PassthroughSubject) fails, it can no longer emit values to its subscribers.

One way to solve this might be to call Webservice().authenticate($0) manually and use the publisher it returns directly (and completely remove the authentication subject).

Note: I'm not a Combine expert, I'm not sure if this is the right way to manage publishers (if there is some sort of performance issues storing a cancellable that frequently), you might want to dig into that.

class LoginViewModel: ObservableObject {

    // [...]

    func login() {
        Webservice()
            .authenticate(User(username: email, password: password))
            .print()
            .sink { error in
                self.errorAlert = Alerts.networkError
            } receiveValue: { token in
                self.token = token
            }
            .store(in: &cancellables)
    }
}

A Combine published publishes values until completion or error - this is by design.

In your use case you seem to want to "handle" errors inside the sink subscription. In other words, your pipeline should never error out. To achieve that, you could "package" the value or the error into a Result and emit that as a value, while having a Never failure type as far as Combine pipeline is concerned.

For convenience, let's create an operator asResult():

extension Publisher {
    func asResult() -> AnyPublisher<Result<Output, Failure>, Never> {
        self
            .map { .success($0) }
            .catch { err in Result.Publisher(.failure(err))}
            .eraseToAnyPublisher()
    }
}

And you can apply it to authenticate:

authentication
   .map { 
      Webservice().authenticate($0).asResult()
   }
   .switchToLatest()
   .sink { result in
      // handle the result here
      switch result {
      case .success(let token):
         self.token = token
      case .failure(let error):
         print(error)
         self.errorAlert = Alerts.networkError
      }
   }
   .store(in: &cancellables)

As you can see, the above pipeline never failures - i.e. has Failure == Never

It is expected behaviour for a publisher to stop emitting values once it fails.

You could approach this by sending a request to authenticate from the login function. If the publisher fails you will display an error to the user and they will be able to change their input and tap login again. This will result in new request being made:

class LoginViewModel: ObservableObject {

    @Published var email: String = ""
    @Published var password: String = ""

    @Published private (set) var errorAlert: Alerts? = nil
    @Published private (set) var token: Token? = nil

    private let webservice: Webservice
    private var cancellables = Set<AnyCancellable>()

    init(webservice: Webservice) {
        self.webservice = webservice
        
        $token
            .sink { token in
                KeychainWrapper.save(token: token)
            }
            .store(in: &cancellables)
    }

    func login() {
        let user = User(username: email, password: password)

        webservice
            .authenticate(user)
            .sink(receiveCompletion: { [weak self] completion in
                if completion == .failure(_) {
                    self?.errorAlert = .authenticationError
                }
            }, receiveValue: { [weak self] token in
                self?.token = token
            })
            .store(in: &cancellables)
    }
}
Related