Last active
September 17, 2025 14:50
-
-
Save sithumonline/1738a01ba3d9c8b52d2e9dda39b44bd3 to your computer and use it in GitHub Desktop.
Error handling principles - Golang
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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