Arkitektur för felhantering i Go: Gränser och mönster

Hantera fel vid rätt gräns.

Sidinnehåll

Att klaga på felhantering i Go är enkelt. Varje Go-utvecklare har skrivit den här koden hundratals gånger:

if err != nil {
	return err
}

Det är inte den intressanta delen. Den intressanta delen är vad felet betyder, var det ska hanteras, var det ska inbäddas (wrap), var det ska översättas, var det ska loggas och vad som ska exponeras för anroparen — det är den arkitektoniska frågan.

Go behandlar fel som värden. Det gör misslyckanden explicita. Det innebär också att din kodbas behöver en tydlig design för felhantering. Utan en sådan blir fel slumpmässiga strängar, HTTP-hanterlare läcker databasdetaljer, loggar duplicerar samma misslyckande fem gånger, återförsök sker av felaktiga skäl och anroparen granskar text istället för beteende.

Arkitektur för felhantering i Go: fel som flödar mellan lager

Den här artikeln är inte en nybörjarguide till if err != nil.

Det är en praktisk guide till arkitektur för felhantering i Go: inbäddning (wrapping), sentinel-fel, anpassade feltyper, errors.Is, errors.As, felgränser, API-mappning, loggning, återförsök, säkerhet och produktionsmönster.

Den lite åsiktsstyrda versionen: försök inte få Go-fel att försvinna. Gör dem meningsfulla vid rätt gränssnitt.

Vad Go-fel är

I Go är ett fel bara ett värde som implementerar detta gränssnitt:

type error interface {
	Error() string
}

Detta lilla gränssnitt är anledningen till att felhantering i Go känns så direkt.

Funktioner returnerar fel explicit:

func LoadUser(id string) (*User, error) {
	// ...
}

Anroparen bestämmer vad som ska göras:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

Det finns inga undantag och ingen dold stackavvikling. Misslyckande är en del av funktionens signatur.

Det är bra, men det innebär också att fel behöver design. Om varje paket returnerar godtyckliga meddelanden kan anroparen inte fatta pålitliga beslut. Om varje lager inbäddar varje fel utan disciplin får operatörer bullriga meddelanden och utvecklare förvirrande kedjor. Om inget lager inbäddar fel förlorar misslyckanden kontext.

Målet är inte mindre felhantering, utan bättre felbetydelse.

De tre uppgifterna för ett fel

Ett användbart fel har vanligtvis en eller flera uppgifter.

Uppgift 1: Förklara vad som misslyckades

För människor ska felet förklara vilken operation som misslyckades.

Exempel:

return fmt.Errorf("last användare %s: %w", id, err)

Detta ger kontext. Det säger att misslyckandet skedde under inläsningen av en användare.

Uppgift 2: Bevara orsaken

För kod ska felet bevara den underliggande orsaken när den orsaken är relevant.

Exempel:

return fmt.Errorf("last användare %s: %w", id, err)

%w inbäddar det ursprungliga felet så att anroparen kan granska det med errors.Is eller errors.As.

Uppgift 3: Låt en gräns fatta ett beslut

Vid någon gräns måste programmet besluta vad som ska göras.

Exempel:

  • Returnera HTTP 404
  • Returnera HTTP 409
  • Försök operationen igen
  • Logga på varningsnivå
  • Visa ett användarsäkert meddelande
  • Avbryt transaktionen
  • Skicka felet till övervakning
  • Ignorera avbrott

Det beslutet bör vanligtvis baseras på felidentitet eller typ, inte strängmatchning.

De huvudsakliga felverktygen i modern Go

Modern Go ger dig ett litet men kraftfullt verktygsset.

errors.New

Använd errors.New för att skapa ett enkelt felvärde:

var ErrNotFound = errors.New("hittades inte")

Detta är användbart för sentinel-fel.

fmt.Errorf med %w

Använd fmt.Errorf med %w för att inbädda ett fel:

return fmt.Errorf("fråga användare: %w", err)

Inbäddning lägger till kontext medan den bevarar det ursprungliga felet för granskning.

errors.Is

Använd errors.Is för att kontrollera om ett fel matchar en specifik måltid någonstans i dess kedja:

if errors.Is(err, ErrNotFound) {
	// hantera hittades inte
}

Använd detta för sentinel-fel och kända villkor.

errors.As

Använd errors.As för att extrahera en specifik feltyp från en kedja:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	// använd validationErr.Field eller validationErr.Reason
}

Använd detta när felet bär strukturerad data.

errors.Join

Använd errors.Join när flera fel inträffat och alla ska bevaras:

return errors.Join(closeErr, flushErr)

Sammanfogade fel kan fortfarande granskas med errors.Is och errors.As.

Använd detta försiktigt. Ett sammanfogat fel innebär att flera misslyckanden är en del av ett resultat.

Sentinel-fel

Ett sentinel-fel är ett felvärde på paketnivå som representerar ett känt villkor.

Exempel:

var ErrUserNotFound = errors.New("användare hittades inte")
var ErrDuplicateEmail = errors.New("dubblett e-post")

Sentinel-fel är användbara när anroparen bara behöver veta vilken kategori av misslyckande som skett.

Exempel:

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.queryUser(ctx, id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("fråga användare: %w", err)
	}

	return user, nil
}

Därefter kan en tjänst eller hanterare kontrollera:

if errors.Is(err, ErrUserNotFound) {
	// returnera 404
}

När man ska använda sentinel-fel

Använd sentinel-fel när:

  • Villkoret är stabilt.
  • Anroparen behöver göra ett grenval baserat på det.
  • Ingen extra strukturerad data behövs.
  • Felet tillhör ditt paket eller domän.

Bra exempel:

var ErrNotFound = errors.New("hittades inte")
var ErrAlreadyExists = errors.New("finns redan")
var ErrPermissionDenied = errors.New("åtkomst nekad")
var ErrConflict = errors.New("konflikt")

När man inte ska använda sentinel-fel

Skapa inte sentineler för varje möjliga misslyckande.

Dåligt:

var ErrCouldNotOpenFile = errors.New("kunde inte öppna fil")
var ErrCouldNotReadFile = errors.New("kunde inte läsa fil")
var ErrCouldNotParseLine = errors.New("kunde inte pars rad")

Om anroparen inte gör ett grenval baserat på dessa kan de vara bara meddelanden.

Var också försiktig med att exportera för många sentineler. Exporterade sentinel-fel blir en del av ditt pakets API.

Anpassade feltyper

En anpassad feltyp är användbar när felet bär strukturerad information.

Exempel:

type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validering misslyckades för %s: %s", e.Field, e.Reason)
}

Anropare:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

Detta är bättre än att pars ett felmeddelande.

När man ska använda anpassade feltyper

Använd anpassade feltyper när:

  • Anroparen behöver strukturerad data.
  • Felet har meningsfulla fält.
  • Typen är en del av ditt pakets avtal.
  • Anroparen kan behöva hantera flera värden olika.

Exempel:

  • Valideringsfel med fältname
  • Rate limit-fel med återförsökstid
  • HTTP-fel med statuskod
  • Parsfel med rad och kolumn
  • Domänfel med resurs-ID

När man inte ska använda anpassade feltyper

Skapa inte anpassade typer bara för att undvika errors.New.

Detta är onödigt:

type NotFoundError struct{}

func (e NotFoundError) Error() string {
	return "hittades inte"
}

Om det inte finns någon användbar data räcker ofta en sentinel.

Felinbäddning (Wrapping)

Inbäddning lägger till kontext till ett fel medan den bevarar det ursprungliga felet.

Exempel:

func LoadConfig(path string) error {
	data, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("läs konfig %s: %w", path, err)
	}

	if err := parseConfig(data); err != nil {
		return fmt.Errorf("pars konfig %s: %w", path, err)
	}

	return nil
}

Om os.ReadFile misslyckas får anroparen både:

  • den högnivåoperationen: läs konfig
  • den lågnivåorsaken: åtkomst nekad, fil hittades inte, etc.

Båda är tillgängliga genom felkedjan, vilket är det som gör inbäddning med %w värd att göra konsekvent.

Inbädda med användbar kontext

Bra inbäddning säger vilken operation som misslyckades:

return fmt.Errorf("skapa faktura %s: %w", invoiceID, err)

Dålig inbäddning lägger till brus:

return fmt.Errorf("fel: %w", err)

Detta berättar inget för anroparen.

Undvik också att upprepa samma substantiv vid varje lager:

return fmt.Errorf("användartjänst: hämta användare: användarrepository: fråga användare: %w", err)

Den typen av kedja är tekniskt korrekt men praktiskt irriterande.

Inbädda där kontexten ändrar betydelse. Om du inte kan förklara i en fras vilken operation som misslyckades, inbäddar du antingen för aggressivt eller inte tillräckligt.

När man ska inbädda och när man inte ska inbädda

Detta är ett av de viktigaste arkitektoniska besluten.

Inbädda vid att korsa en meningsfull gräns

Inbädda när felet flyttar från en operation till en högre nivå operation.

Exempel:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("hämta användare %s: %w", id, err)
	}

	return user, nil
}

Repository-felet är nu en del av en tjänsteoperation, och den tillagda kontexten är användbar när operatörer spårar ett misslyckande tillbaka genom loggarna.

Inbädda inte bara för att säga “misslyckades”

Dåligt:

if err != nil {
	return fmt.Errorf("misslyckades: %w", err)
}

Ordet “misslyckades” är vanligtvis underförstått av det faktum att ett fel existerar.

Inbädda inte om du översätter

Ibland bör du översätta ett fel till ett annat domänfel.

Exempel:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Detta döljer med avsikt databasdetaljen och exponerar ett domänvillkor.

Du kan fortfarande bevara orsaken om det är användbart, men gör det medvetet.

Exponera inte implementeringsdetaljer oavsiktligt

Om du inbäddar ett lågnivåfel med %w kan anroparen granska det.

Det är vanligtvis bra inuti din applikation.

Men i ett offentligt pakets API kan inbäddning exponera implementeringsdetaljer som en del av ditt avtal.

Till exempel, om ditt paket inbäddar sql.ErrNoRows, kan anroparen börja bero på det:

if errors.Is(err, sql.ErrNoRows) {
	// anroparen vet nu att du använder database/sql
}

Om du kan ändra lagring senare, föredra en domänsentinel:

var ErrUserNotFound = errors.New("användare hittades inte")

Returnera sedan detta från paketsgränsen.

Felgränser

Det mest användbara sättet att tänka på felhantering i Go är genom gränser.

En gräns är en plats där ett fel ändrar betydelse eller publik.

Vanliga gränser inkluderar:

  • databas till repository
  • repository till tjänst
  • tjänst till HTTP-hanterare
  • tjänst till CLI-kommando
  • internt fel till användarvänligt meddelande
  • tillfälligt misslyckande till återförsödsbeslut
  • operationsmisslyckande till logghändelse
  • domänfel till API-svar

Felarkitektur är mestadels gränsdesign. Varje gräns är en beslutspunkt där fel antingen får kontext, förlorar implementeringsdetaljer eller översätts till en form som nästa lager kan agera på.

Repository-gräns

Repositoryt pratar med lagring.

Det bör vanligtvis översätta databasspecifika fel till domänfel.

Exempel:

var ErrUserNotFound = errors.New("användare hittades inte")
var ErrDuplicateEmail = errors.New("dubblett e-post")

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("fråga användare efter id: %w", err)
	}

	return &user, nil
}

Repositoryt döljer sql.ErrNoRows och exponerar ErrUserNotFound — en ren gräns som innebär att tjänsten inte behöver veta något om hur lagringen representerar “hittades inte”.

Tjänstegräns

Tjänsten äger affärsbetydelsen.

Den bör vanligtvis lägga till operationskontext och bevara domänfel.

Exempel:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("hämta användare %s: %w", id, err)
	}

	return user, nil
}

Detta bevarar domänvillkoret medan det lägger till kontext för oväntade fel.

För mer komplexa affärsregler kan tjänsten skapa domänfel direkt:

var ErrAccountDisabled = errors.New("konto inaktiverat")

func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
	user, err := s.repo.GetUserByEmail(ctx, email)
	if err != nil {
		return nil, fmt.Errorf("hämta användare efter e-post: %w", err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	// ...
	return session, nil
}

Tjänsten är rätt plats för affärsnivåfel — skapade direkt från domänlogik snarare än översatta från infrastrukturvillkor.

HTTP-hanterar-gräns

HTTP-hanteraren översätter applikationsfel till HTTP-svar.

Detta är en gräns där interna detaljer bör bli användarsäkra svar.

Exempel:

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, err := svc.GetUser(r.Context(), r.PathValue("id"))
		if err != nil {
			writeHTTPError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Fel mappning:

func writeHTTPError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, "användare hittades inte", http.StatusNotFound)

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "konto inaktiverat", http.StatusForbidden)

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "förfrågan tog för lång tid", http.StatusGatewayTimeout)

	default:
		http.Error(w, "internt serverfel", http.StatusInternalServerError)
	}
}

Hanteraren mappar domänfel till HTTP-semantik snarare än att exponera råa databas- eller interna fel detaljer. Detta är där många Go-applikationer går fel — de antingen exponerar för mycket intern detalj eller kollapsar alla fel till HTTP 500. För en komplett bild av hanterarmönster och middleware i Go-API, Bygg REST-API i Go täcker autentisering, ruttning och felhantering över standardbiblioteket, Gin, Echo och Fiber.

CLI-gräns

En CLI har en annan gräns än ett HTTP-API.

I en CLI ska felet vara användbart för personen som kör kommandot.

Exempel:

func RunImport(ctx context.Context, args []string) error {
	if len(args) == 0 {
		return ErrMissingInputFile
	}

	if err := importFile(ctx, args[0]); err != nil {
		return fmt.Errorf("importera %s: %w", args[0], err)
	}

	return nil
}

Vid kommandogränsen:

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, formatCLIError(err))
		os.Exit(exitCode(err))
	}
}

Mappa kända fel till exit-koder:

func exitCode(err error) int {
	switch {
	case errors.Is(err, ErrMissingInputFile):
		return 2
	case errors.Is(err, ErrValidation):
		return 3
	default:
		return 1
	}
}

En CLI kan ofta visa mer detalj än ett offentligt API, men den bör fortfarande undvika att läcka hemligheter.

API-feltypsmönster

För HTTP-API kan en liten applikationsnivå feltyp vara användbar.

Exempel:

type APIError struct {
	Status  int
	Code    string
	Message string
	Err     error
}

func (e *APIError) Error() string {
	if e.Err == nil {
		return e.Message
	}

	return e.Message + ": " + e.Err.Error()
}

func (e *APIError) Unwrap() error {
	return e.Err
}

Konstruktör:

func NewAPIError(status int, code string, message string, err error) *APIError {
	return &APIError{
		Status:  status,
		Code:    code,
		Message: message,
		Err:     err,
	}
}

Användning:

return NewAPIError(
	http.StatusConflict,
	"dubblett_e-post",
	"e-posten är redan registrerad",
	ErrDuplicateEmail,
)

Hanterare:

func writeAPIError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if errors.As(err, &apiErr) {
		writeJSON(w, apiErr.Status, map[string]string{
			"code":    apiErr.Code,
			"message": apiErr.Message,
		})
		return
	}

	writeJSON(w, http.StatusInternalServerError, map[string]string{
		"code":    "internt_fel",
		"message": "internt serverfel",
	})
}

Detta mönster är användbart när du vill ha strukturerade API-fel med stabila koder.

Använd det vid API-gränsen. Tvinga inte varje internt paket att returnera API-specifika fel.

Domänfel vs transportfel

Håll domänfel separerade från transportfel.

Domänfel:

var ErrInsufficientBalance = errors.New("otillräcklig balans")

Transportmappning:

if errors.Is(err, ErrInsufficientBalance) {
	http.Error(w, "otillräcklig balans", http.StatusConflict)
	return
}

Gör inte så att ditt domänlager returnerar HTTP-statuskoder:

return &APIError{Status: http.StatusConflict}

Det kopplar affärslogik till HTTP och förhindrar att ditt tjänstelager fungerar rent över HTTP, CLI, arbetare, tester och framtida gRPC-adapter. Transportmappning hör hemma vid transportgränsen, inte i domänkod. För vägledning om var man ska definiera domänfel, sentineler och transportadapter inom din projektstruktur, Go Projektstruktur: Praxis & Mönster täcker konventionerna för internal/, pkg/ och adapter som håller dessa lager rent separerade.

Återförsöksbara fel

Vissa fel ska trigga återförsök. Vissa ska inte.

Beslut inte detta genom att matcha strängar.

Använd ett markeringsgränssnitt eller en explicit funktion.

Exempel:

type RetryableError struct {
	Err error
}

func (e *RetryableError) Error() string {
	return e.Err.Error()
}

func (e *RetryableError) Unwrap() error {
	return e.Err
}

Hjälpfunktion:

func Retryable(err error) error {
	if err == nil {
		return nil
	}

	return &RetryableError{Err: err}
}

func IsRetryable(err error) bool {
	var retryable *RetryableError
	return errors.As(err, &retryable)
}

Användning:

if err := callRemoteAPI(ctx); err != nil {
	if isTemporaryNetworkError(err) {
		return Retryable(fmt.Errorf("kalla remote api: %w", err))
	}

	return fmt.Errorf("kalla remote api: %w", err)
}

Återförsöksslopp:

err := doWork(ctx)
if err != nil {
	if IsRetryable(err) {
		// försök igen med backoff
	}
	return err
}

Detta är mycket bättre än att kontrollera om felsträngen innehåller “timeout” — strängmatchning bryter tyst när meddelanden ändras och skapar osynlig koppling mellan producent och konsument.

Valideringsfel

Valideringsfel behöver ofta strukturerad data.

Exempel:

type FieldError struct {
	Field   string
	Message string
}

type ValidationError struct {
	Fields []FieldError
}

func (e *ValidationError) Error() string {
	return "validering misslyckades"
}

Användning:

func ValidateCreateUser(req CreateUserRequest) error {
	var fields []FieldError

	if req.Email == "" {
		fields = append(fields, FieldError{
			Field:   "email",
			Message: "e-post krävs",
		})
	}

	if len(fields) > 0 {
		return &ValidationError{Fields: fields}
	}

	return nil
}

Hanterare:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	writeJSON(w, http.StatusBadRequest, validationErr)
	return
}

Detta är en bra användning av errors.As eftersom anroparen behöver strukturerad information — fältnamn och valideringsmeddelanden — inte bara en oklar felsträng.

Flera fel

Ibland misslyckas flera saker.

Exempel:

  • stänga flera resurser
  • validera många fält
  • stänga ner flera arbetare
  • köra oberoende kontroller
  • flusha och stänga utdata

Använd errors.Join när alla fel ska bevaras.

Exempel:

func CloseAll(closers ...io.Closer) error {
	var errs []error

	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}

	return errors.Join(errs...)
}

Anropare:

if err := CloseAll(a, b, c); err != nil {
	return fmt.Errorf("stäng resurser: %w", err)
}

Både errors.Is och errors.As kan granska sammanfogade fel, vilket innebär att sammanfogade felvärden förblir fullt kompatibla med standardfelkontrollmönster.

När man inte ska använda errors.Join

Använd inte errors.Join när det finns ett primärt fel och lite loggkontext.

Använd det inte för att undvika att besluta vilket fel som är viktigast.

Returnera inte enorma sammanfogade fel till användare.

Sammanfogade fel är användbara, men de kan snabbt bli bullriga.

Panic är inte felhantering

Använd inte panic för förväntade fel i normal applikationskod.

Dåligt:

if err != nil {
	panic(err)
}

Använd panic för programmeringsfel eller verkligen oåterkalleliga situationer.

Exempel:

  • omöjlig intern invariansövertrampning
  • ogiltig paketinitiering
  • testhjälparmisslyckande med t.Fatal eller panic i begränsade fall
  • oåterkalleligt startkonfigurationsfel, beroende på stil

Använd inte panic för att en databasfråga misslyckades eller en användare skickade in ogiltig indata.

Det är normala fel.

Loggning av fel

Ett vanligt Go-fel är att logga samma fel vid varje lager.

Dåligt:

func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		log.Printf("fråga misslyckades: %v", err)
		return nil, err
	}
	return user, nil
}

func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		log.Printf("tjänst misslyckades: %v", err)
		return nil, err
	}
	return user, nil
}

Detta skapar dubbla loggar för ett misslyckande.

Bättre:

  • inbädda fel när de flyttar uppåt
  • logga en gång vid gränsen där felet hanteras
  • inkludera strukturerad kontext i loggen

Exempel:

func (s *Server) handleError(r *http.Request, err error) {
	s.logger.ErrorContext(
		r.Context(),
		"förfrågan misslyckades",
		"method", r.Method,
		"path", r.URL.Path,
		"err", err,
	)
}

Detta ger en logghändelse med hela felkedjan. För en produktionsredo strukturerad loggningsinställning, Strukturerad loggning i Go med slog täcker log/slog-poster, JSON-handlare, kontextkorrelation och reaktion — allt som parar sig naturligt med felloggning på gränsnivå.

När man ska logga i lägre lager

Logga i lägre lager endast när lagret faktiskt hanterar felet eller lägger till viktig operativ kontext som inte kommer att synas någon annanstans.

Till exempel kan en återförsöksslopp logga varje återförsök på debug- eller varningsnivå.

Men ett repository bör inte logga varje frågefel om hanteraren kommer att logga det slutgiltiga förfrågningsmisslyckandet.

Användarvänliga fel vs operatorfel

Visa inte interna fel direkt för användare.

Internt fel:

fråga användare efter id: dial tcp 10.0.4.12:5432: anslutning vägras

Användarvänligt meddelande:

internt serverfel

Operatorlog:

förfrågan misslyckades err="hämta användare 123: fråga användare efter id: dial tcp 10.0.4.12:5432: anslutning vägras"

Detta är olika publiker, och en bra felarkitektur håller dem separerade:

  • intern diagnostiskt fel
  • användarsäkert svar
  • stabilt API-felkod
  • operatorlogkontext

Att tvinga ett felmeddelande att tjäna alla dessa publikger antingen en exponeringsrisk eller en felsökningsmardröm. Designa din felarkitektur kring distinkta värden för distinkta konsumenter.

Säker felhantering

Fel kan läcka känslig information.

Undvika att exponera:

  • databasanslutningssträngar
  • SQL-frågor med hemligheter
  • interna värdnamn
  • filvägar
  • åtkomsttoken
  • API-nycklar
  • stackspår
  • privata kunddata
  • detaljer om auktoriseringspolicy

Detta är särskilt viktigt i HTTP-API.

Dåligt:

http.Error(w, err.Error(), http.StatusInternalServerError)

Bra:

http.Error(w, "internt serverfel", http.StatusInternalServerError)

Logga det interna felet säkert för operatörer. Returnera ett säkert meddelande till användaren.

Felkoder

För offentliga API är stabila felkoder ofta bättre än att bara lita på meddelanden.

Exempel svar:

{
  "code": "user_not_found",
  "message": "användare hittades inte"
}

Meddelandet kan ändras. Koden bör vara stabil.

Använd felkoder för:

  • klientbeteende
  • dokumentation
  • SDK
  • lokalisering
  • supportdiagnostik

Gör inte så att klienter pars engelska felmeddelanden.

Ett praktiskt lagerfel-design

Här är ett rent mönster för många Go backend-tjänster.

Repository-lager

  • Pratar med databas eller extern lagring.
  • Konverterar lagringsspecifika “hittades inte”-fel till domänfel.
  • Inbäddar oväntade lagringsfel med operationskontext.
  • Returnerar inte HTTP-fel.
  • Loggar vanligtvis inte.

Exempel:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

return nil, fmt.Errorf("fråga användare efter id: %w", err)

Tjänstelager

  • Äger affärsregler.
  • Skapar domänfel.
  • Bevarar kända domänfel.
  • Inbäddar oväntade lågnivåfel.
  • Returnerar inte HTTP-statuskoder.
  • Loggar vanligtvis inte.

Exempel:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transportlager

  • Mappar domänfel till HTTP, gRPC eller CLI-svar.
  • Loggar ohanterade eller oväntade fel.
  • Döljer interna detaljer från användare.
  • Sätter statuskoder och API-felkoder.

Exempel:

switch {
case errors.Is(err, ErrUserNotFound):
	writeError(w, http.StatusNotFound, "user_not_found", "användare hittades inte")
default:
	writeError(w, http.StatusInternalServerError, "internal_error", "internt serverfel")
}

Denna separation håller felhantering begriplig och låter varje lager utvecklas oberoende — du kan ändra lagringsteknik utan att röra tjänstelogsik eller transportmappning. Den lagerbaserade designen fungerar bäst när beroenden injiceras snarare än hårdkodas; Beroendeinjektion i Go: Mönster & Bästa Praxis täcker konstruktor- och gränssnittsmönster som gör varje gräns lätt att testa isolerat.

Komplett exempel

Här är ett litet end-to-end-exempel.

Domänfel:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("användare hittades inte")
	ErrDuplicateEmail = errors.New("dubblett e-post")
	ErrAccountDisabled = errors.New("konto inaktiverat")
)

Repository:

package users

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Repository struct {
	db *sql.DB
}

func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name, disabled
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
		&user.Disabled,
	)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrUserNotFound
		}

		return nil, fmt.Errorf("fråga användare efter id: %w", err)
	}

	return &user, nil
}

Tjänst:

package users

import (
	"context"
	"errors"
	"fmt"
)

type Service struct {
	repo *Repository
}

func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			return nil, err
		}

		return nil, fmt.Errorf("hämta profil för användare %s: %w", id, err)
	}

	if user.Disabled {
		return nil, ErrAccountDisabled
	}

	return &Profile{
		ID:    user.ID,
		Email: user.Email,
		Name:  user.Name,
	}, nil
}

HTTP-hanterare:

package httpapi

import (
	"context"
	"errors"
	"net/http"

	"example.com/app/users"
)

type Handler struct {
	users *users.Service
}

func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
	profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
	if err != nil {
		h.writeError(w, err)
		return
	}

	writeJSON(w, http.StatusOK, profile)
}

func (h *Handler) writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, users.ErrUserNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{
			"code":    "user_not_found",
			"message": "användare hittades inte",
		})

	case errors.Is(err, users.ErrAccountDisabled):
		writeJSON(w, http.StatusForbidden, map[string]string{
			"code":    "account_disabled",
			"message": "kontot är inaktiverat",
		})

	case errors.Is(err, context.Canceled):
		return

	case errors.Is(err, context.DeadlineExceeded):
		writeJSON(w, http.StatusGatewayTimeout, map[string]string{
			"code":    "request_timeout",
			"message": "förfrågan tog för lång tid",
		})

	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"code":    "internal_error",
			"message": "internt serverfel",
		})
	}
}

Denna struktur ger dig:

  • domänfel
  • lagringsöversättning
  • tjänstekontext
  • säker HTTP-mappning
  • granskbara felkedjor
  • ingen strängmatchning
  • ingen transportläckage i domänkod

Det är den typen av felarkitektur som skalar — tillräckligt enkel för att en ny medarbetare ska förstå, men strukturerad nog så att domänlogik aldrig läcker in i transportsvar.

Testa felbeteende

Felbeteende bör testas lika noggrant som lyckovägen, eftersom gränsbeslut — sentinel-mappning, typextraktion, HTTP-koder — ofta är där buggar gömmer sig längst. För en komplett guide till Go-teststruktur, mockning och täckningsmönster, se Go Enhetstestning: Struktur & Bästa Praxis.

Testa sentinel-mappning

func TestGetByIDNotFound(t *testing.T) {
	repo := newTestRepository(t)

	_, err := repo.GetByID(t.Context(), "missing")
	if !errors.Is(err, users.ErrUserNotFound) {
		t.Fatalf("got %v, want ErrUserNotFound", err)
	}
}

Testa extraktion av anpassade fel

func TestValidationError(t *testing.T) {
	err := ValidateCreateUser(CreateUserRequest{})

	var validationErr *ValidationError
	if !errors.As(err, &validationErr) {
		t.Fatalf("got %T, want ValidationError", err)
	}

	if len(validationErr.Fields) == 0 {
		t.Fatal("expected validation fields")
	}
}

Testa HTTP-mappning

func TestWriteErrorNotFound(t *testing.T) {
	rec := httptest.NewRecorder()

	writeHTTPError(rec, users.ErrUserNotFound)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
	}
}

Tester ska bevisa att kända fel producerar rätt beteende vid varje gräns, så att refactorering av lagrings- eller transportlager inte tyst kan ändra misslyckandeavtalet.

Vanliga anti-mönster

Anti-mönster 1: Strängmatchning

Dåligt:

if strings.Contains(err.Error(), "hittades inte") {
	// ...
}

Använd errors.Is eller errors.As istället — båda hanterar inbäddade felkedjor automatiskt och bryter inte när meddelanden formateras om eller lokaliseras.

Anti-mönster 2: Förlora orsaken

Dåligt:

return errors.New("fråga misslyckades")

Bättre:

return fmt.Errorf("fråga användare: %w", err)

Anti-mönster 3: Inbädda utan mening

Dåligt:

return fmt.Errorf("fel hände: %w", err)

Inbädda med operationskontext som förklarar vad som försöktes, som "skapa faktura %s: %w" snarare än en vag prefix som lägger till ingen diagnostisk värde.

Anti-mönster 4: Logga vid varje lager

Dåligt:

log.Println(err)
return err

vid varje nivå. Logga en gång där felet slutligen hanteras, inte vid varje mellannivå som bara passerar det uppåt.

Anti-mönster 5: Returnera HTTP-fel från domänkod

Dåligt:

return &APIError{Status: http.StatusNotFound}

från en domäntjänst. Mappa domänfel till HTTP-statuskoder och svarskroppar vid hanterar-gränsen, håll ditt tjänstelager oberoende av transportfrågor.

Anti-mönster 6: Exponera interna fel för användare

Dåligt:

http.Error(w, err.Error(), http.StatusInternalServerError)

Returnera säkra generiska meddelanden till användare och logga det fulla interna felet med strukturerad kontext för operatörer. Exponera aldrig databasanslutningssträngar, filvägar eller råa stackspår i API-svar.

Anti-mönster 7: För många exporterade sentineler

Exporterade fel är en del av ditt pakets API, och att lägga till dem binder dig att underhålla dem. Exportera inte varje intern villkor om externa anropare verkligen behöver göra ett grenval baserat på det — föredra att hålla sentineler oexporterade tills det finns ett tydligt behov.

Anti-mönster 8: Använda panic för förväntade misslyckanden

Dåligt:

panic(err)

för normala körning misslyckanden. Reservera panic för verkligen oåterkalleliga villkor eller programmeringsfel, inte för saknade poster eller ogiltig användarindata — returnera alltid fel i dessa fall.

Anti-mönster 9: Ignorera kontextfel

Dåligt:

return fmt.Errorf("förfrågan misslyckades")

när den verkliga orsaken var context.Canceled. Bevara kontextfel så att anroparen kan skilja på ett genuint operationsmisslyckande och en avbruten eller tidsöverskriden förfrågan, och svara lämpligt på var och en.

Felsgranskning checklista

Använd denna checklista vid kodgranskning.

Fel skapande

  • Är detta ett känt villkor?
  • Borde det vara en sentinel?
  • Behöver det strukturerad data?
  • Borde det vara en anpassad typ?
  • Är felmeddelandet tydligt?

Fel inbäddning

  • Lägger inbäddningen till användbar operationskontext?
  • Bevarar %w orsaken där det behövs?
  • Exponerar koden oavsiktligt implementeringsdetaljer?
  • Är kedjan för bullrig?

Fel översättning

  • Översätts ett lågnivåfel vid rätt gräns?
  • Döljs databasspecifikt beteende från tjänstekoden?
  • Är domänfel oberoende av HTTP- eller CLI-frågor?

Fel hantering

  • Gör anroparen ett grenval med errors.Is eller errors.As?
  • Hanteras kontextavbrott och tidsgränser korrekt?
  • Identifieras återförsöksbara fel explicit?
  • Är valideringsfel strukturerade?

Loggning

  • Loggas felet en gång, vid hanteringsgränsen?
  • Är loggarna strukturerade?
  • Exkluderas känsliga detaljer från användarsvar?
  • Finns det tillräckligt med kontext för operatörer?

Testning

  • Testas kända fall?
  • Testas HTTP- eller CLI-mappningar?
  • Testas valideringsdetaljer?
  • Testas återförsödsbeslut?

Mina åsiktsstyrda regler

Regel 1: Fel ska korsa gränser med mening

Passa inte bara runt fel. Beslut vad de betyder vid varje lager.

Regel 2: Inbädda för kontext, inte dekoration

Om inbäddning inte lägger till användbar information om vilken operation som misslyckades, inbädda inte. Ett extra lager av kontext utan mening gör felkedjan svårare att läsa och lägger till ingen diagnostisk värde.

Regel 3: Översätt implementeringsfel till domänfel

Låt inte sql.ErrNoRows bli en del av din affärslogik. Översätt implementeringsfel till domänfel vid lagringsgränsen, så att resten av applikationen aldrig behöver veta vilken databas eller ORM som är under.

Regel 4: Pars inte felsträngar

Om kod behöver göra ett grenval baserat på feltyp, använd sentineler, anpassade typer, errors.Is eller errors.As. Stränginspektion skapar osynlig koppling som bryter tyst när felmeddelanden ändras.

Regel 5: Logga en gång

Inbädda när fel flyttar uppåt. Logga där felet slutligen hanteras.

Regel 6: Håll användarmeddelanden säkra

Interna diagnostiska fel är för loggar. Användarvänliga meddelanden är för användare.

Regel 7: Håll transportfel vid transportgränsen

HTTP-statuskoder hör hemma i hanterare eller API-adapter, inte i domäntjänster. Domänkod bör vara återanvändbar över transporter — idag HTTP, imorgon CLI, gRPC eller en händelsestyrd arbetare.

Avslutande tankar

Felhantering i Go handlar inte om att skriva if err != nil för evigt — det handlar om att göra misslyckande explicit och begripligt vid varje gräns.

Mekaniken är enkel:

returnera fel
inbädda med %w
kontrollera med errors.Is
extrahera med errors.As
sammanfoga när flera fel är viktiga

Arkitekturen är den svårare delen:

översätt vid gränser
bevara orsaker
dölj interna detaljer från användare
logga en gång
testa kända misslyckanden

Det är Go-felhantering gjort väl — inte clever, inte magisk, men tydlig nog så att nästa utvecklare, operatör, API-klient och framtida du kan förstå vad som misslyckades och vad som ska hända näst. För en bredare bild av produktionsredo Go-mönster över integration, testning och dataåtkomst, se App Arkitektur i Produktion.

Källor

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.