Non sei tu, è il mio catch: la posta del cuore delle eccezioni
Catch vuoti, except nudi, retry infiniti, cause perse e return dentro finally: la Dottoressa Finally risponde alle lettere del vostro codice. Perché catch non è sempre la soluzione.
Benvenuti alla Posta del cuore del runtime, la rubrica in cui il vostro codice racconta i suoi problemi di coppia e una professionista prova a rimettere insieme i pezzi. Risponde la Dottoressa Finally: trent'anni di esperienza nella gestione delle eccezioni e un'unica certezza, cioè che catch non è sempre la risposta giusta.
Perché un try/catch è una relazione a tutti gli effetti. C'è qualcuno che lancia un problema, qualcuno che lo riceve, e la domanda di sempre: ne parliamo adesso o facciamo finta di niente? Passiamo alle lettere.
«Lui non mi dice mai niente»
Cara Dottoressa, il mio collega ha scritto questo tre anni fa. Da allora gli ordini spariscono e nessuno capisce perché. — Giulia, 31, backend Java
try {
ordini.salva(ordine);
} catch (Exception e) {
// non dovrebbe mai succedere
}Cara Giulia, questa è la relazione in cui i problemi non si affrontano mai. Il database è giù, la connessione è scaduta, il disco è pieno, ma lui risponde «tutto bene, perché?». Il programma continua come se l'ordine fosse stato salvato, l'utente riceve la conferma e il cliente non riceverà mai il pacco. Il problema non è sparito: è stato rimandato, e quando tornerà fuori nessuno saprà più dove cercarlo.
In gergo si chiama Pokémon exception handling, perché «gotta catch 'em all». Il commento «non dovrebbe mai succedere» è l'equivalente di «ma noi non litighiamo mai»: è vero solo finché nessuno controlla.
La regola: se catturate un'eccezione dovete fare qualcosa: recuperare davvero, aggiungere contesto e rilanciarla, oppure registrarla e rispondere in modo sensato. Un blocco catch vuoto non è gestione dell'errore. È ghosting.
«È gelosa: intercetta tutte le mie chiamate»
Dottoressa, il mio worker Python non si ferma più. Né con Ctrl+C, né consys.exit(). Devo usarekill -9. — Marco, 27, data engineer
while True:
try:
elabora_prossimo_batch()
except: # "except" nudo: cattura TUTTO
passCaro Marco, il tuo except è il partner geloso che risponde a tutte le telefonate, comprese quelle dei vigili del fuoco. In Python un except: senza tipo cattura BaseException, cioè anche KeyboardInterrupt (Ctrl+C) e SystemExit (sys.exit()). In pratica il programma non ti lascia andare via.
La gerarchia delle eccezioni è fatta così proprio per evitarlo: Exception raccoglie gli errori "normali", mentre le richieste di chiusura stanno fuori. Dalla versione 3.8 anche asyncio.CancelledError deriva da BaseException, quindi un except Exception non blocca più per sbaglio la cancellazione di un task. Ma un except BaseException sì.
In Java vale lo stesso discorso con catch (Throwable t), che cattura anche OutOfMemoryError e StackOverflowError. Sono situazioni da cui la JVM spesso non può riprendersi in modo affidabile. E se catturate InterruptedException, ripristinate il flag. Altrimenti chi voleva fermare il thread non lo saprà mai:
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // "ho sentito, lo dico anche agli altri"
return;
}La regola: catturate il tipo più specifico che sapete gestire. FileNotFoundError, non Exception. La gelosia non è una strategia di gestione degli errori.
«Racconta i nostri litigi a tutti»
Dottoressa, quando qualcosa va storto nel nostro servizio lo stesso stack trace compare quattro volte nei log, a livelli diversi. Durante le reperibilità notturne ci sembra di avere quattro incidenti. — Il team SRE di un'azienda che preferisce restare anonima
// repository
catch (SQLException e) { log.error("errore DB", e); throw e; }
// service
catch (SQLException e) { log.error("errore salvataggio", e); throw e; }
// controller
catch (Exception e) { log.error("errore richiesta", e); throw e; }Cari SRE, questa è la coppia che, dopo ogni discussione, la racconta alla madre, al migliore amico, al collega e al barista. Alla fine tutti conoscono il litigio in quattro versioni, e nessuno ha capito come è finito. Ogni livello registra l'errore e lo rilancia, quindi un solo problema produce quattro voci nei log, quattro allarmi e un'ora persa a capire che si trattava di una cosa sola.
La regola: o gestite un errore, o lo propagate. Non tutte e due le cose. Si registra una volta sola, nel punto in cui l'errore viene davvero gestito, che di solito è il confine del sistema: l'handler HTTP, il loop principale, il consumer della coda. Se un livello intermedio ha qualcosa da aggiungere, aggiunga contesto, non una riga di log in più.
«Mi ha lasciata con un messaggio di due parole»
Dottoressa, nei log trovo solo RuntimeException: Errore DB. Nessun dettaglio, nessuna causa. Cosa è successo? — Francesca, 35, tech leadcatch (SQLException e) {
throw new RuntimeException("Errore DB"); // e la causa?
}Cara Francesca, sei stata lasciata con un SMS: «Non funziona. Ciao». L'eccezione originale, con il codice SQL, il vincolo violato e lo stack trace, è stata buttata via. Tradurre un'eccezione in una di livello più alto va benissimo (è il pattern Adapter applicato agli errori), ma la causa va allegata. Ogni linguaggio ha il suo modo per farlo:
throw new OrdineNonSalvato("salvataggio ordine " + id, e); // Java: la causa resta collegataraise OrdineNonSalvato(f"salvataggio ordine {id}") from e # Python: "The above exception was the direct cause..."return fmt.Errorf("salvataggio ordine %d: %w", id, err) // Go: %w, poi errors.Is / errors.AsIn Python esiste anche raise ... from None, che nasconde di proposito la causa. È il ghosting consapevole: a volte ha senso, per esempio quando la causa contiene dati sensibili, ma bisogna sapere perché lo si fa.
«Continuiamo a lasciarci e a tornare insieme»
Dottoressa, il nostro client riprova all'infinito quando il servizio dei pagamenti non risponde. Ieri il servizio si è ripreso e lo abbiamo subito steso di nuovo. E un cliente ha pagato tre volte. — Luca, 40, CTO di una startup
while True:
try:
return addebita(carta, importo)
except Exception:
continue # riproviamo, magari stavolta vaCaro Luca, questo è il tira e molla con l'ex, versione distribuita. Tre problemi:
- Nessuna pausa. Mille client che riprovano tutti insieme nello stesso istante generano una tempesta di richieste (thundering herd) che abbatte il servizio appena si rialza.
- Nessuna distinzione. Un timeout può essere temporaneo. «Carta rifiutata» no. Riprovare un errore definitivo è come richiamare chi ti ha bloccato: non cambia niente, ma fa più rumore.
- Nessuna idempotenza. Se la prima richiesta è arrivata e solo la risposta si è persa, il secondo tentativo addebita di nuovo. Da qui i tre pagamenti.
import random, time
def con_retry(op, tentativi=5, base=0.2):
for n in range(tentativi):
try:
return op()
except (TimeoutError, ConnectionError): # solo errori transitori
if n == tentativi - 1:
raise
time.sleep(base * 2 ** n * random.uniform(0.5, 1.5)) # backoff esponenziale + jitter
con_retry(lambda: addebita(carta, importo, idempotency_key=ordine.id))Aggiungete un circuit breaker: dopo troppi fallimenti smette di provare per un po', esattamente come l'interruttore di casa. A volte la cosa più sana è prendersi una pausa.
«Usa le crisi per decidere dove andare a cena»
Dottoressa, il mio parser controlla se una stringa è un numero provando a convertirla e catturando l'eccezione. Lo fa milioni di volte al secondo. Il profiler dice che passiamo il 40% del tempo a costruire stack trace. — Andrea, 29, sviluppatore
Caro Andrea, le eccezioni servono per le situazioni eccezionali, non per il traffico ordinario. In Java creare un'eccezione costa: il costruttore chiama fillInStackTrace(), che percorre tutto lo stack. In C++ il modello "a costo zero" non costa nulla quando tutto va bene, ma lanciare un'eccezione costa moltissimo, perché lo srotolamento dello stack passa per tabelle e ricerche. È come provocare una crisi di coppia ogni sera per decidere tra pizza e sushi: si arriva a una risposta, ma il prezzo è alto.
Ci sono però differenze culturali. In Python lo stile EAFP (Easier to Ask Forgiveness than Permission) è idiomatico: try: d[k] except KeyError: è normale, e perfino il ciclo for finisce con un'eccezione, StopIteration. Ogni linguaggio ha le sue usanze. L'importante è non importare quelle di un altro senza capirle.
«Ho scoperto che stava con me solo per abitudine»
Dottoressa, abbiamo un catch (NullPointerException e) che "risolve" un crash in produzione. Da allora i dati di alcuni utenti sono sbagliati, ma nessuno sa dire quali. — Sara, 33, QACara Sara, un NullPointerException, un IndexError o un'asserzione fallita non sono eventi della vita da gestire: sono bug. Catturarli tiene in piedi un programma il cui stato interno è già sbagliato, e da lì in poi ogni operazione rischia di scrivere dati corrotti. È la relazione che va avanti per inerzia: nessuno ha più fiducia, ma nessuno vuole essere il primo a dirlo.
Ci sono interi ecosistemi costruiti sul principio opposto. Erlang ed Elixir hanno la filosofia let it crash: il processo che si trova in uno stato inatteso muore subito, e un supervisor ne avvia uno nuovo, pulito. Rust distingue nettamente tra Result (un errore previsto, da gestire) e panic! (un bug, che si propaga). Su un server, systemd con Restart=on-failure e Kubernetes con i suoi restart fanno la stessa cosa a livello di processo.
La regola: i bug non si catturano, si correggono. A volte la cosa più sana è lasciarsi: un crash pulito e un riavvio sono meglio di una convivenza con dati corrotti.
«Se n'è andato senza restituirmi le chiavi»
Dottoressa, dopo qualche ora il servizio finisce i file descriptor. Ogni volta che una richiesta fallisce, un file resta aperto. — Paolo, 38, sysadmin
Caro Paolo, anche quando una relazione finisce male, le chiavi vanno restituite. Il rilascio delle risorse non dipende da come è andata: per questo esistono finally e, ancora meglio, i costrutti che lo rendono automatico. with in Python, try-with-resources in Java, defer in Go, RAII in C++ e Rust:
with open(percorso) as f: # il file si chiude anche se qualcosa va storto
dati = analizza(f)Attenzione però a una trappola poco conosciuta: un return dentro finally fa sparire l'eccezione in corso.
def salva():
try:
raise IOError("disco pieno")
finally:
return "tutto ok" # l'IOError sparisce: nessuno lo saprà maiÈ il "tutto ok" detto con un sorriso mentre la casa brucia. Succede anche in Java. Python ha deciso di intervenire: con la PEP 765, Python 3.14, in uscita il mese prossimo, emetterà un SyntaxWarning per return, break e continue che escono da un blocco finally.
Allora quando si fa il catch? (Le coppie che funzionano)
Dopo tante lettere tristi, una buona notizia: il catch giusto esiste, ed è quello che ha un piano.
- Ai confini del sistema. L'handler HTTP che trasforma un'eccezione in una risposta 500 con un identificativo di correlazione, la CLI che stampa un messaggio chiaro ed esce con un codice diverso da zero, l'interfaccia che mostra «riprova più tardi». Qui si registra l'errore, una volta sola.
- Quando c'è un piano B reale. Il file di configurazione utente non esiste (
FileNotFoundError, nonException)? Usate i valori predefiniti. La cache remota non risponde? Leggete dal database. - Per tradurre tra livelli, collegando la causa:
SQLExceptiondiventaOrdineNonSalvato, così il livello di dominio non deve conoscere i dettagli SQL. - Per aggiungere contesto: «salvataggio ordine 4812» è molto più utile di «errore».
Alcuni linguaggi hanno scelto di non usare proprio le eccezioni per gli errori previsti. Go usa if err != nil: una comunicazione esplicita a ogni passo, un po' noiosa ma onesta. Rust usa Result e l'operatore ?: si dice subito che qualcosa non va, senza drammi, e si passa il problema a chi può risolverlo. Java ha le checked exception, una specie di contratto prematrimoniale: le clausole sono scritte nella firma del metodo, e da trent'anni si discute se sia una buona idea. C# e Kotlin, infatti, hanno deciso di farne a meno.
Il decalogo della coppia sana
- Catturate solo ciò che sapete gestire.
- Catturate il tipo più specifico, mai
except:nudo oThrowable. - O gestite o propagate: non tutte e due.
- Registrate l'errore una volta sola, al confine del sistema.
- Non perdete mai la causa:
from e,%w,new X(msg, e). - Riprovate solo gli errori transitori, con backoff, jitter, un limite e l'idempotenza.
- I bug non si catturano: si correggono.
- Rilasciate le risorse con
with,defero RAII, e mai unreturndentrofinally. - Le eccezioni sono per le eccezioni, non per il controllo del flusso (salvo usanze locali).
- A volte la cosa più sana è lasciarsi: un crash pulito e un buon supervisor.
Con affetto e uno stack trace completo,
la Dottoressa Finally 💌