feat: add typed error message templates and stable replay snapshots

This commit is contained in:
2026-09-24 15:05:08 +08:00
parent 2041e7f661
commit 6e3f025aad
32 changed files with 1570 additions and 99 deletions

160
errors/params_test.go Normal file
View File

@@ -0,0 +1,160 @@
package apierrors_test
import (
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"sync"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
func templateCatalog() apierrors.Catalog {
return apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"TEXT_TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}},
"FIXED": {Code: 400003, Message: "literal {unchanged}"},
}}
}
func TestMessageParamsIsolationAndResolution(t *testing.T) {
catalog := templateCatalog()
r := apierrors.MustRegistry(catalog)
catalog.Definitions["TEXT_TOO_LONG"].Params[0].Type = "bool"
r.Catalog().Definitions["TEXT_TOO_LONG"].Params[0].Name = "changed"
private := errors.New("private cause")
values := map[string]any{"label": "企业名称", "max": 100}
reference := apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(private)
values["label"] = "changed"
first := r.Resolve(fmt.Errorf("wrapped: %w", reference))
for n := 0; n < 3; n++ {
first = r.Resolve(first)
if first.Message != "企业名称不能超过100个字符" || first.Status != 200 || first.BusinessCode != int64(400002) || !errors.Is(first, private) {
t.Fatal(first)
}
}
second := r.Resolve(first.WithParams(map[string]any{"label": "备注", "max": 500}))
if first.Message != "企业名称不能超过100个字符" || second.Message != "备注不能超过500个字符" {
t.Fatal(first, second)
}
if r.Example("TEXT_TOO_LONG").Message != first.Message {
t.Fatal("example and runtime differ")
}
if r.Resolve(apierrors.ByCode("FIXED")).Message != "literal {unchanged}" {
t.Fatal("legacy braces changed")
}
var wg sync.WaitGroup
for n := 0; n < 80; n++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
label, max := "企业名称", 100
if n%2 == 1 {
label, max = "备注", 500
}
got := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(map[string]any{"label": label, "max": max}).WithCause(private))
if got.Message != fmt.Sprintf("%s不能超过%d个字符", label, max) || !errors.Is(got, private) {
t.Error(got)
}
}(n)
}
wg.Wait()
}
func TestInvalidTemplateParamsFailClosed(t *testing.T) {
r := apierrors.MustRegistry(templateCatalog())
cause := errors.New("private")
for _, values := range []map[string]any{
nil, {"label": "x"}, {"label": "x", "max": "100"}, {"label": 100, "max": 100},
{"label": "x", "max": 100.0}, {"label": "x", "max": 100, "extra": "private-value"},
{"label": "x", "max": nil}, {"label": map[string]any{"secret": "private-value"}, "max": 100},
} {
result := r.Resolve(apierrors.ByCode("TEXT_TOO_LONG").WithParams(values).WithCause(cause))
var diagnostic *apierrors.ParamError
if result.Code != "INTERNAL_ERROR" || result.Status != 500 || strings.Contains(result.Message, "{") || !errors.As(result, &diagnostic) || !errors.Is(result, cause) {
t.Fatal(result)
}
if strings.Contains(diagnostic.Error(), "private-value") {
t.Fatal("diagnostic leaked value")
}
}
// A parameterized fallback cannot recurse or expose its template.
c := templateCatalog()
c.Definitions["INTERNAL_ERROR"] = c.Definitions["TEXT_TOO_LONG"]
if result := apierrors.MustRegistry(c).Resolve(errors.New("private")); result.Message != "internal server error" || result.Status != 500 {
t.Fatal(result)
}
for _, change := range []func(*apierrors.Definition){
func(d *apierrors.Definition) { d.Message = "{label}{missing}" },
func(d *apierrors.Definition) { d.Message = "{label}" },
func(d *apierrors.Definition) { d.Message = "{label}{max" },
func(d *apierrors.Definition) { d.Message = "{label}{max}}" },
func(d *apierrors.Definition) { d.Params[1].Name = "label" },
func(d *apierrors.Definition) { d.Params[1].Name = "type" },
func(d *apierrors.Definition) { d.Params[1].Type = "interface{}" },
func(d *apierrors.Definition) { d.Params[1].Example = "100" },
func(d *apierrors.Definition) { d.Params = []apierrors.Parameter{} },
} {
c := templateCatalog()
d := c.Definitions["TEXT_TOO_LONG"]
change(&d)
c.Definitions["TEXT_TOO_LONG"] = d
if _, err := apierrors.NewRegistry(c); err == nil {
t.Fatal("accepted invalid template", d)
}
}
}
func TestParamTypesEscapingAndSnapshot(t *testing.T) {
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"T": {Code: "t", Message: "{{literal}} {value} {value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}}}
r := apierrors.MustRegistry(c)
got := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": "{other}"}))
if got.Message != "{literal} {other} {other}" {
t.Fatal("recursive substitution", got)
}
for _, test := range []struct {
kind string
good, bad any
want string
}{
{"int32", int64(math.MaxInt32), int64(math.MaxInt32) + 1, "2147483647"},
{"int64", json.Number("9223372036854775807"), json.Number("9223372036854775808"), "9223372036854775807"},
{"bool", true, "true", "true"}, {"float64", float64(1.25), math.Inf(1), "1.25"},
} {
d := c.Definitions["T"]
d.Params = []apierrors.Parameter{{Name: "value", Type: test.kind}}
c.Definitions["T"] = d
r = apierrors.MustRegistry(c)
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.good})); x.Message != "{literal} "+test.want+" "+test.want {
t.Fatal(test.kind, x)
}
if x := r.Resolve(apierrors.ByCode("T").WithParams(map[string]any{"value": test.bad})); x.Status != 500 {
t.Fatal(test.kind, x)
}
}
original := apierrors.New(200, "T", "complete").WithCause(errors.New("private"))
original.BusinessCode = int64(math.MaxInt64)
raw, err := json.Marshal(original.Snapshot())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "params") {
t.Fatal(string(raw))
}
// Simulate a store rewriting its envelope with ordinary JSON numbers.
var storage map[string]any
if err = json.Unmarshal(raw, &storage); err != nil {
t.Fatal(err)
}
raw, err = json.Marshal(storage)
if err != nil {
t.Fatal(err)
}
var snapshot apierrors.Snapshot
if err = json.Unmarshal(raw, &snapshot); err != nil {
t.Fatal(err)
}
restored := snapshot.ErrorValue()
if restored.Cause != nil || restored.Message != "complete" || restored.BusinessCode != int64(math.MaxInt64) {
t.Fatal(restored)
}
}