tinka alpha

Exception handling

This guide covers how Tinka turns errors into responses. You will learn how to:

Overview

A Tinka handler returns an error instead of writing failure responses itself. The App renders it: an expected failure becomes RFC 9457 problem details with its status and message, and anything else becomes a generic 500 that's logged with the request's ID. Internal details, such as a database address in an error message, never reach the client.

Return an expected failure

Return one of the constructors when the client should know what went wrong. Each takes a message for people, formatted like fmt.Sprintf.

posts.go
package main

import (
	"net/http"

	"github.com/mucolabs/tinka"
)

func showDraft(w http.ResponseWriter, r *http.Request) error {
	id, err := tinka.PathInt(r, "id")
	if err != nil {
		return err
	}
	if id > 100 {
		return tinka.NotFound("No draft %d.", id)
	}
	return tinka.JSON(w, http.StatusOK, map[string]int64{"id": id})
}

The constructors cover the common statuses:

For anything else, return a *tinka.Error with your own status and a stable Code that clients can match on:

go
	return &tinka.Error{
		Status:  http.StatusPaymentRequired,
		Code:    "card_declined",
		Message: "The card was declined.",
		Hint:    "Try another card or contact your bank.",
	}

Map Capability errors to responses

A Capability returns sentinel errors such as ErrEmailTaken, with no HTTP in sight. A tinka.ErrorMap turns them into responses in one place, and any error it doesn't know passes through as a logged 500. Here the Capability's errors sit beside the handler to keep the example in one file:

signup.go
package main

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

	"github.com/mucolabs/tinka"
)

var (
	ErrInvalidEmail = errors.New("invalid email address")
	ErrEmailTaken   = errors.New("email address already has an account")
)

var signupErrors = tinka.ErrorMap{
	ErrInvalidEmail: tinka.Invalid("email", "The email address is invalid."),
	ErrEmailTaken:   tinka.Conflict("An account already uses this email."),
}

The handler calls the Action and maps whatever it refuses with:

signup.go
type signup struct {
	register func(ctx context.Context, email string) (int64, error)
}

type signupForm struct {
	Email string `json:"email"`
}

func (h signup) execute(w http.ResponseWriter, r *http.Request) error {
	in, err := tinka.Decode[signupForm](w, r)
	if err != nil {
		return err
	}
	id, err := h.register(r.Context(), in.Email)
	if err != nil {
		return signupErrors.Map(err)
	}
	return tinka.JSON(w, http.StatusCreated, map[string]int64{"id": id})
}

Map matches wrapped errors with errors.Is, and the response it returns keeps the original error as its cause, so the log still shows what happened.

Read the response clients get

Clients receive problem details with Content-Type: application/problem+json. code, hint, fields and request_id extend the standard members:

Response
{
  "title": "Conflict",
  "status": 409,
  "detail": "An account already uses this email.",
  "code": "conflict",
  "request_id": "S5QWQ3DAEXTVZ6M7F4DZEL6EBI"
}

A browser that asks for HTML gets the same information as a small HTML page instead. The request_id matches the X-Request-Id response header and the log, so a user's report leads straight to the log record.

Render errors your own way

OnError replaces the default rendering, for example with your own HTML pages. It receives the error as the client should see it. If it returns an error, the App sends the default problem details instead.

main.go
	app.OnError(func(
		w http.ResponseWriter, r *http.Request, e *tinka.Error,
	) error {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.WriteHeader(e.Status)
		_, err := fmt.Fprintf(w, "%d: %s\n", e.Status, e.Message)
		return err
	})

Know what the App does on its own

A few failures need no code from you:

See also: App, Body parser