Pattern per la gestione degli errori nelle API Go che standardizzano errori tipizzati, codici di stato HTTP, ID di richiesta e messaggi sicuri senza esporre dettagli interni.

Quando ogni endpoint segnala i fallimenti in modo diverso, i client smettono di fidarsi della tua API. Una rotta restituisce { "error": "not found" }, un'altra { "message": "missing" } e una terza invia testo semplice. Anche se il significato è simile, il codice client deve ora indovinare cosa è successo.
Il costo si manifesta rapidamente. I team scrivono logiche di parsing fragili e aggiungono casi speciali per ogni endpoint. I retry diventano rischiosi perché il client non riesce a distinguere “prova più tardi” da “il tuo input è sbagliato.” I ticket di supporto aumentano perché il client vede solo un messaggio vago e il tuo team non riesce facilmente a ricondurlo a una riga di log server.
Uno scenario comune: un'app mobile chiama tre endpoint durante la registrazione. Il primo restituisce HTTP 400 con una mappa di errori per campo, il secondo restituisce HTTP 500 con una stringa di stack trace e il terzo restituisce HTTP 200 con { "ok": false }. Il team dell'app spedisce tre handler diversi e il team backend continua a ricevere segnalazioni tipo “la registrazione a volte fallisce” senza indizi su dove iniziare.
L'obiettivo è un contratto prevedibile. I client dovrebbero poter leggere in modo affidabile cosa è successo: se è colpa loro o del server, se ha senso ritentare, e un ID di richiesta da incollare al supporto.
Nota di ambito: questo si concentra su API HTTP JSON (non gRPC), ma le stesse idee valgono ovunque tu ritorni errori ad altri sistemi.
Scegli un contratto chiaro per gli errori e applicalo a ogni endpoint. “Coerente” significa la stessa forma JSON, lo stesso significato dei campi e lo stesso comportamento indipendentemente da quale handler fallisca. Una volta fatto ciò, i client smettono di indovinare e cominciano a gestire gli errori.
Un contratto utile aiuta i client a decidere cosa fare dopo. Per la maggior parte delle app, ogni risposta di errore dovrebbe rispondere a tre domande:
Un set pratico di regole:
Decidi in anticipo cosa non deve mai apparire nelle risposte. Gli elementi comuni da escludere includono frammenti SQL, stack trace, nomi host interni, segreti e stringhe di errore grezze da dipendenze.
Mantieni una separazione pulita: un messaggio breve rivolto all'utente (sicuro, cortese, azionabile) e dettagli interni (errore completo, stack e contesto) conservati nei log. Per esempio, “Impossibile salvare le modifiche. Riprova.” è sicuro. “pq: duplicate key value violates unique constraint users_email_key” non lo è.
Quando ogni endpoint segue lo stesso contratto, i client possono costruire un unico handler di errori e riutilizzarlo ovunque.
I client possono gestire gli errori solo se ogni endpoint risponde con la stessa forma. Scegli un involucro JSON e mantienilo stabile.
Un default pratico è un oggetto error più un request_id a livello top:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
Lo status HTTP dà la categoria ampia (400, 401, 409, 500). Il error.code leggibile dalla macchina dà il caso specifico su cui il client può fare branching. Questa separazione è importante perché molti problemi diversi condividono lo stesso status. Un'app mobile può mostrare UI diversa per EMAIL_TAKEN vs WEAK_PASSWORD, anche se entrambi sono 400.
Mantieni error.message sicuro e leggibile. Deve aiutare l'utente a risolvere il problema, ma non deve mai rivelare interni (SQL, stack trace, nomi di provider, percorsi di file).
I campi opzionali sono utili quando restano prevedibili:
details.fields come mappa campo → messaggio.details.retry_after_seconds.details.docs_hint come testo semplice (non un URL).Per compatibilità retroattiva, tratta i valori di error.code come parte del contratto API. Aggiungi nuovi codici senza cambiare i significati vecchi. Aggiungi solo campi opzionali e supponi che i client ignorino campi non riconosciuti.
La gestione degli errori diventa disordinata quando ogni handler inventa il proprio modo di segnalare il fallimento. Un piccolo set di errori tipizzati risolve il problema: gli handler restituiscono tipi di errore noti e uno strato di risposta li trasforma in risposte coerenti.
Un set pratico di partenza copre la maggior parte degli endpoint:
La chiave è la stabilità a livello superiore, anche se la causa radice cambia. Puoi avvolgere errori di basso livello (SQL, rete, parsing JSON) restituendo lo stesso tipo pubblico che il middleware può rilevare.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
Nel tuo handler, restituisci NotFoundError{Resource: "user", ID: id, Err: err} invece di far trapelare direttamente sql.ErrNoRows.
Per verificare gli errori, preferisci errors.As per i tipi custom e errors.Is per errori sentinel. Gli errori sentinel (come var ErrUnauthorized = errors.New("unauthorized")) funzionano per casi semplici, ma i tipi custom vincono quando hai bisogno di contesto sicuro (per esempio quale risorsa mancava) senza cambiare il tuo contratto pubblico.
Sii rigoroso su cosa alleghi:
Err sottostante, informazioni sullo stack, errori SQL grezzi, token, dati utente.Questa separazione ti permette di aiutare i client senza esporre gli interni.
Una volta che hai errori tipizzati, il passo successivo è noioso ma essenziale: lo stesso tipo di errore dovrebbe sempre produrre lo stesso status HTTP. I client costruiranno logica attorno a questo.
Una mappatura pratica che funziona per la maggior parte delle API:
| Error type (example) | Status | When to use it |
|---|---|---|
| BadRequest (malformed JSON, missing required query param) | 400 | The request is not valid at a basic protocol or format level. |
| Unauthenticated (no/invalid token) | 401 | The client needs to authenticate. |
| Forbidden (no permission) | 403 | Auth is valid, but access is not allowed. |
| NotFound (resource ID does not exist) | 404 | The requested resource is not there (or you choose to hide existence). |
| Conflict (unique constraint, version mismatch) | 409 | The request is well-formed, but it clashes with current state. |
| ValidationFailed (field rules) | 422 | The shape is fine, but business validation fails (email format, min length). |
| RateLimited | 429 | Too many requests in a time window. |
| Internal (unknown error) | 500 | Bug or unexpected failure. |
| Unavailable (dependency down, timeout, maintenance) | 503 | Temporary server-side issue. |
Due distinzioni che evitano molta confusione:
Indicazioni sul retry:
Un request ID è un valore unico breve che identifica una chiamata API end-to-end. Se i client lo vedono in ogni risposta, il supporto diventa semplice: “Mandami il request ID” è spesso sufficiente per trovare i log esatti e il fallimento esatto.
Questa abitudine ripaga sia per risposte di successo che di errore.
Usa una regola chiara: se il client manda un request ID, conservalo. Altrimenti, creane uno.
X-Request-Id).Metti il request ID in tre posti:
request_id nello schema standard)Per endpoint batch o job in background, conserva un request ID padre. Esempio: un client carica 200 righe, 12 falliscono la validazione e tu metti in coda il lavoro. Restituisci un solo request_id per tutta la chiamata e includi un parent_request_id su ogni job e su ogni errore per item. Così puoi tracciare “un upload” anche quando si espande in molti task.
I client hanno bisogno di una risposta di errore chiara e stabile. I tuoi log hanno bisogno della verità incasinata. Mantieni separati questi due mondi: ritorna un messaggio pubblico sicuro e un codice errore, mentre logghi la causa interna, lo stack e il contesto sul server.
Registra un evento strutturato per ogni risposta di errore, ricercabile tramite request_id.
Campi che vale la pena mantenere coerenti:
Conserva i dettagli interni solo nei log server (o in uno store di errori interno). Il client non dovrebbe mai vedere errori database grezzi, query, stack trace o messaggi dei provider. Se hai più servizi, un campo interno come source (api, db, auth, upstream) può velocizzare il triage.
Controlla endpoint rumorosi e errori rate-limited. Se un endpoint può produrre lo stesso 429 o 400 migliaia di volte al minuto, evita lo spam nei log: campiona eventi ripetuti o abbassa la severità per errori attesi continuando però a contarli nelle metriche.
Le metriche catturano i problemi prima dei log. Traccia conteggi raggruppati per status HTTP e codice errore, e alert su picchi improvvisi. Se RATE_LIMITED sale di 10x dopo un deploy, lo vedrai rapidamente anche se i log sono campionati.
Il modo più semplice per rendere gli errori coerenti è smettere di gestirli “ovunque” e instradarli attraverso una piccola pipeline. Quella pipeline decide cosa vede il client e cosa conservi per i log.
Inizia con un piccolo set di codici di errore su cui i client possono contare (per esempio: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Avvolgili in un errore tipizzato che espone solo campi pubblici e sicuri (code, messaggio sicuro, dettagli opzionali come quale campo è sbagliato). Mantieni le cause interne private.
Poi implementa una funzione traduttrice che converte qualsiasi errore in (statusCode, responseBody). Qui i tipi tipizzati mappano agli status HTTP e gli errori sconosciuti diventano una sicura risposta 500.
Aggiungi poi middleware che:
request_idUn panic non dovrebbe mai scaricare stack trace al client. Restituisci un normale 500 con un messaggio generico e logga il panic completo con lo stesso request_id.
Infine, modifica i tuoi handler in modo che restituiscano un error invece di scrivere direttamente la risposta. Un wrapper può chiamare l'handler, eseguire il traduttore e scrivere il JSON nello standard.
Una checklist compatta:
I test golden sono importanti perché bloccano il contratto. Se qualcuno cambia un messaggio o un codice di stato, i test falliscono prima che i client vengano sorpresi.
Immagina un endpoint: un'app client crea un record cliente.
POST /v1/customers con JSON come { "email": "[email protected]", "name": "Pat" }. Il server restituisce sempre la stessa forma di errore e include sempre un request_id.
L'email manca o è malformata. Il client può evidenziare il campo.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
L'email esiste già. Il client può suggerire di accedere o sceglierne un'altra.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Una dipendenza è giù. Il client può ritentare con backoff e mostrare un messaggio calmo.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Con un solo contratto, il client reagisce in modo coerente:
details.fieldsrequest_id come ID per il supportoPer il supporto, lo stesso request_id è il percorso più rapido per trovare la causa reale nei log interni, senza esporre stack trace o errori di database.
Il modo più rapido per infastidire i client API è farli indovinare. Se un endpoint ritorna { "error": "..." } e un altro { "message": "..." }, ogni client si trasforma in una catasta di casi speciali e i bug restano nascosti per settimane.
Alcuni errori ricorrenti:
code stabile su cui i client possano affidarsi.request_id solo sui fallimenti, così non puoi correlare una segnalazione utente con la chiamata di successo che ha innescato il problema successivo.Far trapelare gli interni è la trappola più facile. Un handler restituisce err.Error() perché è comodo, e poi un nome di vincolo o un messaggio di terze parti finisce in produzione. Mantieni il messaggio client breve e sicuro e metti la causa dettagliata nei log.
Affidarsi solo al testo è un altro problema lento. Se il client deve parsare frasi in inglese come “email already exists”, non puoi cambiare la formulazione senza rompere la logica. I codici di errore stabili ti permettono di aggiustare i messaggi, tradurli e mantenere il comportamento coerente.
Tratta i codici di errore come parte del contratto pubblico. Se devi cambiarne uno, aggiungi un nuovo codice e mantieni quello vecchio funzionante per un po’, anche se entrambi mappano allo stesso status HTTP.
Infine, includi lo stesso campo request_id in ogni risposta, successo o fallimento. Quando un utente dice “prima funzionava, poi ha smesso”, quell'ID spesso salva ore di indagini.
Prima del deploy, fai un controllo rapido per la coerenza:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Aggiungi test così gli handler non possono restituire codici sconosciuti per errore.request_id e registralo per ogni richiesta, inclusi panic e timeout.Dopo di ciò, verifica manualmente alcuni endpoint. Genera un errore di validazione, una risorsa mancante e un errore inaspettato. Se le risposte sono diverse tra endpoint (campi che cambiano, status che divergono, messaggi che sovraespongono), correggi la pipeline condivisa prima di aggiungere altre funzionalità.
Una regola pratica: se un messaggio aiuterebbe un attaccante o confonderebbe un utente normale, appartiene ai log, non alla risposta.
Scrivi il contratto di errore che vuoi che tutti gli endpoint seguano, anche se la tua API è già live. Un contratto condiviso (status, codice errore stabile, messaggio sicuro e request_id) è il modo più rapido per rendere gli errori prevedibili per i client.
Poi migra gradualmente. Mantieni gli handler esistenti, ma instrada i loro fallimenti attraverso un mapper che converte gli errori interni nella forma pubblica. Questo migliora la coerenza senza una riscrittura rischiosa e impedisce ai nuovi endpoint di inventare nuovi formati.
Tieni un piccolo catalogo di codici di errore e trattalo come parte della tua API. Quando qualcuno vuole aggiungerne uno nuovo, fai una rapida revisione: è veramente nuovo, è nominato chiaramente e mappa al corretto status HTTP?
Aggiungi alcuni test che catturino la deriva:
request_id.error.code è presente e proviene dal catalogo.error.message resta sicuro e non include dettagli interni.Se stai costruendo un backend Go da zero, può aiutare bloccare il contratto presto. Per esempio, Koder.ai (koder.ai) include una modalità di pianificazione dove puoi definire convenzioni come uno schema di errore e un catalogo di codici fin dall'inizio, quindi mantenere gli handler allineati man mano che l'API cresce.
Usa una sola forma JSON per tutte le risposte di errore, in tutti gli endpoint. Un'impostazione pratica è un request_id in cima e un oggetto error con code, message e opzionali details, così i client possono parsare e reagire in modo affidabile.
Restituisci error.message come una frase breve e sicura per l'utente e conserva la causa reale nei log del server. Non ritornare errori grezzi del database, stack trace, host interni o messaggi di dipendenze, anche se possono aiutare in sviluppo.
Usa un error.code stabile per la logica macchina e lascia che lo status HTTP descriva la categoria ampia. I client dovrebbero fare branching su error.code (ad es. ALREADY_EXISTS) e usare lo status come guida (ad es. 409 indica conflitto di stato).
Usa 400 quando la richiesta non può essere interpretata in modo affidabile (JSON malformato, tipi sbagliati). Usa 422 quando la richiesta è ben formata ma viola regole di business (formato email non valido, password troppo corta).
Usa 409 quando l'input è valido ma non può essere applicato a causa dello stato corrente (email già usata, conflitto di versione). Usa 422 per validazioni a livello di campo dove cambiare il valore risolve il problema senza dipendere dallo stato del server.
Crea un piccolo insieme di errori tipizzati (validation, not found, conflict, unauthorized, internal) e fai sì che gli handler li restituiscano. Poi usa un traduttore condiviso per mappare quei tipi in status HTTP e nella forma JSON standard.
Restituisci sempre un request_id in ogni risposta, sia di successo sia di errore, e registralo in ogni riga di log del server. Se un client segnala un problema, quell'ID dovrebbe essere sufficiente per trovare il percorso esatto del fallimento nei log.
Restituisci 200 solo quando l'operazione è riuscita, e usa 4xx/5xx per gli errori. Nascondere errori dietro 200 obbliga i client a parsare campi nel body e crea comportamenti incoerenti tra endpoint.
Di norma non riprovare per 400, 401, 403, 404, 409 e 422 perché il retry non aiuta senza modifiche. Permetti il retry per 503 e talvolta per 429 dopo aver aspettato; se supporti chiavi di idempotenza, i retry diventano più sicuri per POST su errori transitori.
Blocca il contratto con alcuni test “golden” che asseriscano status, error.code e presenza di request_id. Aggiungi nuovi codici senza cambiare i significati vecchi e aggiungi solo campi opzionali così i client più vecchi continuano a funzionare.