Home > front end >  How to have a publisher continue even after timeout?
How to have a publisher continue even after timeout?

Time:01-18

So I am making multiple API calls that I need to refresh my UI. I am using CombineLatest to wait until I have data from all of them before updating the UI:

let apiCall1 = repository1.fetchData().eraseToAnyPublisher()
let apiCall2 = repository2.fetchData().eraseToAnyPublisher()
let apiCall3 = repository3.fetchData().eraseToAnyPublisher()
let apiCall4 = repository4.fetchData().eraseToAnyPublisher()

Publishers.CombineLatest4(apiCall1, apiCall2, apiCall3, apiCall4)
      .sink(receiveCompletion: { [weak self] result in
            switch result {
            case .finished: return
            case .failure(_): handleError()
            }
      }, receiveValue: { [weak self] result1, result2, result3, result4 in
            self.saveResults(result1, result2, result3, result4)
            self.updateUI()
      })
      .store(in: &cancellables)

Three are vital, but apiCall4 should be optional. If it takes too long, I would like to avoid waiting for it. In other words, I don't want to block the UI until it comes in.

So I am trying to implement a stop gap timeout. Maybe return an empty array so that I can update the UI until I actually get the data.

I have played with adding a timeout to the publisher, something like this:

let apiCall4 = repository4.fetchData().eraseToAnyPublisher() 
.timeout(5, scheduler: DispatchQueue.main, customError: {
    return ResponseError.timeout
})
.replaceError(with: [])

But the problem, of course, is that this terminates the apiCall4 publisher. I won't get the data if the API call completes after the timeout.

Is there any way to send a placeholder value after x amount of time without actually terminating the apiCall4 publisher?

CodePudding user response:

My understanding is:

  • If apiCall4 publishes its first output before the timeout, you only want the genuine outputs from apiCall4.

  • If apiCall4 doesn't publish its first output before the timeout, you want to publish a default value, but you want to allow apiCall4 to continue running, and use its output if it eventually publishes anything.

Here's one way to solve this.

  • We'll use a Just with a delay to publish the default output after the timeout.

  • We'll use map to tag the default output and the genuine outputs, so we can distinguish them downstream.

  • We'll use merge to combine the tagged outputs of apiCall4 and the delayed Just into a single stream.

  • We'll then use scan to keep some state about whether we've ever seen a genuine output, so if we get the default output after seeing a genuine output, we can discard the default output.

  • Since scan publishes its entire state, and always publishes when its upstream publishes, we can't actually discard the default output in scan, nor can we publish just the output without the extra state. So we'll use compactMap after scan to strip off scans extra state and actually discard if we get the default output after a genuine output.

Here's the type we'll use to tag each output with its genuine-ness:

fileprivate struct Wrapper<Output> {
    var output: Output
    var isGenuine: Bool
}

And here's the type we'll use to hold the extra scan state:

fileprivate struct ScanState<Output> {
    var output: Output?
    var hasSeenGenuine: Bool = false
}

And here's how we assemble the pieces, as described above:

extension Publisher {
    func defaulting<S: Scheduler>(
        to defaultOutput: Output,
        after timeout: S.SchedulerTimeType.Stride,
        scheduler: S
    ) -> some Publisher<Output, Failure> {
        let genuine = self
            .map { Wrapper(output: $0, isGenuine: true) }

        let defaulted = Just(Wrapper(
            output: defaultOutput,
            isGenuine: false
        ))
            .setFailureType(to: Failure.self)
            .delay(for: timeout, scheduler: scheduler)

        return genuine.merge(with: defaulted)
            .scan(ScanState<Output>()) { state, wrapper in
                if state.hasSeenGenuine && !wrapper.isGenuine {
                    return ScanState(output: nil, hasSeenGenuine: true)
                } else {
                    return ScanState(
                        output: wrapper.output,
                        hasSeenGenuine: wrapper.isGenuine || state.hasSeenGenuine
                    )
                }
            }
            .compactMap { $0.output }
    }
}

Here's a test function:

func runTest(call4Delay: DispatchQueue.SchedulerTimeType.Stride) -> AnyCancellable {
    let apiCall1 = Just("answer1").delay(for: .milliseconds(300), scheduler: DispatchQueue.main)
    let apiCall2 = Just("answer2").delay(for: .milliseconds(400), scheduler: DispatchQueue.main)
    let apiCall3 = Just("answer3").delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
    let apiCall4 = Just("answer4").delay(for: call4Delay, scheduler: DispatchQueue.main)

    let defaultedCall4 = apiCall4.defaulting(to: "default", after: .milliseconds(1000), scheduler: DispatchQueue.main)

    let combo = apiCall1.combineLatest(apiCall2, apiCall3, defaultedCall4)
    let ticket = combo.sink { print($0) }
    return ticket
}

The timeout is one second. If I run a test with apiCall4 publishing after 900 milliseconds, I only see the genuine output:

("answer1", "answer2", "answer3", "answer4")

If I run a test with apiCall4 publishing after 1100 milliseconds, I see the default output followed by the genuine output:

("answer1", "answer2", "answer3", "default")
("answer1", "answer2", "answer3", "answer4")

CodePudding user response:

Three are vital, but apiCall4 should be optional. If it takes too long, I would like to avoid waiting for it. In other words, I don't want to block the UI until it comes in... Maybe return an empty array so that I can update the UI until I actually get the data.

The answer is simple, just prepend an empty array to apiCall4. The UI will update when the other three values emit along with the empty array for apiCall4, then when apiCall4 comes in, the UI will update again with the additional, optional, information.

No need for a timeout at all.

Is there any way to send a placeholder value after x amount of time without actually terminating the apiCall4 publisher?

The best part about using prepend is that the UI will update as soon as the other three calls emit values. If apiCall4 has already emitted by then, the default won't show, if not, then it will.

  • Related