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

View File

@@ -32,6 +32,7 @@ type IdempotencyConfig struct {
type replay struct {
fingerprint [32]byte
errorCode string
failure *apierrors.Snapshot
status int
headers http.Header
body []byte
@@ -151,11 +152,13 @@ func (i *Idempotency) Middleware() httpx.Middleware {
return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or its outcome cannot be replayed")
}
status, headers, data := saved.status, saved.headers.Clone(), append([]byte(nil), saved.body...)
errorCode := saved.errorCode
errorCode, failure := saved.errorCode, saved.failure
i.mu.Unlock()
headers.Set("Idempotency-Replayed", "true")
if errorCode != "" {
c.RecordError(apierrors.New(status, errorCode, ""))
if failure != nil {
c.RecordFailure(failure.ErrorValue())
} else if errorCode != "" {
c.RecordFailure(apierrors.New(status, errorCode, ""))
}
return c.WriteResponse(status, headers, data)
}
@@ -176,8 +179,12 @@ func (i *Idempotency) Middleware() httpx.Middleware {
saved.failed = true
saved.expires = i.now().Add(i.config.TTL)
size := buffer.body.Len()
if failure := c.Failure(); failure != nil {
size += len(failure.Code)
failure := c.Failure().Snapshot()
var metadataErr error
if failure != nil {
var metadata []byte
metadata, metadataErr = json.Marshal(failure)
size += len(metadata) + len(failure.Code)
}
for k, values := range buffer.headers {
size += len(k)
@@ -185,11 +192,12 @@ func (i *Idempotency) Middleware() httpx.Middleware {
size += len(v)
}
}
if buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes {
if metadataErr == nil && buffer.complete && buffer.err == nil && buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") && size <= i.config.MaxBytes-i.bytes {
saved.failed = false
saved.status = buffer.status
if failure := c.Failure(); failure != nil {
saved.errorCode = failure.Code
saved.failure = failure.Snapshot()
}
saved.headers = buffer.headers.Clone()
saved.body = append([]byte(nil), buffer.body.Bytes()...)
@@ -214,7 +222,7 @@ func (i *Idempotency) Middleware() httpx.Middleware {
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordError(failure)
c.RecordFailure(failure)
}
buffer.complete = true
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())

View File

@@ -1,6 +1,7 @@
package middleware
import (
"errors"
"fmt"
"runtime/debug"
"time"
@@ -93,6 +94,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
}
if failure != nil {
attrs = append(attrs, "failed", true, "errorKey", failure.Code)
var parameterErr *apierrors.ParamError
if errors.As(failure, &parameterErr) {
attrs = append(attrs, "errorParameter", parameterErr.Error())
}
if failure.BusinessCode != nil {
attrs = append(attrs, "businessCode", failure.BusinessCode)
}

View File

@@ -0,0 +1,81 @@
package redis
import (
"context"
"errors"
"fmt"
"math"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
goredis "github.com/redis/go-redis/v9"
)
func TestTemplateErrorRedisReplay(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("requires isolated Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1})
defer client.Close()
backend, err := NewIdempotencyStore(client, fmt.Sprint("template:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 4})
if err != nil {
t.Fatal(err)
}
defer client.Del(context.Background(), backend.keys...)
var observed []*apierrors.APIError
calls := 0
var bodies []string
for _, template := range []string{"{label}不能超过{max}个字符", "changed: {label}/{max}"} {
cat := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"LIMIT": {Code: int64(math.MaxInt64), Message: template, Params: []apierrors.Parameter{{Name: "label", Type: "string"}, {Name: "max", Type: "int64"}}}}}
app := omni.New(omni.WithErrors(cat), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}))
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "fixture", nil }})
if err != nil {
t.Fatal(err)
}
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { err := next(c); observed = append(observed, c.Failure()); return err }
})
app.POST("/", func(c *httpx.Context) error {
calls++
return apierrors.ByCode("LIMIT").WithParams(map[string]any{"label": "备注", "max": int64(math.MaxInt64)}).WithCause(errors.New("privateCause"))
}, idem.Middleware())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", nil)
r.Header.Set("Idempotency-Key", "same")
app.ServeHTTP(w, r)
app.Close()
if w.Code != 200 || !strings.Contains(w.Body.String(), "备注不能超过9223372036854775807个字符") || !strings.Contains(w.Body.String(), `"code":9223372036854775807`) || !strings.Contains(w.Body.String(), `"data":null`) || strings.Contains(w.Body.String(), "privateCause") {
t.Fatal(w.Code, w.Body.String())
}
bodies = append(bodies, w.Body.String())
}
if calls != 1 || bodies[0] != bodies[1] || len(observed) != 2 {
t.Fatal(calls, bodies, observed)
}
for _, e := range observed {
if e == nil || e.BusinessCode != int64(math.MaxInt64) || e.Message != "备注不能超过9223372036854775807个字符" {
t.Fatal(e)
}
}
if observed[1].Cause != nil {
t.Fatal("persisted internal cause")
}
records, err := client.HVals(context.Background(), backend.keys[0]).Result()
if err != nil {
t.Fatal(err)
}
for _, raw := range records {
if strings.Contains(raw, "privateCause") || strings.Contains(raw, `"params"`) {
t.Fatal("private replay metadata")
}
}
}

View File

@@ -4,20 +4,23 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"net/http"
"strings"
"time"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
type StoredResponse struct {
Status int `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
Status int `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Failure *apierrors.Snapshot `json:"failure,omitempty"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
}
type IdempotencyRecord struct {
Fingerprint string `json:"fingerprint"`
@@ -55,8 +58,10 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
response := record.Response
headers := response.Headers.Clone()
headers.Set("Idempotency-Replayed", "true")
if response.ErrorCode != "" {
c.RecordError(apierrors.New(response.Status, response.ErrorCode, ""))
if response.Failure != nil {
c.RecordFailure(response.Failure.ErrorValue())
} else if response.ErrorCode != "" {
c.RecordFailure(apierrors.New(response.Status, response.ErrorCode, ""))
}
return c.WriteResponse(response.Status, headers, response.Body)
}
@@ -89,13 +94,20 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
buffer.status = 200
}
if failure := child.Failure(); failure != nil {
c.RecordError(failure)
c.RecordFailure(failure)
}
var response *StoredResponse
if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") {
response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)}
if failure := child.Failure(); failure != nil {
response.ErrorCode = failure.Code
response.Failure = failure.Snapshot()
}
}
if response != nil && response.Failure != nil {
metadata, encodeErr := json.Marshal(response.Failure)
if encodeErr != nil || len(metadata) > i.config.MaxResponseBytes {
response = nil
}
}
if err = finish(response); err != nil {