Generic retry function that re-calls a function if it returns errors I designate as retryable?

From working on code suggestions for this related topic:

I found it nice to use a while loop with else branch to keep the retry logic within an iterator, while keeping all the error handling and result processing directly within the code you are working on, without needing duplicate calls of the function that can fail, I prefer this because the reusable code (the retry stuff) is abstracted away, while the code that differs from situation to situation is kept un-abstracted and can be changed easily.

Here I simplified the suggestion from the above topic (removing the time based backoff), I think one of the great things about this, is that you could switch between many other iterators and still keep the same code within the while loop, additionally using the else branch for when it failed too many times makes the code more readable.

const std = @import("std");

pub const Retry = struct {
    iter: u64,
    max_tries: u16,

    pub fn init(max_tries: u16) Retry {
        return .{ .max_tries = max_tries, .iter = 0 };
    }

    pub fn next(self: *Retry) ?void {
        if (self.iter < self.max_tries) {
            self.iter += 1;
            return;
        }
        return null;
    }
};

pub fn attempt(random: std.Random) !u32 {
    return switch (random.uintLessThan(u8, 100)) {
        0...80 => error.Retry,
        81 => error.Other,
        82...99 => |x| x,
        else => unreachable,
    };
}

pub fn main() !void {
    // zig 0.13
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const seed = try getSeed(allocator);
    var gen = std.rand.DefaultPrng.init(seed);
    const random = gen.random();

    var tries = Retry.init(10);
    while (tries.next()) |_| {
        std.debug.print("new try\n", .{});
        const result = attempt(random) catch |err| switch (err) {
            error.Retry => continue,
            else => return err,
        };
        std.debug.print("got valid result: {}\n", .{result});
        break;
    } else {
        return error.MaxTriesExhausted;
    }
}

fn getSeed(allocator: std.mem.Allocator) !u64 {
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    const seed_arg = 1;
    const seed: u64 = if (args.len > seed_arg) try parseSeed(args[seed_arg]) else guessSeedFromTime();
    return seed;
}

fn guessSeedFromTime() u64 {
    const seed: u64 = @intCast(std.time.nanoTimestamp());
    std.debug.print("guessed seed: {d}\n", .{seed});
    return seed;
}

fn parseSeed(seed: []const u8) !u64 {
    std.debug.print("got seed: {s}\n", .{seed});
    return std.fmt.parseInt(u64, seed, 10);
}

I also added a random seed that either gets set via a timestamp or supplied as commandline argument, that way you can explore different possibilities and also repeat them by providing the seed.

If the source is stored in a retry.zig file you can call it like this to provide the seed: zig run retry.zig -- <seed>

seed result
1726310939861915769 error: MaxTriesExhausted
1726311882627969760 got valid result: 95
1726312050498686408 error: Other

You also could add an abort function to the iterator, which causes the next next call to return null (and thus exit via the else branch) if you wanted to write some logic within the while loop that considers giving up after every try, or maybe based on specific error codes. It would then call tries.abort(); continue;

2 Likes