Get a quote

Go Error Handling in Production SaaS: Wrapping, Context, and Sentry Integration

Go's error handling looks simple on day one. By the time you're running a multi-tenant SaaS with five services and a pager rotation, 'something went wrong' stops being acceptable.

Go's error handling looks simple on day one. By the time you're running a multi-tenant SaaS with five services and a pager rotation, "something went wrong" stops being acceptable. The difference between a useful error and a useless one is not whether the error was returned. It is whether the error tells you what failed, why it failed, and exactly where in the call stack it happened.

This post covers how we structure error handling in Go production services: wrapping with context, custom error types, Sentry integration that surfaces signal without noise, and the common mistakes that burn teams at scale.

Why Go's Error Handling Is Deceptively Simple

Go gives you errors.New, fmt.Errorf, errors.Is, and errors.As. That is the entire standard library. For a tutorial project, this is plenty. For a production SaaS billing service processing transactions in USD, LBP, and AED, it falls apart fast.

The first problem is errors.New. A bare sentinel error like this:

var ErrNotFound = errors.New("not found")

tells you nothing about which resource was not found, which tenant triggered the lookup, or which layer of the stack first produced the error. By the time this surfaces in your HTTP handler, the context that would have helped you debug it at 2am is gone.

fmt.Errorf with %w is better because it preserves the error chain for errors.Is and errors.As:

return fmt.Errorf("getUser: tenant %s: %w", tenantID, err)

But even this is informal. There is no structured field for tenant ID, no HTTP status hint, no distinction between an error that should wake someone up and one that is expected operational noise.

Wrapping Errors with Context at Every Layer

The rule we follow: every function that calls another function wraps the error before returning it. The wrapper adds the operation name and any IDs that are in scope at that layer.

func (r *UserRepository) GetByID(ctx context.Context, tenantID, userID string) (*User, error) {
    var user User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email, created_at FROM users WHERE id = $1 AND tenant_id = $2`,
        userID, tenantID,
    ).Scan(&user.ID, &user.Email, &user.CreatedAt)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("UserRepository.GetByID: tenant=%s user=%s: %w",
                tenantID, userID, ErrNotFound)
        }
        return nil, fmt.Errorf("UserRepository.GetByID: tenant=%s user=%s: %w",
            tenantID, userID, err)
    }
    return &user, nil
}

At the service layer, wrap again:

func (s *UserService) GetUser(ctx context.Context, tenantID, userID string) (*UserResponse, error) {
    user, err := s.repo.GetByID(ctx, tenantID, userID)
    if err != nil {
        return nil, fmt.Errorf("UserService.GetUser: %w", err)
    }
    return toUserResponse(user), nil
}

Now when an error reaches your HTTP handler, err.Error() gives you:

UserService.GetUser: UserRepository.GetByID: tenant=acme user=usr_123: not found

That is a stack trace in plain text. You know the service, the repository method, the tenant, and the user, without a debugger.

Sentinel errors versus descriptive errors: Use sentinels for errors that callers need to check programmatically (ErrNotFound, ErrUnauthorized, ErrConflict). Use descriptive wrapped errors for everything else. Never return a raw sql.ErrNoRows or context.DeadlineExceeded directly from a public method without wrapping it in your domain vocabulary.

Structuring Errors for Production Observability

Wrapped strings are a good start. A custom error type is the next step. Here is the AppError type we use across our services:

type AppError struct {
    Code       string // machine-readable: "user.not_found", "billing.insufficient_funds"
    Message    string // safe to show end users
    Internal   string // internal detail, never sent to clients
    HTTPStatus int    // hint for the HTTP layer
    Err        error  // the underlying wrapped error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %s: %v", e.Code, e.Internal, e.Err)
    }
    return fmt.Sprintf("%s: %s", e.Code, e.Internal)
}

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

func NotFoundError(resource, id string) *AppError {
    return &AppError{
        Code:       "resource.not_found",
        Message:    resource + " not found",
        Internal:   fmt.Sprintf("%s id=%s does not exist", resource, id),
        HTTPStatus: http.StatusNotFound,
    }
}

func DatabaseError(op string, err error) *AppError {
    return &AppError{
        Code:       "database.error",
        Message:    "An internal error occurred. Please try again.",
        Internal:   fmt.Sprintf("database operation %s failed", op),
        HTTPStatus: http.StatusInternalServerError,
        Err:        err,
    }
}

In your HTTP handler, extract the AppError with errors.As and respond appropriately:

func writeError(w http.ResponseWriter, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(appErr.HTTPStatus)
        json.NewEncoder(w).Encode(map[string]string{
            "error": appErr.Message,
            "code":  appErr.Code,
        })
        return
    }
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error"})
}

The Code field is particularly valuable for API clients. A frontend can switch on "billing.insufficient_funds" and show the appropriate modal without parsing a message string.

Sentry Integration in Go Without the Noise

Sentry is only useful if the alerts mean something. If every 404 and every input validation failure fires a Sentry event, on-call becomes immune to the noise and starts ignoring alerts. You want Sentry to tell you about errors that require engineering attention, not errors that users cause themselves.

The pattern: wrap your HTTP handler stack with middleware that captures errors selectively.

func SentryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        hub := sentry.GetHubFromContext(r.Context())
        if hub == nil {
            hub = sentry.CurrentHub().Clone()
        }

        hub.Scope().SetRequest(r)

        if claims, ok := r.Context().Value(ctxKeyClaims).(*Claims); ok {
            hub.Scope().SetUser(sentry.User{
                ID:    claims.UserID,
                Email: claims.Email,
            })
            hub.Scope().SetTag("tenant_id", claims.TenantID)
            hub.Scope().SetTag("request_id", r.Header.Get("X-Request-ID"))
        }

        ctx := sentry.SetHubOnContext(r.Context(), hub)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func captureIfNeeded(ctx context.Context, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        // Do not send 4xx client errors to Sentry
        if appErr.HTTPStatus >= 400 && appErr.HTTPStatus < 500 {
            return
        }
    }
    hub := sentry.GetHubFromContext(ctx)
    if hub != nil {
        hub.CaptureException(err)
    }
}

This filters out 404s, 401s, 403s, and 422s. Sentry only receives 5xx errors and unexpected conditions. The tenant ID and request ID tags make every Sentry issue immediately actionable.

For teams running SaaS in the MENA region, where customers may have intermittent connectivity and retry-heavy mobile clients, this filtering is especially important. High retry rates can flood Sentry with 429s and transient timeout errors that are not actually bugs in your code.

Common Mistakes in Go Error Handling at Scale

Swallowing errors in goroutines. This is the most dangerous pattern. A goroutine that fails silently leaves your system in a partially inconsistent state with no trace:

// WRONG: error is silently dropped
go func() {
    if err := sendNotification(ctx, userID); err != nil {
        // nothing here
    }
}()

// CORRECT: log it and capture if needed
go func() {
    if err := sendNotification(ctx, userID); err != nil {
        log.Error("sendNotification failed",
            "user_id", userID,
            "error", err,
        )
        captureIfNeeded(ctx, err)
    }
}()

Logging and returning. This creates duplicate log lines for the same error at every layer of the stack. Log once, at the boundary where the error leaves your system. Wrap and return everywhere below.

Using panic for non-panic situations. panic is for unrecoverable programmer errors: nil pointer dereferences, index out of bounds, invariant violations that represent bugs in the program itself. It is not for "user not found" or "payment gateway returned 402". A panic in a goroutine will crash your entire process. Use errors.

Not including the error in structured log fields. This is common with structured loggers:

// WRONG: error is in the message, not a field
log.Error(fmt.Sprintf("failed to process order: %v", err))

// CORRECT: error as a structured field so log aggregators can index it
log.Error("failed to process order", "error", err, "order_id", orderID)

Key Lessons from Production

  • Wrap every error with operation name and relevant IDs before returning it.
  • Build a custom AppError type early: HTTP status, user message, internal detail, and machine-readable code in one struct.
  • Filter Sentry: 4xx errors are user behavior, not bugs. Send only 5xx and unexpected errors.
  • Set tenant ID and request ID as Sentry tags on every event so on-call knows which customer is affected.
  • Log once at the boundary, not at every layer.
  • Never swallow errors in goroutines.
  • Never use panic for business logic errors.
  • Use errors.Is and errors.As for programmatic error checking, never string matching.

Not sure where to start?

If you are building a Go backend and want to get observability and error handling right from day one rather than retrofitting it after your first production incident, we can help. Reach out to the Voxire engineering team and we will walk through your architecture together. https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp