Gestire i tentativi automatici in Python con retry e backoff esponenziale

by theArchitect
SHARE
Gestire i tentativi automatici in Python con retry e backoff esponenziale
© Guida-HTML5.it

Introduzione

Non tutti gli errori richiedono l’interruzione immediata di un programma. In molte applicazioni, soprattutto quelle che comunicano con servizi esterni, database o API web, un’eccezione può essere causata da un problema temporaneo: una connessione instabile, un server momentaneamente sovraccarico o un timeout di rete.

In questi casi è utile adottare una strategia di retry, cioè riprovare automaticamente l’operazione dopo un breve intervallo. Il retry deve però essere progettato con attenzione: ripetere un’operazione senza limiti può bloccare il programma, sovraccaricare il servizio remoto o causare duplicazioni indesiderate.

Una tecnica comune è il backoff esponenziale. Dopo ogni tentativo fallito, il programma aspetta un tempo progressivamente maggiore, ad esempio 1, 2, 4, 8 secondi. È inoltre consigliabile aggiungere un piccolo ritardo casuale, chiamato jitter, per evitare che molti client riprovino esattamente nello stesso momento.

Codice completo

import random
import time
from typing import Callable, TypeVar


T = TypeVar("T")


class RetryExhaustedError(Exception):
    """Errore sollevato quando tutti i tentativi sono falliti."""


def esegui_con_retry(
    operazione: Callable[[], T],
    tentativi_massimi: int = 4,
    ritardo_iniziale: float = 1.0,
    eccezioni_ritentabili: tuple[type[Exception], ...] = (TimeoutError,),
) -> T:
    """
    Esegue un´operazione riprovandola in caso di errori temporanei.

    Il tempo di attesa segue una progressione esponenziale:
    ritardo_iniziale, 2 * ritardo_iniziale, 4 * ritardo_iniziale, ...
    """

    if tentativi_massimi < 1:
        raise ValueError("tentativi_massimi deve essere almeno 1")

    if ritardo_iniziale < 0:
        raise ValueError("ritardo_iniziale non può essere negativo")

    ultimo_errore: Exception | None = None

    for numero_tentativo in range(1, tentativi_massimi + 1):
        try:
            risultato = operazione()

            print(f"Operazione riuscita al tentativo {numero_tentativo}")
            return risultato

        except eccezioni_ritentabili as errore:
            ultimo_errore = errore

            if numero_tentativo == tentativi_massimi:
                break

            ritardo = ritardo_iniziale * (2 ** (numero_tentativo - 1))
            jitter = random.uniform(0, ritardo * 0.2)
            attesa_totale = ritardo + jitter

            print(
                f"Tentativo {numero_tentativo} fallito: {errore}. "
                f"Nuovo tentativo tra {attesa_totale:.2f} secondi."
            )

            time.sleep(attesa_totale)

    raise RetryExhaustedError(
        f"L´operazione è fallita dopo {tentativi_massimi} tentativi"
    ) from ultimo_errore


# Simulazione di un servizio instabile
numero_chiamate = 0


def leggi_dati_da_servizio() -> str:
    global numero_chiamate
    numero_chiamate += 1

    if numero_chiamate < 3:
        raise TimeoutError("Il servizio non ha risposto in tempo")

    return "Dati ricevuti correttamente"


try:
    dati = esegui_con_retry(
        leggi_dati_da_servizio,
        tentativi_massimi=4,
        ritardo_iniziale=0.5,
        eccezioni_ritentabili=(TimeoutError,),
    )
    print(dati)

except RetryExhaustedError as errore:
    print(f"Operazione non completata: {errore}")

Spiegazione

Definire un’operazione riutilizzabile

La funzione esegui_con_retry riceve una funzione come argomento. In questo modo la logica dei tentativi non è legata a una specifica API o a un particolare database. È possibile passarle qualsiasi funzione che non richieda argomenti e che possa generare eccezioni temporanee.

Il parametro Callable[[], T] indica una funzione senza argomenti che restituisce un valore di tipo generico. La variabile di tipo T consente di mantenere l’informazione sul tipo del risultato restituito.

Limitare il numero di tentativi

Il ciclo for prova l’operazione al massimo il numero di volte indicato da tentativi_massimi. Questo limite è fondamentale: un retry infinito potrebbe rendere l’applicazione inutilizzabile e nascondere un problema permanente.

Prima di iniziare, vengono verificati anche i parametri della funzione. Un numero di tentativi inferiore a uno o un ritardo negativo rappresentano configurazioni non valide e producono subito un ValueError.

Ritentare solo gli errori adatti

Il parametro eccezioni_ritentabili contiene una tupla di classi di eccezioni. Nell’esempio viene gestito soltanto TimeoutError, perché un timeout può essere temporaneo.

Non è invece corretto riprovare automaticamente qualsiasi eccezione. Un PermissionError, un errore di autenticazione o un errore di sintassi nella richiesta difficilmente verranno risolti ripetendo subito la stessa operazione.

Calcolare il backoff e il jitter

Il ritardo viene calcolato con la formula:

ritardo_iniziale * 2 ** (numero_tentativo - 1)

Con un ritardo iniziale di 0,5 secondi si ottiene una progressione di 0,5, 1, 2 e così via. Il valore casuale aggiuntivo rende il comportamento più distribuito quando molte applicazioni utilizzano contemporaneamente lo stesso servizio.

Conservare l’errore originale

Quando tutti i tentativi falliscono, viene sollevata RetryExhaustedError. L’istruzione raise ... from ultimo_errore conserva il collegamento con l’eccezione originale. Questo aiuta il debugging, perché il traceback mostra sia il fallimento finale sia la causa iniziale.

Best practice

  • Ritentare solo errori temporanei: distinguere timeout, errori di rete e risposte temporanee dagli errori permanenti.
  • Impostare sempre un limite: usare un numero massimo di tentativi e, se necessario, un tempo massimo complessivo.
  • Applicare il backoff: attendere progressivamente più a lungo riduce il carico sul servizio remoto.
  • Usare il jitter: un intervallo casuale evita che molti client effettuino il retry simultaneamente.
  • Registrare informazioni utili: in un’applicazione reale è preferibile usare il modulo logging invece di print.
  • Attenzione alle operazioni non idempotenti: ripetere un pagamento o l’invio di un ordine potrebbe creare duplicati. Quando possibile, usare chiavi di idempotenza.
  • Rispettare le indicazioni del servizio: alcune API restituiscono intestazioni come Retry-After, che indicano quanto attendere.
  • Testare i casi limite: verificare il successo immediato, il successo dopo alcuni tentativi e il fallimento definitivo.

Riepilogo

Il retry è una tecnica efficace per rendere più robuste le applicazioni Python che dipendono da risorse esterne. Una soluzione ben progettata limita il numero di tentativi, seleziona soltanto le eccezioni temporanee, applica un backoff esponenziale e conserva la causa originale dell’errore.

La gestione automatica dei tentativi non deve però sostituire una corretta progettazione. Prima di aggiungere un retry è necessario chiedersi se l’operazione può essere ripetuta senza conseguenze indesiderate e se l’errore è realmente temporaneo.

Approfondisci con risorse ufficiali

SHARE