serhii.net

In the middle of the desert you can say anything you want

06 Dec 2023

Overengineered solution to retrying and exceptions in python

Goal: retry running function X times max Scenario: networking-ish issues

Solution: I came up with the thing below. It gets an optional list of acceptable exception types, and retries N times every time it gets one of them. As soon as it gets an unacceptable exception it passes it further. As soon as the function runs successfully it returns the function’s return value.

Can repeat infinite times and can consider all exceptions acceptable if both params are given empty or None.

from urllib3.exceptions import ProtocolError
from functools import partial
from itertools import count
from typing import Optional

def _try_n_times(fn, n_times: Optional[int]=3, acceptable_exceptions: Optional[tuple] =(ProtocolError, )):
    """ Try function X times before giving up.

    Concept:
    - retry N times if fn fails with an acceptable exception   
    - raise immediately any exceptions not inside acceptable_exceptions
    - if n_times is falsey will retry infinite times
    - if acceptable_exceptions is falsey, all exceptions are acceptable

    Returns:
        - after n<n_times retries the return value of the first successdful run of fn

    Raises:
        - first unacceptable exceptions if acceptable_exceptions is not empty 
        - last exception raised by fn after too many retries

    Args:
        fn: callable to run
        n_times: how many times, 0 means infinite
        acceptable_exceptions: iterable of exceptions classes after which retry
            empty/None means all exceptions are OK 

    TODO: if this works, integrate into load image/json as well (or increase 
        the number of retries organically) for e.g. NameResolutionErrors
        and similar networking/connection issues
    """
    last_exc = None
    for time in range(n_times) if n_times else count(0):
        try:
            # Try running the function and save output
            # break if it worked
            if time>0:
                logger.debug(f"Running fn {time=}")
            res = fn()
            break
        except Exception as e:
            # If there's an exception, raise bad ones otherwise continue the loop
            if acceptable_exceptions and e.__class__ not in acceptable_exceptions:
                logger.error(f"Caught {e} not in {acceptable_exceptions=},  so raising")
                raise
            logger.debug(f"Caught acceptable {e} our {time}'th time, continuing")
            last_exc = e
            continue
    else:
        # If loop went through without a single break it means fn always failed
        # we raise the last exception
        logger.error(f"Went through {time} acceptable exceptions, all failed, last exception was {last_exc}")
        raise last_exc

    # Return whatever fn returned on its first successful run
    return res

The main bit here was that I didn’t want to use any magic values that might conflict with whatever the function returns (if I get a None/False how can I know it wasn’t the function without ugly complex magic values?)

The main insight here is the else clause w/ break.

fn is run as fn() and partial is a good way to generate them

EDIT: (ty CH) you can also just declare a function, lol

Nel mezzo del deserto posso dire tutto quello che voglio.