Skip to content

Instantly share code, notes, and snippets.

@BigZaphod
Created August 11, 2022 21:18
Show Gist options
  • Save BigZaphod/50ccb254a6e7047ff5693a4381a3be37 to your computer and use it in GitHub Desktop.
Save BigZaphod/50ccb254a6e7047ff5693a4381a3be37 to your computer and use it in GitHub Desktop.
//
// Created by Sean Heber on 8/11/22.
//
import Foundation
enum ExponentialBackoffError : Error {
case retryLimitExceeded
}
/// Runs the `operation` until it succeeds.
/// Success here is defined as not throwing, so if `operation` throws any errors, this function swallows them, waits a bit, and runs the `operation` again until either we reach the maximum attempts allowed or the task is cancelled.
func retryWithExponentialBackoff<Result>(base: Double = 0.25, maxInterval: Double = 60, maxAttempts: Int? = nil, operation: () async throws -> Result) async throws -> Result {
var attempt = 0
while maxAttempts == nil || attempt < maxAttempts! {
try Task.checkCancellation()
do {
return try await operation()
} catch {
// errors from the operation are ignored!
}
// This uses an exponential backoff with jitter (hence the randomness).
let sleep = base * Double(pow(Double(2), Double(attempt)))
let seconds = Double.random(in: 0...min(maxInterval, sleep))
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
attempt += 1
}
throw ExponentialBackoffError.retryLimitExceeded
}
@BigZaphod
Copy link
Author

Here's a variation on this idea that uses a function to decide if the thrown error is something we care about or not. If onFailure throws, then that'll cancel the retrying - otherwise it keeps going. This way you can implement your own maxAttempts logic, log the errors, or decide if certain errors are actually failures while ignoring and retrying for others.

//
//  Created by Sean Heber on 8/11/22.
//

import Foundation

/// Runs the `operation` until it succeeds, `onFailure` throws an error, or the task is cancelled.
/// If `operation` throws an error, it is passed to `onFailure` along with the current attempt count (always >= 1) allowing you to log it or decide what to do.
/// If `onFailure` does not throw, then retrying continues after the backoff timeout. If `onFailure` throws an error, then this function rethrows the error and fails itself.
/// To limit the maximum number of retries, implement a custom `onFailure` that throws an error after the number of attempts crosses your threshold.
func retryWithExponentialBackoff<Result>(base: Double = 0.25, maxInterval: Double = 60, onFailure: (Error, Int) throws -> () = { _, _ in }, operation: () async throws -> Result) async throws -> Result {
    var attempts = 1
    
    while true {
        try Task.checkCancellation()

        do {
            return try await operation()
        } catch {
            try onFailure(error, attempts)
        }

        // This uses an exponential backoff with jitter (hence the randomness).
        let sleep = base * Double(pow(Double(2), Double(attempts - 1)))
        let seconds = Double.random(in: 0...min(maxInterval, sleep))
        try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
        
        attempts += 1
    }
}

@BigZaphod
Copy link
Author

And of course the onFailure function could also be made async there, too, and who knows what crazy possibilities that might unlock.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment