Strategie di retry e traceback estesi con Python

In questo post presenteremo alcune soluzioni pratiche e generiche a problemi concreti che possono essere incontrati nella vita di tutti i giorni da uno sviluppatore Python e che sono troppo brevi per meritare un pacchetto dedicato, ma allo stesso tempo troppo specifiche per trovare posto nella libreria standard.

Strategia di retry su richieste http

Qualunque server che esponga una API REST http degna di questo nome usa in maniera appropriata gli status code, per informare il client sull’esito della richiesta fatta. Di particolare interesse per un client di una API è la gestione degli errori appartenenti alla famiglia 4xx, ovvero quelli dove il server segnala un problema sulla richiesta fatta dal client.

In questi casi il server è tenuto a specificare se la situazione di errore è permanente o soltanto temporanea: prendiamo ad esempio il caso del codice 429 Too Many Requests, tipicamente usato per segnalare al client il superamento di una quota o di un rate di richieste. La richiesta è fallita, ma avrà successo se eseguita oltre il rinnovo del quota.

Vediamo una soluzione funzionale ma ingenua al problema di impostare un retry, che usa la nota libreria requests. Ai fini di questa trattazione, assumiamo che il task di richiesta giri in maniera asincrona rispetto al thread principale (ad esempio usando un meccanismo di job distribuiti come Celery in contesto web, oppure un task eseguito tramite timer in contesto desktop) e che sia possibile invocare un tentativo di retry sollevando un’eccezione con tale semantica:

r = requests.get('https://api.github.com/events')
if r.status_code == 429:
    # Assume che il rinnovo del quota avvenga entro il minuto.
    raise RetryException(interval=60)

Questo codice funziona, è molto semplice e risolve il problema presentato. La realtà tuttavia è molto più complicata: ad esempio, è prassi comune che il tempo da attendere prima del rinnovo del quota venga indicato dal server nel body della risposta. Non ci perdiamo d’animo e aggiorniamo il nostro codice:

r = requests.get('https://api.github.com/events')
if r.status_code == 429:
    response_body = json.loads(r.text)
    try:
        retry_interval = response_body['retry_interval']
    except KeyError:
        # Assume che il rinnovo del quota avvenga entro il minuto.
        retry_interval = 60
    raise RetryException(retry_interval)

Purtroppo non è ancora abbastanza. C’è anche la possibilità che le richieste vadano in timeout, altro caso in cui si può applicare una strategia di retry:

try:
    r = requests.get('https://api.github.com/events')
except requests.exceptions.ConnectTimeout as exc:
    # Attendiamo 2 minuti in occasione di un timeout.
    raise RetryException(120)

if r.status_code == 429:
    response_body = json.loads(r.text)
    try:
        retry_interval = response_body['retry_interval']
    except KeyError:
        # Assume che il rinnovo del quota avvenga entro il minuto.
        retry_interval = 60
    raise RetryException(retry_interval)

La logica così strutturata sta diventando complicata (ci sono già due punti diversi in cui il codice solleva l’eccezione di retry) senza contare che timeout su endpoint diversi potrebbero necessitare di tempi diversi di retry; inoltre, non abbiamo considerato che esistono altri errori 4xx che potremmo voler gestire con una strategia di retry (ad esempio, il 409 Conflict). Come possiamo mappare questa logica su una struttura più efficace?

Context manager

La libreria standard di Python ci viene incontro offrendoci i context manager, che tramite l’operatore with permettono di eseguire una funzione all’interno di un contesto controllato. Per meglio comprendere lo strumento, si consideri come esempio il seguente codice:

@contextmanager
def time_logger():
    """Stampa il tempo che il codice controllato impiega per essere eseguito."""

    # Prologo: eseguito in entrata del contesto
    start = time.time()

    # Passaggio del controllo al client
    yield

    # Epilogo: eseguito in uscita dal contesto
    end = time.time()
    print("elapsed time: {:.2f}s".format(end - start))

with time_logger():
    s = sum(x for x in xrange(10000000))

L’output sarà qualcosa del genere:

elapsed time: 0.79s

Come risulta dall’esempio presentato, il context manager si compone essenzialmente di tre parti: il prologo, il passaggio del controllo (statement yield) e l’epilogo; qualunque stato impostato dal prologo si mantiene durante l’esecuzione del codice controllato, e al termine di quest’ultimo verrà eseguito l’epilogo.

Retry strategy

Oltre ad averci permesso di costruire un profiler primitivo ma efficace, i context manager possono essere applicati ai nostri scopi per raccogliere una volta per tutte la logica di retry, e parametrizzarla in funzione dei tipi di errore e timeout. Questa è l’interfaccia che ci aspettiamo di poter usare:

# A ciascun tipo di eccezione associa l'intervallo in secondi da aspettare
# prima del tentativo successivo.
retry_delays = {
    client_api.TimeoutException: 120,
    client_api.TooManyRequestsException: "retry_interval",
}

with retry_strategy(retry_delays):
    client_api.do_request()

In questo scenario, supponiamo di avere implementato un client per la API REST a cui ci vogliamo interfacciare, e che questo definisca delle proprie eccezioni. Una di queste in particolare, TooManyRequestsException, renderà accessibile il tempo di retry specificato dal server (che probabilmente in origine si trovava nel body della response, ma che risulta comodo esporre al codice chiamante).

Vediamo come procedere all’implementazione della strategia di retry, che con le premesse fatte risulta semplice e lineare:

@contextmanager
def retry_strategy(delays):
    """Definisce una strategia di retry per il codice controllato, facendo in
    modo che gli intervalli di retry siano dipendenti dal tipo di eccezione.

    :param delays: mappa che associa il tipo di eccezione da gestire ad un
        valore intero (secondi da attendere prima di riprovare) o ad una
        stringa (attributo dell'oggetto eccezione che contiene il tempo
        in secondi prima di riprovare).
    """
    try:
        yield
    except tuple(delays) as exc:

        for handled_exc in delays:
            if isinstance(exc, handled_exc):
                delay = delays[handled_exc]

        if isinstance(delay, int):
            # L'intervallo da attendere è specificato dal client
            retry_interval = delay
        else:
            # L'intervallo si trova all'interno dell'oggetto eccezione
            retry_interval = exc[delay]

        # Innesca il tentativo di retry.
        raise RetryException(retry_interval)

Il passaggio di controllo viene inserito all’interno di una clausola try/except, che permetterà di intercettare le eccezioni definite come chiavi del dizionario delays.

Nel blocco successivo viene recuperato l’intervallo di tempo da attendere prima del prossimo retry. Infine, il context manager innesca il tentativo di retry.

Le parti più complesse della logica sono così “nascoste” e scritte una volta per tutte dentro il context manager, mentre il client deve semplicemente preoccuparsi di definire quando e quanto aspettare, ed eseguire la richiesta nel contesto.

Ulteriori sviluppi

La struttura così definita si presta bene come base per ulteriori sviluppi, che non verranno trattati nel contesto di questo post e vengono lasciati come esercizio al lettore. Ad esempio, una funzionalità che ci si potrebbe aspettare da una retry strategy è la possibilità di definire un tempo di attesa che cresce esponenzialmente su un errore di timeout, fino ad un massimo di tentativi previsti.

Deluxe exception hook

Uno dei vantaggi indiscutibili della gestione degli errori mediante eccezioni è che non è possibile dimenticarsi di controllare uno stato di errore. Se si verifica un errore in un programma Python allora un’eccezione viene propagata lungo lo stack del programma, e se essa non viene gestita e arriva in fondo allo stack il programma termina automaticamente, mostrando un traceback.

Il traceback è un primo passo per capire cosa è andato storto e porvi rimedio, ma spesso sapere solo la sequenza di stack frame che hanno portato alla situazione di errore non è sufficiente per riprodurre un crash.

Per questa ragione, molti framework e alcuni microframework web includono un gestore custom delle eccezioni Python, che tramite interfaccia html mostra sia gli stack frame che i valori delle variabili locali in essi riferiti. In molti di questi casi è anche possibile interagire col contesto del server al momento del crash tramite un interprete interattivo.

La possibilità di avere un traceback arricchito di questo tipo è un lusso che i programmatori backend hanno imparato a dare per scontato, ma che per varie ragioni non è ugualmente diffuso in contesto desktop o embedded. È un vero peccato, visto che data la natura di questo settore, spesso un traceback è l’unico sistema per capire come riprodurre un crash avvenuto su un computer dall’altra parte del mondo. Vediamo cosa è possibile fare per colmare questa lacuna.

Exception hook

Il comportamento di Python quando un’eccezione arriva in fondo allo stack è implementato dalla funzione sys.excepthook, che è invocata prima che l’interprete termini.

Un exception hook può essere definito semplicemente ridefinendo sys.excepthook ed impostandone il valore ad una funzione da noi realizzata. L’interprete la chiamerà automaticamente passando tipo, valore e traceback dell’eccezione. Ecco un esempio minimale di un exception hook, insieme all’output di un programma che lo usa:

import sys

def exception_hook(e_type, e_value, e_traceback):
    print("Exception hook")
    print("Type: {}".format(e_type))
    print("Value: {}".format(e_value))
    print("Traceback: {}".format(e_traceback))

sys.excepthook = exception_hook

1 / 0



Type: 
Value: integer division or modulo by zero
Traceback: 

Dall’output, diverso da quello familiare ai programmatori Python, si deduce che l’exception hook di default non è stato chiamato, in quanto sovrascritto dalla nostra versione custom. I primi due argomenti sono il tipo e il valore dell’eccezione.

Il terzo argomento e_traceback è quello che ci interessa maggiormente, in quanto contiene le informazioni sugli stack frame attraversati dall’eccezione. Vorremmo usarlo come base per implementare un nostro exception hook che, oltre alle informazioni già riportate da quello di default, includa tutti i valori delle variabili locali.

Un exception hook più completo

L’oggetto traceback espone gli stack frame attraversati dall’eccezione. Per prima cosa scriviamo una funzione di utilità che li accumuli tutti:

def extract_stack(tb):
    stack = []

    if not tb:
        return stack

    while tb.tb_next:
        tb = tb.tb_next

    f = tb.tb_frame

    while f:
        stack.append(f)
        f = f.f_back

    stack.reverse()
    return stack

Fatto questo, è possibile visitare in ordine i frame ed ispezionarne il membro f_locals, che contiene il dizionario di nomi e valori delle variabili locali:

def exception_hook(e_type, e_value, e_traceback):

    # Recuperiamo gli stack frame
    stack = extract_stack(e_traceback)

    here = os.path.split(__file__)[0]

    # Informazioni di debug che popoleremo
    info = []

    for frame in stack:

        # Recupera il percorso del file corrente
        fn = os.path.relpath(frame.f_code.co_filename, here)

        # Aggiunge nome del modulo, funzione e riga di codice
        # alle informazioni di debug
        info.append("  {}, {} +{}".format(frame.f_code.co_name,
                                     fn, frame.f_lineno))

        # Salva i valori delle variabili locali
        for key, value in frame.f_locals.iteritems():
            info.append("   {} = {}".format(key, value))

Riproduciamo anche il comportamento dell’exception hook originale, che consiste nello stampare il traceback dell’eccezione.

    s_traceback = StringIO()
    traceback.print_exception(e_type, e_value, e_traceback, file=s_traceback)
    info.append(s_traceback.getvalue())

    # Stampa a video le informazioni raccolte
    print '\n'.join(info)

Vediamo come si comporta il nostro exception hook su un programma di test:

# Installiamo il nuovo exception hook
sys.excepthook = exception_hook

def dbz(d):
    c = 1
    return c / d

def run():
    d = 0
    dbz(d)

run()

Oltre a diversi valori poco interessanti definiti nel main module, l’output presenterà anche questa sezione, che permette di individuare non solo la causa del problema, ma anche di vedere entrambi i valori coinvolti nell’operazione di divisione.

  , exc.py +88
   run = 
   StringIO = StringIO.StringIO
   [...]
  run, exc.py +86
   d = 0
  dbz, exc.py +82
   c = 1
   d = 0

A questo punto viene naturale porsi il dubbio di cosa succeda se il codice dell’exception hook solleva a propria volta un’eccezione. In tal caso, l’interprete manterrà entrambe le eccezioni, riportandole tramite l’exception hook di default; va comunque da sé che quando si scrive codice di questo tipo ha senso usare pratiche di programmazione difensiva, anche per permetterci sempre di usare le informazioni raccolte a fini di reporting.

Infine, un passo che ritengo obbligatorio se si intraprende questa strada è aggiungere all’exception hook della logica per prelevare informazioni specifiche della vostra applicazione. Ad esempio, il nome progetto attualmente aperto, lo strumento in uso, la posizione del mouse, l’ultima lettura da seriale, etc… Qualunque elemento del vostro dominio che aiuti ad identificare un crash deve essere iterativamente aggiunto al sistema di reporting.

Cerchi un corso su Python?

Scopri i nostri corsi per aziende

Maggiori informazioni

Crash reporting

Con poco sforzo abbiamo costruito un gestore di eccezioni che fornisce molte più informazioni rispetto a quello di default.

Il prossimo passo logico è quello di raccoglierle e fare in modo che i programmatori possano consultarle per intervenire sulle cause. Non dimentichiamo infatti che il contesto in cui supponiamo di lavorare è quello in cui l’ambiente del crash è una macchina desktop da qualche parte nel mondo, al quale i programmatori non hanno accesso diretto.

Una prima soluzione consiste banalmente nel salvare il report dell’eccezione in locale. Nel caso di una segnalazione di errore, si potrà chiedere all’utente di inviare via e-mail i file contenuti nella directory scelta come destinazione; oppure, se ci troviamo su un sistema desktop, potremmo aprire automaticamente il client di posta dell’utente, precompilando il corpo del messaggio con le informazioni relative al crash.

Un’estensione naturale di queste soluzioni consiste nell’inviare i report ad un servizio di reportistica esposto su Internet; questi sono tipicamente composti da due parti, una parte client ed una server. La prima consiste in un modulo per il linguaggio che si sta usando nell’applicazione, che implementa un’interfaccia quanto più semplice possibile per inviare le segnalazioni. La parte server svolge invece il compito di esporre uno o più endpoint http per l’invio delle segnalazioni, ed oltre a memorizzare queste ultime le rende elencabili e ispezionabili da remoto.

In Develer a questo scopo usiamo Sentry che è molto diffuso e facilmente integrabile con una gran quantità di piattaforme, oltre ad essere open source e installabile sui propri server. Il codice che dobbiamo implementare lato client è sorprendentemente semplice:

from raven import Client
from raven.base import ClientState

def exception_hook(e_type, e_value, e_traceback):
    [...]

    # SENTRY_DSN è fornito da Sentry ed è un endpoint specifico
    # per il vostro progetto
    sentry_client = Client(SENTRY_DSN, timeout=SENTRY_TIMEOUT)

    # Processiamo l'eccezione. get_additional_data() può essere
    # implementata seguendo la linea guida del precedente exception
    # hook, e deve includere le informazioni specifiche del dominio della
    # vostra applicazione.
    sentry_client.captureException((e_type, e_value, e_traceback), extra=get_additional_data())

    if sentry_client.state.status != ClientState.ERROR:
        # Se il report è stato caricato correttamente su Sentry,
        # mostriamo un messaggio che informa dell'errore dell'applicazione,
        # e usciamo.
    else:
        # Altrimenti, memorizziamo comunque il contenuto del traceback
        # su un file.

Conclusioni

Entrambi gli argomenti presentati prendono spunto da casi concreti, che sono stati realmente incontrati su progetti in produzione. La notevole espressività di Python ha permesso in entrambi i casi di creare due piccole “ricette” riusabili anche per altri progetti, a fronte di uno sforzo di astrazione davvero minimo.