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

Hantera fel vid rätt gräns.

Sidinnehåll

Felhantering i Go är lätt att klaga på. Varje Go-utvecklare har skrivit denna kod 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 inlindas (wrapping), var det ska översättas, var det ska loggas och vad som ska exponeras för anroparen – det är arkitekturfrå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-handlare läcker databasedetaljer, loggar duplicerar samma misslyckande fem gånger, återförsök sker av fel anledningar och anropare granskar text istället för beteende.

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

Denna artikel är inte en nybörjarguide till if err != nil.

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

Den något å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) {
	// ...
}

Anropare bestämmer vad som ska göras:

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

Det finns inga undantag och ingen dold stackavvinding. Misslyckande är en del av funktionsignaturen.

Det är bra, men det innebär också att fel behöver design. Om varje paket returnerar godtyckliga meddelanden kan anropare inte fatta pålitliga beslut. Om varje lager lindar in varje fel utan disciplin får operatörer brusiga meddelanden och utvecklare förvirrande kedjor. Om inget lager lindar in 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("lasta användare %s: %w", id, err)

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

Uppgift 2: Bevara orsaken

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

Exempel:

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

%w lindar in det ursprungliga felet så att anropare kan granska det med errors.Is eller errors.As.

Uppgift 3: Låta 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 säkert meddelande för användaren
  • Avbryt transaktionen
  • Skicka felet till övervakning
  • Ignorera avbrott

Detta beslut 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 set av verktyg.

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 linda in ett fel:

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

Inlindning lägger till kontext samtidigt som det ursprungliga felet bevaras för granskning.

errors.Is

Använd errors.Is för att kontrollera om ett fel matchar en specifik måltyp 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 med försiktighet. Ett sammanfogat fel betyder 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ändaren hittades inte")
var ErrDuplicateEmail = errors.New("duplicerad e-post")

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

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 handläggare 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 måste grenar sig 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 nekas")
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 tolka rad")

Om anropare inte grenar sig baserat på dessa är de kanske bara meddelanden.

Var också försiktig med att exportera för många sentineler. Exporterade sentinel-fel blir en del av din 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 tolka en felsträng.

När man ska använda anpassade feltyper

Använd anpassade feltyper när:

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

Exempel:

  • Valideringsfel med fältname
  • Rate limit-fel med återförsökstid
  • HTTP-fel med statuskod
  • Tolkningsfel 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.

Felinlindning (Wrapping)

Inlindning lägger till kontext till ett fel samtidigt som det ursprungliga felet bevaras.

Exempel:

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

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

	return nil
}

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

  • den högsta nivån av operation: läs konfiguration
  • den underliggande orsaken: åtkomst nekad, filen hittades inte, etc.

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

Lind in med användbar kontext

Bra inlindning säger vilken operation som misslyckades:

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

Dålig inlindning lägger till brus:

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

Detta berättar ingenting för anroparen.

Undvik också att upprepa samma substantiv vid varje lager:

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

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

Lind in där kontexten ändrar betydelse. Om du inte kan förklara i en fras vilken operation som misslyckades, lindar du antingen för aggressivt eller inte tillräckligt.

När man ska linda in och när man inte ska

Detta är en av de viktigaste arkitekturbesluten.

Lind in när du korsar en meningsfull gräns

Lind in 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
}

Repertoarfelet ä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.

Lind inte in bara för att säga “misslyckades”

Dåligt:

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

Ordet “misslyckades” är vanligtvis implicit i det faktum att ett fel existerar.

Lind inte in om du översätter

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

Exempel:

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

Detta döljer medvetet databasedetaljen 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 lindar in ett lågnivå-fel med %w kan anropare granska det.

Det är vanligtvis bra inuti din applikation.

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

Till exempel, om ditt paket lindar in sql.ErrNoRows, kan anropare börja bero på det:

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

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

var ErrUserNotFound = errors.New("användaren 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 repertoar
  • repertoar till tjänst
  • tjänst till HTTP-handläggare
  • tjänst till CLI-kommando
  • internt fel till användarvänligt meddelande
  • tillfälligt misslyckande till återförsöksbeslut
  • operationsmisslyckande till logghändelse
  • domänfel till API-svar

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

Repertoargränsen

Repertoaren pratar med lagring.

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

Exempel:

var ErrUserNotFound = errors.New("användaren hittades inte")
var ErrDuplicateEmail = errors.New("duplicerad 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
}

Repertoaren 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änsen

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 samtidigt som 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("kontot 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ärslägesfel – skapade direkt från domänlogik snarare än översatta från infrastrukturvillkor.

HTTP-handläggaregränsen

HTTP-handläggaren ö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)
	}
}

Felkartläggning:

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

	case errors.Is(err, ErrAccountDisabled):
		http.Error(w, "kontot 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)
	}
}

Handläggaren kartlägger domänfel till HTTP-semantik snarare än att exponera råa databas- eller interna feldetaljer. Det är här många Go-applikationer går fel – de exponerar antingen för mycket intern detalj eller kollapsar alla fel till HTTP 500. För en komplett bild av handläggarmönster och middleware i Go-API, Bygg REST-API i Go täcker autentisering, routing och felhantering över standardbiblioteket, Gin, Echo och Fiber.

CLI-gränsen

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))
	}
}

Kartlägg 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,
	"duplicate_email",
	"e-posten är redan registrerad",
	ErrDuplicateEmail,
)

Handläggare:

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":    "internal_error",
		"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 separata från transportfel.

Domänfel:

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

Transportkartläggning:

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

Gör inte 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-adaptrar. Transportkartläggning hör hemma vid transportgränsen, inte i domänkod. För vägledning om var man ska definiera domänfel, sentineler och transportadaptrar inom din projektstruktur, Go-projektstruktur: Praxis & Mönster täcker konventionerna för internal/, pkg/ och adaptrar som håller dessa lager rent separerade.

Återförsöksbara fel

Vissa fel ska utlösa återförsök. Vissa ska inte.

Beslut inte detta genom att matcha strängar.

Använd ett markeringsgränssnitt eller 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("anropar remote api: %w", err))
	}

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

Återförsöksslinga:

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
}

Handläggare:

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 opakerad felsträng.

Flera fel

Ibland misslyckas flera saker.

Exempel:

  • stänger flera resurser
  • validerar många fält
  • stänger flera arbetare
  • kör oberoende kontroller
  • tömmer och stänger output

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 standardfelkontrolleringsmönster.

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

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

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

Returnera inte enorma sammanfogade fel till användare.

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

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 invariantöverträdelse
  • ogiltig paketinitiering
  • testhjälparmisslyckande med t.Fatal eller panic i begränsade fall
  • oåterkalleligt startkonfigurationsfel, beroende på stil

Panic inte för att en databasfråga misslyckades eller en användare skickade in ogiltig input.

Det är normala fel.

Loggning av fel

Ett vanligt Go-mistak ä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:

  • linda in fel när de rör sig 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 produktionsklar strukturerad loggningsuppsättning, Strukturerad loggning i Go med slog täcker log/slog-poster, JSON-handläggare, kontextkorrelation och redigering – allt som parar 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 operational kontext som inte kommer att synas annars.

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

Men en repertoar bör inte logga varje frågafel om handläggaren kommer att logga det slutgiltiga förfrågningsmisslyckandet.

Användarfel 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: connection refused

Användarvänligt meddelande:

internt serverfel

Operatorlogg:

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

Detta är olika publik, och en bra felarkitektur håller dem separata:

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

Att tvinga en felsträng att tjäna alla dessa publik producerar 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
  • autentiseringspolicydetaljer

Detta gäller särskilt 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 på svar:

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

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

Använd felkoder för:

  • klientbeteende
  • dokumentation
  • SDK:n
  • lokalisering
  • supportdiagnostik

Gör inte att klienter tolkar engelska felmeddelanden.

Ett praktiskt lagerbaserat fel design

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

Repertoarlager

  • Pratar med databas eller extern lagring.
  • Konverterar lagringsspecifika “hittades inte”-fel till domänfel.
  • Lindar in 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.
  • Lindar in oväntade lågnivå-fel.
  • Returnerar inte HTTP-statuskoder.
  • Loggar vanligtvis inte.

Exempel:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Transportlager

  • Kartlägger 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ändaren 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 byta lagringsteknik utan att röra tjänsteloggik eller transportkartläggning. Den lagerbaserade designen fungerar bäst när beroenden injiceras snarare än hardcodas; Beroendeinjicering i Go: Mönster & Bästa praxis täcker konstruktör- och gränssnittsmönster som gör varje gräns lätt att testa isolerat.

Kompletta exempel

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

Domänfel:

package users

import "errors"

var (
	ErrUserNotFound   = errors.New("användaren hittades inte")
	ErrDuplicateEmail = errors.New("duplicerad e-post")
	ErrAccountDisabled = errors.New("kontot inaktiverat")
)

Repertoar:

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-handläggare:

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ändaren 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-kartläggning
  • granskbara felkedjor
  • ingen strängmatchning
  • ingen transportläckage in i domänkod

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

Testa felbeteende

Felbeteende bör testas lika noggrant som den lyckliga vägen, eftersom gränsbeslut – sentinelkartläggning, typextraktion, HTTP-koder – ofta är där buggar gömmer sig längst. För en full guide till Go-teststruktur, mockning och täckningsmönster, se Go enhetstestning: Struktur & Bästa praxis.

Testa sentinelkartläggning

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-kartläggning

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 refaktorerings av lagrings- eller transportlager inte tyst kan ändra misslyckandeavtalet.

Vanliga antipattern

Antipattern 1: Strängmatchning

Dåligt:

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

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

Antipattern 2: Förlora orsaken

Dåligt:

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

Bättre:

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

Antipattern 3: Inlindning utan mening

Dåligt:

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

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

Antipattern 4: Loggning vid varje lager

Dåligt:

log.Println(err)
return err

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

Antipattern 5: Returnera HTTP-fel från domänkod

Dåligt:

return &APIError{Status: http.StatusNotFound}

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

Antipattern 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.

Antipattern 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 inte externa anropare verkligen behöver grenar sig baserat på det – föredra att hålla sentineler oexporterade tills det finns ett tydligt behov.

Antipattern 8: Använda panic för förväntade misslyckanden

Dåligt:

panic(err)

för normala runtime-fel. Reservera panic för verkligen oåterkalleliga villkor eller programmeringsfel, inte för saknade poster eller ogiltig användarinput – returnera alltid fel i dessa fall.

Antipattern 9: Ignorera kontextfel

Dåligt:

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

när den verkliga orsaken var context.Canceled. Bevara kontextfel så att anropare kan skilja mellan ett genuint operationsmisslyckande och en avbruten eller timeout-förfrågan, och svara lämpligt på varje. För en genomgång av hur kontextavbrott och timeout-propagering fungerar över tjänstelager, se Go context.Context Done Right.

Felgranskingschecklista

Använd denna checklista vid kodgranskning.

Skapande av fel

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

Inlindning av fel

  • Lägger inlindningen till användbar operationskontext?
  • Bevarar %w orsaken där den behövs?
  • Exponerar koden oavsiktligt implementeringsdetaljer?
  • Är kedjan för brusig?

Översättning av fel

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

Hantering av fel

  • Grenar anroparen med errors.Is eller errors.As?
  • Hanteras kontextavbrott och deadlines 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 felcase?
  • Testas HTTP- eller CLI-kartläggningar?
  • Testas valideringsdetaljer?
  • Testas återförsöksbeslut?

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: Lind in för kontext, inte dekoration

Om inlindning inte lägger till användbar information om vilken operation som misslyckades, lind inte in. 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 finns under.

Regel 4: Tolka inte felsträngar

Om kod behöver grenar sig 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

Lind in när fel rör sig 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 handläggare eller API-adaptrar, inte i domäntjänster. Domänkod bör vara återanvändbar över transporter – idag HTTP, imorgon CLI, gRPC eller en händelsedriven 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:

return errors
linda in med %w
kontrollera med errors.Is
extrahera med errors.As
foga samman när flera fel har betydelse

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 gjord väl – inte smart, inte magisk, men tydlig nog för att nästa utvecklare, operatör, API-klient och framtida du ska förstå vad som misslyckades och vad som ska hända nästa. För en bredare bild av produktions Go-mönster över integration, testning och dataåtkomst, se Apparkitektur i produktion.

Källor

Prenumerera

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