feat: add typed error message templates and stable replay snapshots
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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, ¶meterErr) {
|
||||
attrs = append(attrs, "errorParameter", parameterErr.Error())
|
||||
}
|
||||
if failure.BusinessCode != nil {
|
||||
attrs = append(attrs, "businessCode", failure.BusinessCode)
|
||||
}
|
||||
|
||||
81
middleware/redis/errorParams_test.go
Normal file
81
middleware/redis/errorParams_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user