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.

@shles
Copy link

shles commented Jun 17, 2025

For clarity I would rename the onFaliure to shouldRetryOn: (Error, Int) -> Bool = { _,_ in true} it will make it more explicitly a condition/filter like closure

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