Go API-foutafhandelingspatronen die getypeerde fouten, HTTP-statuscodes, request-ID-correlatie en veilige berichten standaardiseren zonder interne details te lekken.

Als elk endpoint fouten op een andere manier meldt, verliezen clients het vertrouwen in je API. De ene route retourneert { "error": "not found" }, een andere { "message": "missing" }, en een derde stuurt platte tekst. Zelfs als de betekenis dicht bij elkaar ligt, moet clientcode nu raden wat er gebeurde.
De kosten verschijnen snel. Teams bouwen fragiele parsinglogica en voegen per endpoint speciale gevallen toe. Retries worden riskant omdat de client niet kan onderscheiden of hij het later nog eens moet proberen of dat zijn invoer onjuist is. Supporttickets nemen toe omdat de client slechts een vage melding ziet en je team die niet makkelijk aan een serverlogregel kan koppelen.
Een veelvoorkomend scenario: een mobiele app roept tijdens signup drie endpoints aan. De eerste geeft HTTP 400 met een veldniveau-foutenmap, de tweede geeft HTTP 500 met een stacktrace-string, en de derde geeft HTTP 200 met { "ok": false }. Het app-team levert drie verschillende fouthandlers en je backend-team krijgt nog steeds meldingen als “signup faalt soms” zonder duidelijke aanwijzing waar te beginnen.
Het doel is één voorspelbaar contract. Clients moeten betrouwbaar kunnen lezen wat er gebeurde: of het hun fout is of die van jou, of retry zinnig is, en een request ID die ze in support kunnen plakken.
Scope-opmerking: dit richt zich op JSON HTTP-API's (niet gRPC), maar dezelfde ideeën gelden overal waar je fouten teruggeeft aan andere systemen.
Kies één helder contract voor fouten en zorg dat elk endpoint zich eraan houdt. “Consistent” betekent dezelfde JSON-structuur, dezelfde betekenis van velden en hetzelfde gedrag ongeacht welke handler faalt. Zodra je dat hebt, stoppen clients met raden en beginnen ze fouten af te handelen.
Een nuttig contract helpt clients beslissen wat ze daarna moeten doen. Voor de meeste apps zou elke foutresponse drie vragen moeten beantwoorden:
Een praktische set regels:
Bepaal vooraf wat nooit in responses mag verschijnen. Veelvoorkomende “nooit”-items zijn SQL-fragmenten, stacktraces, interne hostnames, geheimen en ruwe foutstrings van afhankelijkheden.
Houd een duidelijke scheiding: een korte gebruiksgerichte boodschap (veilig, beleefd, actiegericht) en interne details (volledige fout, stack en context) die in logs blijven. Bijvoorbeeld: “We konden je wijzigingen niet opslaan. Probeer het opnieuw.” is veilig. “pq: duplicate key value violates unique constraint users_email_key” is dat niet.
Als elk endpoint hetzelfde contract volgt, kunnen clients één fouthandler bouwen en die overal hergebruiken.
Clients kunnen alleen netjes met fouten omgaan als elk endpoint hetzelfde antwoordvorm heeft. Kies één JSON-envelope en houd die stabiel.
Een praktisch standaard is een error-object plus een top-level request_id:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
De HTTP-status geeft de brede categorie (400, 401, 409, 500). De machineleesbare error.code geeft het specifieke geval waarop de client kan takken. Die scheiding is belangrijk omdat veel verschillende problemen dezelfde status delen. Een mobiele app kan bijvoorbeeld andere UI tonen voor EMAIL_TAKEN vs WEAK_PASSWORD, ook al zijn beide 400.
Houd error.message veilig en menselijk. Het moet de gebruiker helpen het probleem op te lossen, maar nooit internheden lekken (SQL, stacktraces, provider-namen, bestands- of padnamen).
Optionele velden zijn nuttig als ze voorspelbaar blijven:
details.fields als een map van veld naar bericht.details.retry_after_seconds.details.docs_hint als platte tekst (geen URL).Voor backwards compatibility behandel je error.code-waarden als onderdeel van je API-contract. Voeg nieuwe codes toe zonder oude betekenissen te wijzigen. Voeg alleen optionele velden toe en ga ervan uit dat clients velden die ze niet herkennen negeren.
Foutafhandeling wordt rommelig wanneer elke handler zijn eigen manier verzint om falen te signaleren. Een kleine set getypeerde fouten lost dat op: handlers retourneren bekende fouttypes en één response-laag zet die om in consistente responses.
Een praktisch startsetje dekt de meeste endpoints:
Het belangrijkste is stabiliteit aan de bovenkant, zelfs als de onderliggende oorzaak verandert. Je kunt lagere niveau-fouten (SQL, netwerk, JSON-parsing) wrappen en toch hetzelfde publieke type teruggeven dat middleware kan detecteren.
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 }
In je handler retourneer je NotFoundError{Resource: "user", ID: id, Err: err} in plaats van sql.ErrNoRows direct te lekken.
Om fouten te controleren, geef de voorkeur aan errors.As voor custom types en errors.Is voor sentinel-fouten. Sentinel-fouten (zoals var ErrUnauthorized = errors.New("unauthorized")) werken voor eenvoudige gevallen, maar custom types winnen wanneer je veilige context nodig hebt (bijvoorbeeld welke resource ontbrak) zonder je publieke response-contract te veranderen.
Wees strikt over wat je toevoegt:
Err, stackinfo, ruwe SQL-fouten, tokens, user data.Die scheiding laat je clients helpen zonder internheden bloot te geven.
Als je getypeerde fouten hebt, is de volgende klus saai maar essentieel: hetzelfde fouttype moet altijd dezelfde HTTP-status opleveren. Clients bouwen logica rondom die verwachting.
Een praktische mapping die voor de meeste API's werkt:
| Error type (voorbeeld) | Status | Wanneer te gebruiken |
|---|---|---|
| BadRequest (malformed JSON, missing required query param) | 400 | Het verzoek is op basis- of formaatniveau ongeldig. |
| Unauthenticated (no/invalid token) | 401 | De client moet zich authenticeren. |
| Forbidden (no permission) | 403 | Auth is geldig, maar toegang is niet toegestaan. |
| NotFound (resource ID does not exist) | 404 | De opgevraagde resource is er niet (of je wilt bestaan verbergen). |
| Conflict (unique constraint, version mismatch) | 409 | Het verzoek is goed gevormd, maar botst met de huidige staat. |
| ValidationFailed (field rules) | 422 | De vorm is goed, maar business-validatie faalt (email formaat, minimale lengte). |
| RateLimited | 429 | Te veel verzoeken binnen een tijdvenster. |
| Internal (unknown error) | 500 | Bug of onverwachte fout. |
| Unavailable (dependency down, timeout, maintenance) | 503 | Tijdelijke server-side fout. |
Twee onderscheidingen voorkomen veel verwarring:
Retry-richtlijnen zijn belangrijk:
Een request ID is een korte unieke waarde die één API-aanroep end-to-end identificeert. Als clients die in elke response kunnen zien, wordt support simpel: “Stuur me de request ID” is vaak genoeg om precies de logs en de fout te vinden.
Deze gewoonte betaalt zich uit bij zowel successes als errors.
Gebruik één duidelijke regel: als de client een request ID stuurt, behoud die. Zo niet, genereer er dan één.
X-Request-Id).Plaats de request ID op drie plekken:
request_id in je standaardschema)Voor batch-endpoints of background-jobs houd je een parent request ID. Voorbeeld: een client uploadt 200 rijen, 12 falen validatie en je zet werk in de wachtrij. Retourneer één request_id voor de hele call en voeg een parent_request_id toe aan elk job en elk per-item error. Zo kun je één upload tracen, ook als die ontvouwt in veel taken.
Clients hebben een duidelijk, stabiel foutantwoord nodig. Je logs hebben de rommelige waarheid nodig. Houd die twee werelden gescheiden: geef de client een veilig bericht en een publieke foutcode, terwijl je de interne oorzaak, stack en context op de server logt.
Log één gestructureerd event voor elke foutresponse, doorzoekbaar op request_id.
Velden die het waard zijn om consistent te houden:
Bewaar interne details alleen in serverlogs (of in een interne error store). De client mag nooit ruwe databasefouten, querytekst, stacktraces of providerberichten zien. Als je meerdere services runt, kan een intern veld als source (api, db, auth, upstream) het triageproces versnellen.
Houd lawaaiige endpoints en rate-limited fouten in de gaten. Als een endpoint dezelfde 429 of 400 duizenden keren per minuut kan genereren, vermijd logspam: sample herhaalde events of verlaag severity voor verwachte fouten, terwijl je ze nog steeds in metrics meet.
Metrics vangen problemen vroeger dan logs. Meet aantallen gegroepeerd op HTTP-status en foutcode, en stel alerts in bij plotselinge pieken. Als RATE_LIMITED 10x stijgt na een deploy, zie je het snel ook al zijn logs gesampled.
De makkelijkste manier om fouten consistent te maken is te stoppen met ze overal “afhandelen” en ze door één kleine pipeline te leiden. Die pipeline beslist wat de client ziet en wat je voor logs bewaart.
Begin met een kleine set foutcodes waar clients op kunnen vertrouwen (bijvoorbeeld: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Wrap ze in een getypeerde fout die slechts veilige, publieke velden blootlegt (code, veilige boodschap, optionele details zoals welk veld fout is). Houd interne oorzaken privé.
Implementeer vervolgens één vertalerfunctie die elke error omzet naar (statusCode, responseBody). Hier mappen getypeerde fouten naar HTTP-statuscodes, en onbekende fouten worden een veilige 500-response.
Voeg daarna middleware toe die:
request_id heeftEen panic zou nooit stacktraces naar de client mogen dumpen. Geef een normale 500-response terug met een generiek bericht en log de volledige panic met dezelfde request_id.
Verander ten slotte je handlers zodat ze een error teruggeven in plaats van direct de response te schrijven. Eén wrapper kan de handler aanroepen, de vertaler uitvoeren en JSON in het standaardformaat schrijven.
Een compact checklist:
Golden tests zijn belangrijk omdat ze het contract vergrendelen. Als iemand later een bericht of statuscode verandert, falen tests voordat clients verrast worden.
Stel je één endpoint voor: een client maakt een customer-record aan.
POST /v1/customers met JSON zoals { "email": "[email protected]", "name": "Pat" }. De server retourneert altijd dezelfde foutvorm en altijd een request_id.
Het e-mailadres ontbreekt of is verkeerd geformatteerd. De client kan het veld markeren.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
Het e-mailadres bestaat al. De client kan voorstellen om in te loggen of een ander e-mailadres te kiezen.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Een dependency is down. De client kan na backoff opnieuw proberen en een rustige melding tonen.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
Met één contract reageert de client consistent:
details.fieldsrequest_id als support-IDVoor support is diezelfde request_id de snelste weg naar de echte oorzaak in interne logs, zonder stacktraces of databasefouten bloot te geven.
De snelste manier om API-clients te irriteren is ze te laten raden. Als het ene endpoint { "error": "..." } retourneert en een ander { "message": "..." }, verandert elke client in een stapel speciale gevallen en verstoppen bugs zich wekenlang.
Een paar veelgemaakte fouten:
code waar clients op kunnen keyen.request_id alleen bij failures toevoegen, zodat je een gebruikersmelding niet kunt correleren met de succesvolle call die een later probleem triggerde.Internals lekken is de makkelijkste valkuil. Een handler retourneert err.Error() omdat het handig is, en ineens staat een constraintnaam of derdepartijmelding in productionresponses. Houd het clientbericht veilig en kort, en zet de gedetailleerde oorzaak in logs.
Vertrouwen op tekst alleen is een andere sluipmoordenaar. Als de client Engelse zinnen moet parsen zoals “email already exists”, kun je de tekst niet veranderen zonder logica te breken. Stabiele foutcodes laten je berichten aanpassen, vertalen en toch consistent gedrag behouden.
Behandel foutcodes als onderdeel van je publieke contract. Als je er één moet veranderen, voeg dan een nieuwe code toe en houd de oude code nog even werkend, ook als beide naar dezelfde HTTP-status mappen.
Tot slot: voeg hetzelfde request_id-veld toe aan elke response, succes of fout. Als een gebruiker zegt “het werkte, daarna brak het”, redt die ene ID vaak een uur zoekwerk.
Voordat je releaset, doe een snelle controle op consistentie:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Voeg tests toe zodat handlers niet per ongeluk onbekende codes teruggeven.request_id en log hem voor elke request, inclusief panics en timeouts.Daarna: controleer een paar endpoints handmatig. Trigger een validatiefout, een ontbrekend record en een onverwachte fout. Als responses over endpoints heen verschillen (velden veranderen, statuscodes driften, berichten oversharen), repareer dan de gedeelde pipeline voordat je meer features toevoegt.
Een praktische regel: als een bericht een aanvaller zou helpen of een normale gebruiker in de war brengt, hoort het in logs, niet in de response.
Schrijf het foutcontract op dat je wilt dat elk endpoint volgt, zelfs als je API al live is. Een gedeeld contract (status, stabiele foutcode, veilig bericht en request_id) is de snelste manier om fouten voorspelbaar te maken voor clients.
Migreer vervolgens geleidelijk. Houd je bestaande handlers, maar leid hun fouten door één mapper die interne fouten naar je publieke responsevorm omzet. Dit verbetert consistentie zonder een risicovolle grote rewrite en voorkomt dat nieuwe endpoints nieuwe formaten uitvinden.
Houd een kleine foutcode-catalogus en behandel die als onderdeel van je API. Als iemand een nieuwe code wil toevoegen, doe een korte review: is het echt nieuw, is de naam duidelijk en mapt het naar de juiste HTTP-status?
Voeg een handvol tests toe die drift detecteren:
request_id.error.code is aanwezig en komt uit de catalogus.error.message blijft veilig en bevat nooit interne details.Als je een Go-backend vanaf nul bouwt, helpt het om het contract vroeg vast te leggen. Bijvoorbeeld, Koder.ai (koder.ai) bevat een planningsmodus waar je conventies zoals een foutschema en codecatalogus vooraf kunt definiëren en handlers op één lijn kunt houden terwijl de API groeit.
Gebruik één JSON-vorm voor elke foutresponse, op alle endpoints. Een praktisch standaardformat is een top-level request_id plus een error-object met code, message en optionele details, zodat clients betrouwbaar kunnen parsen en reageren.
Geef error.message als een korte, gebruikersveilige zin en bewaar de echte oorzaak in serverlogs. Retourneer geen ruwe databasefouten, stacktraces, interne hostnamen of afhankelijkheidsmeldingen, ook niet tijdens ontwikkeling.
Gebruik een stabiele error.code voor machinale logica en laat de HTTP-status de brede categorie beschrijven. Clients zouden op error.code moeten vertakken (zoals ALREADY_EXISTS) en de status als richtlijn gebruiken (bijv. 409 als staatconflict).
Gebruik 400 wanneer het verzoek niet betrouwbaar te parsen of interpreteren is (verkeerde JSON, verkeerde types). Gebruik 422 wanneer het verzoek syntactisch juist is maar faalt voor businessregels (ongeldig e-mailadres, te korte wachtwoord).
Gebruik 409 wanneer de invoer geldig is maar niet toepasbaar vanwege een staatconflict (e-mailadres al in gebruik, versieconflict). Gebruik 422 voor veldniveau-validatie waarbij het aanpassen van de waarde het probleem oplost zonder serverstate te veranderen.
Maak een kleine set getypeerde fouten (validatie, niet gevonden, conflict, unauthorized, internal) en laat handlers deze teruggeven. Gebruik vervolgens één gedeelde vertaler om die typen naar statuscodes en de standaard JSON-responsvorm te mappen.
Retourneer altijd een request_id in elke response, succes of fout, en log die op elke serverlogregel. Als een client een probleem meldt, is die ene ID vaak genoeg om het exacte foutpad in de logs te vinden.
Return 200 alleen wanneer de operatie succesvol was; gebruik 4xx/5xx voor fouten. Verbergen van fouten achter 200 dwingt clients om bodies te parsen en veroorzaakt inconsistent gedrag over endpoints heen.
Probeer niet te retry-en voor 400, 401, 403, 404, 409 en 422 — retries helpen meestal niet zonder aanpassingen. Sta retry toe voor 503, en soms 429 na wachten; als je idempotentiesleutels ondersteunt, worden retries veiliger voor POST bij transitieve fouten.
Sluit het contract af met een paar “golden” tests die status, error.code en aanwezigheid van request_id afvinken. Voeg nieuwe foutcodes toe zonder oude betekenissen te veranderen, en voeg alleen optionele velden toe zodat oudere clients blijven werken.