feat: add project-owned error catalogs and HTTP 200 failure contracts
This commit is contained in:
@@ -31,6 +31,7 @@ type IdempotencyConfig struct {
|
||||
|
||||
type replay struct {
|
||||
fingerprint [32]byte
|
||||
errorCode string
|
||||
status int
|
||||
headers http.Header
|
||||
body []byte
|
||||
@@ -150,8 +151,12 @@ 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
|
||||
i.mu.Unlock()
|
||||
headers.Set("Idempotency-Replayed", "true")
|
||||
if errorCode != "" {
|
||||
c.RecordError(apierrors.New(status, errorCode, ""))
|
||||
}
|
||||
return c.WriteResponse(status, headers, data)
|
||||
}
|
||||
if len(i.items) >= i.config.MaxEntries {
|
||||
@@ -171,6 +176,9 @@ 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)
|
||||
}
|
||||
for k, values := range buffer.headers {
|
||||
size += len(k)
|
||||
for _, v := range values {
|
||||
@@ -180,6 +188,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
|
||||
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 {
|
||||
saved.failed = false
|
||||
saved.status = buffer.status
|
||||
if failure := c.Failure(); failure != nil {
|
||||
saved.errorCode = failure.Code
|
||||
}
|
||||
saved.headers = buffer.headers.Clone()
|
||||
saved.body = append([]byte(nil), buffer.body.Bytes()...)
|
||||
saved.size = size
|
||||
@@ -202,6 +213,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
|
||||
if buffer.status == 0 {
|
||||
buffer.status = 200
|
||||
}
|
||||
if failure := child.Failure(); failure != nil {
|
||||
c.RecordError(failure)
|
||||
}
|
||||
buffer.complete = true
|
||||
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
|
||||
}
|
||||
|
||||
@@ -73,10 +73,10 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
|
||||
return func(ctx *httpx.Context) error {
|
||||
started := time.Now()
|
||||
err := next(ctx)
|
||||
status := ctx.Status()
|
||||
if err != nil && !ctx.Responded() {
|
||||
status = apierrors.From(err).Status
|
||||
ctx.WriteError(err)
|
||||
}
|
||||
status := ctx.Status()
|
||||
duration := time.Since(started)
|
||||
slow := settings.slowThreshold > 0 && duration >= settings.slowThreshold
|
||||
attrs := []any{
|
||||
@@ -87,6 +87,16 @@ func AccessLog(options ...AccessLogOption) httpx.Middleware {
|
||||
"durationMs", duration.Seconds() * 1000,
|
||||
"clientIp", ctx.ClientIP(),
|
||||
}
|
||||
failure := ctx.Failure()
|
||||
if failure == nil && err != nil {
|
||||
failure = ctx.ResolveError(err)
|
||||
}
|
||||
if failure != nil {
|
||||
attrs = append(attrs, "failed", true, "errorKey", failure.Code)
|
||||
if failure.BusinessCode != nil {
|
||||
attrs = append(attrs, "businessCode", failure.BusinessCode)
|
||||
}
|
||||
}
|
||||
if slow {
|
||||
attrs = append(attrs, "slow", true)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
type StoredResponse struct {
|
||||
Status int `json:"status"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
Status int `json:"status"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
Headers http.Header `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
type IdempotencyRecord struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
@@ -54,6 +55,9 @@ 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, ""))
|
||||
}
|
||||
return c.WriteResponse(response.Status, headers, response.Body)
|
||||
}
|
||||
completed := false
|
||||
@@ -84,9 +88,15 @@ func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerpri
|
||||
if buffer.status == 0 {
|
||||
buffer.status = 200
|
||||
}
|
||||
if failure := child.Failure(); failure != nil {
|
||||
c.RecordError(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
|
||||
}
|
||||
}
|
||||
if err = finish(response); err != nil {
|
||||
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err)
|
||||
|
||||
Reference in New Issue
Block a user