Swift Combine sink stops receiving values after first error

Viewed 4573

Im moving my project to Combine from RxSwift I have a logic where I want publisher to emit event every time I click button. Acrually clicking button executed pushMe.send()

pushMe
            .print("Debug")
            .flatMap { (res) -> AnyPublisher<Bool, Error> in
                return Future<Bool, Error>.init { closure in
                    closure(.failure(Errors.validationFail))
                }.eraseToAnyPublisher()
            }
            .sink(receiveCompletion: { completion in
                print("Completion received")
            }, receiveValue: { value in
                print("Value = \(value)")
            })
            .store(in: &subscriptions)

The console result

Debug: receive value: (true)
Completion received
Debug: receive value: (true)
Debug: receive value: (true)

I do not understand why sink receive error only on first event. The rest clicks are ignored.

4 Answers

What does flatMap do -

  1. Subscribes to the given publisher (let's say XPublisher).
  2. Sends the Errors and Output values (not finished event/ completion) emitted by XPublisher to the down stream.

So If you handle errors inside the flat map , (which means the publisher inside the flatMap does not emit errors), then flatMap Never sends an error to the down stream.

pushMe
    .print("Debug")
    .flatMap { (res) -> AnyPublisher<Bool, Never> in //<= here
        return Future<Bool, Error>.init { closure in
            closure(.failure(Errors.validationFail))
        }
        .replaceError(with: false) //<= here
        .eraseToAnyPublisher()
    }
    .sink(receiveCompletion: { completion in
        print("Completion received")
    }, receiveValue: { value in
        print("Value = \(value)")
    })
    .store(in: &subscriptions)

Otherwise you can handle error outside the fatMap. Problem here is that, once an error out whole the subscription / cancellable cancelled. ( in the below example error has replace with a false value)

pushMe
    .print("Debug")
    .flatMap { (res) -> AnyPublisher<Bool, Error> in 
        return Future<Bool, Error>.init { closure in
            closure(.failure(Errors.validationFail))
        }
        .eraseToAnyPublisher()
    }
    .replaceError(with: false) //<= here
    .sink(receiveCompletion: { completion in
        print("Completion received")
    }, receiveValue: { value in
        print("Value = \(value)")
    })
    .store(in: &subscriptions)

What is happening in the above code.

  1. FlatMap error outs.
  2. replace the error with false (One false value will receive Because of this)
  3. subscription cancelled because of the error out in the stream.

The rule is that if an error propagates down the pipeline, the entire pipeline is cancelled. Thus, if your Future generates an error, it passes as an error to the Sink and thus the pipeline is cancelled all the way up to the Publisher.

The pattern for preventing this is to deal with the error inside the FlatMap. Basically, you've got two pipelines here: the one that starts with pushMe and the one that starts with Future. Simply don't let the error generated by the Future pipeline "leak" out into the pushMe pipeline, and so the pushMe pipeline will not be cancelled. Instead, catch the error inside the FlatMap and, if you want to pass something out of it to your Sink, pass out of it some sort of value that tells your Sink that there has been a bad input.

A simple solution in your case would be to change the type your FlatMap to <Bool,Never>, and pass either true or false as the Bool to indicate whether validation succeeded in the Future or not.

Or, if it's important to you to pass more detailed information about the error down the pipeline, change the type of your FlatMap to <Result<Bool,Error>,Never> and package the error information into the .failure case of the Result object.

This is how Publishers work in Combine.

The Publisher can either emit values or emit a completion event - once a completion event was emitted, the Publisher is finished and it cannot emit any other values or another completion event. If the Publisher emits an error, the error is emitted as a failure completion, meaning that once an error is emitted, the Publisher completes and it cannot emit any more values.

There are several Combine operators designed for handling errors without completing the Publisher. Have a look into the catch operator for instance.

First, thanks all for helping with this question.

Answer of @matt is one of the possible solution. Another solution is to create new pipeline every time you clicking button. Im using this approach because I have sequence of steps below failing publisher and Im not able to rely of dummy true/false result further.

      Just<String>()
            .sink(receiveValue: { value in
                startProcess()
                        .sink(receiveCompletion: { (completion:
                                Subscribers.Completion<Failure>) in
                            // can handle ALL types of error of pipe line in ONE place
                        }, receiveValue: { (v: P.Output) in
                            // handle correct result           
                        })
            })
            .store(in: &subscriptions)

    func startProcess() -> AnyPublisher<Bool, Error> {
        Future<Bool, Error>.init { closure in
                    // action 1 
                    closure(.success(true))
                }
                .flatMap { (b: Bool) -> AnyPubilsher<Void, Error> in
                    Future<Bool, Error>.init { closure in
                        // action 2
                        closure(.success(()))
                    }
                }
    }

Benefit is that you are able to handle all types of errors in one place if second sink()

Related