Skip to content

Instantly share code, notes, and snippets.

@sithumonline
Last active September 17, 2025 14:50
Show Gist options
  • Select an option

  • Save sithumonline/1738a01ba3d9c8b52d2e9dda39b44bd3 to your computer and use it in GitHub Desktop.

Select an option

Save sithumonline/1738a01ba3d9c8b52d2e9dda39b44bd3 to your computer and use it in GitHub Desktop.
Error handling principles - Golang
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"runtime/debug"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
/* -------------------- Domain & Error Model -------------------- */
var (
ErrNotFound = errors.New("not found") // sentinel
ErrConflict = errors.New("conflict")
ErrTooLarge = errors.New("too large")
)
type ValidationError struct {
Field string `json:"field"`
Reason string `json:"reason"`
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
// Temporary marker to classify transient errors (for retries / 503 mapping).
type Temporary interface{ Temporary() bool }
type tempErr struct{ error }
func (e tempErr) Temporary() bool { return true }
func Retryable(err error) error {
if err == nil {
return nil
}
return tempErr{err}
}
// Wrap adds operation context while preserving the cause chain.
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", msg, err)
}
/* -------------------- Repository (Infra) -------------------- */
type Order struct {
ID string `json:"id"`
SKU string `json:"sku"`
Qty int `json:"qty"`
CreatedAt time.Time `json:"created_at"`
}
type OrderRepo struct {
mu sync.RWMutex
byID map[string]Order
nextID int
}
func NewOrderRepo() *OrderRepo {
return &OrderRepo{byID: make(map[string]Order), nextID: 1}
}
func (r *OrderRepo) Get(ctx context.Context, id string) (Order, error) {
r.mu.RLock()
defer r.mu.RUnlock()
// Simulate slow IO honoring context.
select {
case <-time.After(10 * time.Millisecond):
case <-ctx.Done():
return Order{}, Wrap(ctx.Err(), "repo get order")
}
if o, ok := r.byID[id]; ok {
return o, nil
}
return Order{}, fmt.Errorf("order %s: %w", id, ErrNotFound)
}
func (r *OrderRepo) Insert(ctx context.Context, sku string, qty int) (Order, error) {
// Simulate a transient write hiccup (e.g., brief DB outage).
if time.Now().UnixNano()%17 == 0 {
return Order{}, Retryable(errors.New("db temporarily unavailable"))
}
r.mu.Lock()
defer r.mu.Unlock()
// Simple “conflict”: SKU+time window duplicate (demo only).
for _, o := range r.byID {
if o.SKU == sku && time.Since(o.CreatedAt) < 2*time.Second {
return Order{}, fmt.Errorf("duplicate recent SKU: %w", ErrConflict)
}
}
id := fmt.Sprintf("%d", r.nextID)
r.nextID++
o := Order{
ID: id,
SKU: sku,
Qty: qty,
CreatedAt: time.Now(),
}
r.byID[id] = o
return o, nil
}
/* -------------------- Service (Business) -------------------- */
type Service struct {
repo *OrderRepo
}
func NewService(repo *OrderRepo) *Service { return &Service{repo: repo} }
type CreateOrderReq struct {
SKU string `json:"sku"`
Qty int `json:"qty"`
}
func (s *Service) Create(ctx context.Context, in CreateOrderReq) (Order, error) {
if in.SKU == "" {
return Order{}, &ValidationError{"sku", "required"}
}
if in.Qty <= 0 {
return Order{}, &ValidationError{"qty", "must be > 0"}
}
if in.Qty > 1000 {
return Order{}, fmt.Errorf("qty=%d: %w", in.Qty, ErrTooLarge)
}
o, err := s.repo.Insert(ctx, in.SKU, in.Qty)
if err != nil {
return Order{}, Wrap(err, "insert order")
}
return o, nil
}
func (s *Service) Get(ctx context.Context, id string) (Order, error) {
if id == "" {
return Order{}, &ValidationError{"id", "required"}
}
o, err := s.repo.Get(ctx, id)
if err != nil {
return Order{}, Wrap(err, "get order")
}
return o, nil
}
/* -------------------- HTTP (Boundary) -------------------- */
const StatusClientClosedRequest = 499 // non-standard but commonly used
type HTTP struct {
r *gin.Engine
log *zap.Logger
svc *Service
}
func NewHTTP(log *zap.Logger, svc *Service) *HTTP {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Middlewares: recovery first, then structured logging.
r.Use(RecoverMiddleware(log))
r.Use(RequestLogMiddleware(log))
h := &HTTP{r: r, log: log, svc: svc}
// Route helpers that centralize error handling & single-point logging.
r.POST("/orders", h.wrap(h.createOrder))
r.GET("/orders/:id", h.wrap(h.getOrder))
// Simple health.
r.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
return h
}
// wrap turns a func(*gin.Context) error into a gin.HandlerFunc and ensures
// we log ONCE and map errors to HTTP responses in a single boundary place.
func (h *HTTP) wrap(fn func(*gin.Context) error) gin.HandlerFunc {
return func(c *gin.Context) {
if err := fn(c); err != nil {
status := Code(err)
// Redact sensitive fields; include actionable context.
h.log.Error("request failed",
zap.Int("status", status),
zap.String("path", c.FullPath()),
zap.String("method", c.Request.Method),
zap.String("client_ip", c.ClientIP()),
zap.Error(err),
)
writeErrorJSON(c, status, err)
}
}
}
func (h *HTTP) createOrder(c *gin.Context) error {
var in CreateOrderReq
if err := json.NewDecoder(c.Request.Body).Decode(&in); err != nil {
return &ValidationError{"body", "invalid JSON"}
}
// Boundary applies timeout; service honors ctx.
ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
o, err := h.svc.Create(ctx, in)
if err != nil {
return err
}
c.JSON(http.StatusCreated, o)
return nil
}
func (h *HTTP) getOrder(c *gin.Context) error {
id := c.Param("id")
ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
o, err := h.svc.Get(ctx, id)
if err != nil {
return err
}
c.JSON(http.StatusOK, o)
return nil
}
func (h *HTTP) Run(addr string) error { return h.r.Run(addr) }
/* -------------------- Error Mapping & Middleware -------------------- */
// Code maps error classes to HTTP status codes.
func Code(err error) int {
var v *ValidationError
switch {
case errors.As(err, &v):
return http.StatusBadRequest
case errors.Is(err, ErrNotFound):
return http.StatusNotFound
case errors.Is(err, ErrConflict):
return http.StatusConflict
case errors.Is(err, ErrTooLarge):
return http.StatusRequestEntityTooLarge
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout
case errors.Is(err, context.Canceled):
return StatusClientClosedRequest // client gave up
default:
// Transient?
var t Temporary
if errors.As(err, &t) && t.Temporary() {
return http.StatusServiceUnavailable
}
return http.StatusInternalServerError
}
}
type errorBody struct {
Error string `json:"error"`
}
func writeErrorJSON(c *gin.Context, status int, err error) {
// Show concise, user-safe message; avoid leaking internals.
// For demo we expose err.Error(); in prod, consider mapping to friendlier text.
c.AbortWithStatusJSON(status, errorBody{Error: err.Error()})
}
// RecoverMiddleware converts panics into 500s and logs stack once.
func RecoverMiddleware(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if rec := recover(); rec != nil {
log.Error("panic recovered", zap.Any("panic", rec), zap.ByteString("stack", debug.Stack()))
writeErrorJSON(c, http.StatusInternalServerError, errors.New("internal error"))
}
}()
c.Next()
}
}
// RequestLogMiddleware logs request/response at boundary (no double-logging elsewhere).
func RequestLogMiddleware(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // run handlers
lat := time.Since(start)
// If the error was already logged by wrap(), avoid duplicating here.
// We still log basic access line.
log.Info("access",
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.String("ip", c.ClientIP()),
zap.Duration("latency", lat),
)
}
}
/* -------------------- main -------------------- */
func main() {
log, _ := zap.NewProduction()
defer log.Sync()
repo := NewOrderRepo()
svc := NewService(repo)
api := NewHTTP(log, svc)
log.Info("listening", zap.String("addr", ":8080"))
if err := api.Run(":8080"); err != nil {
log.Fatal("server stopped", zap.Error(err))
}
}
package main
import "core:fmt"
import "core:math/rand"
import "core:os"
import "core:strings"
import "core:time"
/* --------------------------- Result & Domain Error --------------------------- */
Domain_Error :: enum {
Validation,
NotFound,
Conflict,
TooLarge,
Timeout,
Canceled,
Temporary,
Internal,
}
DomainErr :: struct {
kind: Domain_Error,
message: string,
meta: map[string]string, // optional machine-readable context
cause: ^DomainErr, // optional cause chain (as value, no throw)
}
// Generic Result<T> = Ok T | Err DomainErr
Result :: union($T: typeid) {
Ok: T,
Err: DomainErr,
}
ok :: proc($T: typeid, v: T) -> Result(T) { return Result(T).Ok(v); }
err :: proc($T: typeid, e: DomainErr) -> Result(T) { return Result(T).Err(e); }
// helpers to build domain errors (concise)
E_validation :: proc(msg: string, meta: map[string]string = nil) -> DomainErr {
return DomainErr{Domain_Error.Validation, msg, meta, nil};
}
E_not_found :: proc(msg: string, meta: map[string]string = nil) -> DomainErr {
return DomainErr{Domain_Error.NotFound, msg, meta, nil};
}
E_conflict :: proc(msg: string, meta: map[string]string = nil) -> DomainErr {
return DomainErr{Domain_Error.Conflict, msg, meta, nil};
}
E_too_large :: proc(msg: string, meta: map[string]string = nil) -> DomainErr {
return DomainErr{Domain_Error.TooLarge, msg, meta, nil};
}
E_temporary :: proc(msg: string = "temporarily unavailable", meta: map[string]string = nil) -> DomainErr {
return DomainErr{Domain_Error.Temporary, msg, meta, nil};
}
E_internal :: proc(msg: string, meta: map[string]string = nil, cause: ^DomainErr = nil) -> DomainErr {
return DomainErr{Domain_Error.Internal, msg, meta, cause};
}
// with_ctx keeps the cause chain as values (no stack-throwing)
with_ctx :: proc(e: DomainErr, where: string, meta: map[string]string = nil) -> DomainErr {
out := e;
out.message = where + ": " + e.message;
if meta != nil {
if out.meta == nil do out.meta = make(map[string]string);
for k, v in meta do out.meta[k] = v;
}
// keep a cause pointer so we can introspect if needed
out.cause = new(DomainErr);
out.cause^ = e;
return out;
}
/* --------------------------------- Domain ---------------------------------- */
Order :: struct {
id: string,
sku: string,
qty: int,
created_ms: i64,
}
/* ---------------------------------- Repo ----------------------------------- */
OrderRepo :: struct {
by_id: map[string]Order,
next_id: int,
}
order_repo_init :: proc() -> OrderRepo {
return OrderRepo{by_id = make(map[string]Order), next_id = 1};
}
// honor “no-throw”: everything returns Result<T>
order_repo_get :: proc(repo: ^OrderRepo, id: string) -> Result(Order) {
if o, ok := repo.by_id[id]; ok {
return ok(Order, o);
}
return err(Order, E_not_found("order " + id + " not found"));
}
order_repo_insert :: proc(repo: ^OrderRepo, sku: string, qty: int) -> Result(Order) {
// simulate a transient hiccup roughly 1/17 of the time
if (rand.i64n(17) == 0) {
return err(Order, E_temporary());
}
// “recent duplicate” conflict (same SKU within 2s)
now_ms := time.now().milliseconds;
for _, o in repo.by_id {
if o.sku == sku && (now_ms - o.created_ms) < 2_000 {
return err(Order, E_conflict("duplicate recent SKU", map[string]string{"sku" = sku}));
}
}
id := fmt.int_to_string(repo.next_id);
repo.next_id += 1;
o := Order{id, sku, qty, now_ms};
repo.by_id[id] = o;
return ok(Order, o);
}
/* --------------------------------- Service --------------------------------- */
OrderService :: struct {
repo: ^OrderRepo,
}
CreateOrderReq :: struct {
sku: string,
qty: int,
}
order_svc_create :: proc(svc: ^OrderService, in: CreateOrderReq) -> Result(Order) {
if strings.trim_space(in.sku) == "" do return err(Order, E_validation("sku required"));
if in.qty <= 0 do return err(Order, E_validation("qty must be > 0"));
if in.qty > 1000 do return err(Order, E_too_large("qty exceeds limit", map[string]string{"limit"="1000"}));
r := order_repo_insert(svc.repo, in.sku, in.qty);
return when r in Result(Order) {
.Ok => ok(Order, r),
.Err => err(Order, with_ctx(r, "insert order")),
};
}
order_svc_get :: proc(svc: ^OrderService, id_raw: string) -> Result(Order) {
// very simple validation
for ch in id_raw {
if ch < '0' || ch > '9' {
return err(Order, E_validation("id must be positive integer"));
}
}
r := order_repo_get(svc.repo, id_raw);
return when r in Result(Order) {
.Ok => ok(Order, r),
.Err => err(Order, with_ctx(r, "get order", map[string]string{"id" = id_raw})),
};
}
/* ------------------------------- HTTP boundary ------------------------------ */
Response :: struct {
status: int,
body: string,
}
// single mapping place
status_from_error :: proc(e: DomainErr) -> int {
switch e.kind {
case .Validation: return 400;
case .NotFound: return 404;
case .Conflict: return 409;
case .TooLarge: return 413;
case .Timeout: return 504;
case .Canceled: return 499; // non-standard but common
case .Temporary: return 503;
default: return 500;
}
}
json_order :: proc(o: Order) -> string {
// hand-rolled JSON to keep the example minimal
return fmt.tprintf("{\"id\":\"%s\",\"sku\":\"%s\",\"qty\":%v,\"created_ms\":%v}", o.id, o.sku, o.qty, o.created_ms);
}
write_ok :: proc(o: Order, method: string) -> Response {
status := 200;
if method == "POST" do status = 201;
return Response{status, json_order(o)};
}
write_err :: proc(e: DomainErr) -> Response {
code := status_from_error(e);
msg := strings.replace_all(e.message, "\"", "'"); // keep body simple/safe
return Response{code, fmt.tprintf("{\"error\":\"%s\"}", msg)};
}
// single-point logging (boundary)
log_access :: proc(path, method: string, status: int, start_ms: i64, e: ^DomainErr = nil) {
dur := time.now().milliseconds - start_ms;
if e == nil {
fmt.println("INFO access method=", method, " path=", path, " status=", status, " ms=", dur);
} else {
fmt.println("ERROR request failed method=", method, " path=", path,
" status=", status, " ms=", dur,
" kind=", e.kind, " msg=\"", e.message, "\"");
}
}
/* ------------------------------- Handlers ----------------------------------- */
handle_create_order :: proc(svc: ^OrderService, body: CreateOrderReq) -> Response {
t0 := time.now().milliseconds;
r := order_svc_create(svc, body);
when r in Result(Order) {
.Ok => {
res := write_ok(r, "POST");
log_access("/orders", "POST", res.status, t0);
return res;
},
.Err => {
res := write_err(r);
log_access("/orders", "POST", res.status, t0, &r);
return res;
},
}
}
handle_get_order :: proc(svc: ^OrderService, id: string) -> Response {
t0 := time.now().milliseconds;
r := order_svc_get(svc, id);
when r in Result(Order) {
.Ok => {
res := write_ok(r, "GET");
log_access("/orders/"+id, "GET", res.status, t0);
return res;
},
.Err => {
res := write_err(r);
log_access("/orders/"+id, "GET", res.status, t0, &r);
return res;
},
}
}
/* ----------------------------------- main ----------------------------------- */
main :: proc() {
// seed rng for transient simulation
rand.seed(os.time_now().seconds);
repo := order_repo_init();
svc := OrderService{&repo};
// Simulated requests (HTTP-ish)
fmt.println("== POST /orders {\"sku\":\"ABC\",\"qty\":2} ==");
r1 := handle_create_order(&svc, CreateOrderReq{"ABC", 2});
fmt.println(r1.status, " ", r1.body);
fmt.println("\n== GET /orders/1 ==");
r2 := handle_get_order(&svc, "1");
fmt.println(r2.status, " ", r2.body);
fmt.println("\n== POST /orders {\"sku\":\"\",\"qty\":2} (validation) ==");
r3 := handle_create_order(&svc, CreateOrderReq{"", 2});
fmt.println(r3.status, " ", r3.body);
fmt.println("\n== POST /orders {\"sku\":\"ABC\",\"qty\":5001} (too large) ==");
r4 := handle_create_order(&svc, CreateOrderReq{"ABC", 5001});
fmt.println(r4.status, " ", r4.body);
fmt.println("\n== GET /orders/999 (not found) ==");
r5 := handle_get_order(&svc, "999");
fmt.println(r5.status, " ", r5.body);
fmt.println("\n== POST /orders {\"sku\":\"ZIG\",\"qty\":1} (maybe 503 transient) ==");
r6 := handle_create_order(&svc, CreateOrderReq{"ZIG", 1});
fmt.println(r6.status, " ", r6.body);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment