Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5381bccbf7
|
16
CHANGELOG.md
16
CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this module will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.10.0] - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- `(*Err).WithPlatformCode(code string) *Err` — chainable builder method to attach a platform-level error code; returns the receiver for chaining
|
||||
- `(*Err).PlatformCode() string` — getter that returns the platform code, or an empty string if none was set
|
||||
- `platformCode` field in `MarshalJSON` output — serialised as `"platformCode"` and omitted when empty (`omitempty`)
|
||||
|
||||
### Design Notes
|
||||
|
||||
- Platform codes operate at the domain/system layer and are intentionally decoupled from the transport-level `Code`. `Code` drives HTTP status mapping and gRPC status codes; `PlatformCode` is a stable semantic identifier for the consuming application (e.g. a frontend performing i18n).
|
||||
- Platform codes are **optional**. Errors that do not have a user-actionable meaning (500 internal errors, infrastructure failures, authentication rejections) should not carry one.
|
||||
- No existing behaviour changed — `Code`, `Error`, `Unwrap`, `WithContext`, `MarshalJSON` (for errors without a platform code) are all backwards-compatible.
|
||||
|
||||
[0.10.0]: https://code.nochebuena.dev/go/xerrors/compare/v0.9.0...v0.10.0
|
||||
|
||||
## [0.9.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
54
xerrors.go
54
xerrors.go
@@ -13,18 +13,22 @@ import (
|
||||
// interfaces that logz uses internally to enrich log records — without either
|
||||
// package importing the other.
|
||||
//
|
||||
// Use the builder methods [Err.WithContext] and [Err.WithError] to attach
|
||||
// additional information after construction:
|
||||
// Use the builder methods [Err.WithContext], [Err.WithError], and
|
||||
// [Err.WithPlatformCode] to attach additional information after construction:
|
||||
//
|
||||
// err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||
// WithContext("field", "email").
|
||||
// WithContext("rule", "required").
|
||||
// WithError(cause)
|
||||
//
|
||||
// err := xerrors.New(xerrors.ErrNotFound, "employee not found").
|
||||
// WithPlatformCode("EMPLOYEE_NOT_FOUND")
|
||||
type Err struct {
|
||||
code Code
|
||||
message string
|
||||
err error
|
||||
fields map[string]any
|
||||
code Code
|
||||
message string
|
||||
err error
|
||||
fields map[string]any
|
||||
platformCode string
|
||||
}
|
||||
|
||||
// New creates an Err with the given code and message. No cause is set.
|
||||
@@ -80,6 +84,27 @@ func (e *Err) WithError(err error) *Err {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithPlatformCode sets a platform-level error code and returns the receiver
|
||||
// for chaining. Platform codes are domain-specific identifiers (e.g.
|
||||
// "EMPLOYEE_NOT_FOUND") that operate independently of the transport-level
|
||||
// [Code]. They are intended for consuming applications — such as a frontend —
|
||||
// that need to map errors to localised user-facing messages without relying on
|
||||
// the generic transport code.
|
||||
//
|
||||
// Platform codes are optional. Errors that do not have a user-actionable
|
||||
// meaning (e.g. 500 internal errors, infrastructure failures) should not carry
|
||||
// one; the consuming application renders a generic fallback in those cases.
|
||||
func (e *Err) WithPlatformCode(code string) *Err {
|
||||
e.platformCode = code
|
||||
return e
|
||||
}
|
||||
|
||||
// PlatformCode returns the platform-level error code, or an empty string if
|
||||
// none was set.
|
||||
func (e *Err) PlatformCode() string {
|
||||
return e.platformCode
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
// Format: "INVALID_ARGUMENT: username is required → original cause"
|
||||
func (e *Err) Error() string {
|
||||
@@ -151,15 +176,18 @@ func (e *Err) ErrorContext() map[string]any {
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler].
|
||||
// Output: {"code":"NOT_FOUND","message":"user not found","fields":{"id":"42"}}
|
||||
// Output: {"code":"NOT_FOUND","platformCode":"EMPLOYEE_NOT_FOUND","message":"...","fields":{...}}
|
||||
// platformCode and fields are omitted when empty.
|
||||
func (e *Err) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Code string `json:"code"`
|
||||
PlatformCode string `json:"platformCode,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}{
|
||||
Code: string(e.code),
|
||||
Message: e.message,
|
||||
Fields: e.fields,
|
||||
Code: string(e.code),
|
||||
PlatformCode: e.platformCode,
|
||||
Message: e.message,
|
||||
Fields: e.fields,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +243,72 @@ func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) {
|
||||
_ = err.ErrorContext()
|
||||
}
|
||||
|
||||
func TestErr_WithPlatformCode(t *testing.T) {
|
||||
t.Run("set and get", func(t *testing.T) {
|
||||
err := New(ErrNotFound, "employee not found").
|
||||
WithPlatformCode("EMPLOYEE_NOT_FOUND")
|
||||
if err.PlatformCode() != "EMPLOYEE_NOT_FOUND" {
|
||||
t.Errorf("PlatformCode() = %q, want EMPLOYEE_NOT_FOUND", err.PlatformCode())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty by default", func(t *testing.T) {
|
||||
err := New(ErrNotFound, "something not found")
|
||||
if err.PlatformCode() != "" {
|
||||
t.Errorf("PlatformCode() = %q, want empty string", err.PlatformCode())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chaining with WithContext", func(t *testing.T) {
|
||||
err := New(ErrAlreadyExists, "email taken").
|
||||
WithPlatformCode("EMAIL_ALREADY_EXISTS").
|
||||
WithContext("field", "email")
|
||||
if err.PlatformCode() != "EMAIL_ALREADY_EXISTS" {
|
||||
t.Errorf("PlatformCode() = %q, want EMAIL_ALREADY_EXISTS", err.PlatformCode())
|
||||
}
|
||||
if err.Fields()["field"] != "email" {
|
||||
t.Errorf("Fields()[field] = %v, want email", err.Fields()["field"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chaining preserves transport code", func(t *testing.T) {
|
||||
err := New(ErrPermissionDenied, "protected").
|
||||
WithPlatformCode("ROLE_SYSTEM_PROTECTED")
|
||||
if err.Code() != ErrPermissionDenied {
|
||||
t.Errorf("Code() = %s, want %s", err.Code(), ErrPermissionDenied)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestErr_MarshalJSON_PlatformCode(t *testing.T) {
|
||||
t.Run("included when set", func(t *testing.T) {
|
||||
err := New(ErrNotFound, "employee not found").
|
||||
WithPlatformCode("EMPLOYEE_NOT_FOUND")
|
||||
b, jsonErr := json.Marshal(err)
|
||||
if jsonErr != nil {
|
||||
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||||
}
|
||||
var out map[string]any
|
||||
if jsonErr = json.Unmarshal(b, &out); jsonErr != nil {
|
||||
t.Fatalf("unmarshal error: %v", jsonErr)
|
||||
}
|
||||
if out["platformCode"] != "EMPLOYEE_NOT_FOUND" {
|
||||
t.Errorf("json platformCode = %v, want EMPLOYEE_NOT_FOUND", out["platformCode"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omitted when not set", func(t *testing.T) {
|
||||
err := New(ErrInternal, "unexpected error")
|
||||
b, jsonErr := json.Marshal(err)
|
||||
if jsonErr != nil {
|
||||
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||||
}
|
||||
if strings.Contains(string(b), "platformCode") {
|
||||
t.Errorf("json should omit platformCode when empty, got: %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCode_Description(t *testing.T) {
|
||||
tests := []struct {
|
||||
code Code
|
||||
|
||||
Reference in New Issue
Block a user