错误不是字符串,是契约
调用方不会读 message 来决定重试,它读 code、HTTP 状态和 retryable。因此第一条规则是:code 一旦发布就不能改语义,message 可以改措辞,HTTP 状态尽量不改。 第二条是:错误码只能追加,删除一个枚举值等于给旧客户端制造未知错误。
| code | HTTP | retryable | 含义 |
|---|---|---|---|
INVALID_ARGUMENT | 400 | 否 | 请求形状或字段范围不合法 |
NOT_FOUND | 404 | 否 | 订单不存在 |
INVALID_STATE | 422 | 否 | 当前状态不允许该命令 |
OUT_OF_STOCK | 409 | 否 | 库存不足,需换商品或补货 |
PAYMENT_FAILED | 402 | 是 | 支付被拒,换支付方式后可重试 |
VERSION_CONFLICT | 409 | 否 | 版本前置条件失败,需重读 |
IDEMPOTENCY_CONFLICT | 409 | 否 | 同键异体,需换键或核对请求 |
INTERNAL | 500 | 是 | 未分类错误,不暴露内部细节 |
从领域错误到 HTTP,只在一个地方映射
领域层用哨兵错误表达分类,处理器用 errors.Is 判断,包装链不会丢失分类。未识别的错误统一映射成 INTERNAL,原始文本只进日志,不进响应;details 只放可公开的键值,例如缺少的 SKU,不放 SQL 或堆栈。
// file: internal/platform/httpserver/errors.go
// tier: L1
// verify: go test ./internal/platform/httpserver/
package httpserver
import (
"encoding/json"
"errors"
"net/http"
"sort"
)
type Code string
const (
CodeInvalidArgument Code = "INVALID_ARGUMENT"
CodeNotFound Code = "NOT_FOUND"
CodeInvalidState Code = "INVALID_STATE"
CodeOutOfStock Code = "OUT_OF_STOCK"
CodePaymentFailed Code = "PAYMENT_FAILED"
CodeVersionConflict Code = "VERSION_CONFLICT"
CodeIdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
CodeInternal Code = "INTERNAL"
)
var (
ErrNotFound = errors.New("not found")
ErrInvalidState = errors.New("invalid state")
ErrOutOfStock = errors.New("out of stock")
ErrPaymentFailed = errors.New("payment failed")
ErrVersionConflict = errors.New("version conflict")
ErrIdempotencyConflict = errors.New("idempotency conflict")
)
type meta struct {
status int
retryable bool
message string
}
var registry = map[Code]meta{
CodeInvalidArgument: {http.StatusBadRequest, false, "request is malformed or fails validation"},
CodeNotFound: {http.StatusNotFound, false, "the requested resource does not exist"},
CodeInvalidState: {http.StatusUnprocessableEntity, false, "command is not allowed in the current state"},
CodeOutOfStock: {http.StatusConflict, false, "one or more items are out of stock"},
CodePaymentFailed: {http.StatusPaymentRequired, true, "payment was declined; retry may succeed"},
CodeVersionConflict: {http.StatusConflict, false, "the resource version changed"},
CodeIdempotencyConflict: {http.StatusConflict, false, "the idempotency key was used with a different request"},
CodeInternal: {http.StatusInternalServerError, true, "internal error"},
}
func Status(code Code) int { return registry[code].status }
func Retryable(code Code) bool { return registry[code].retryable }
func AllCodes() []Code {
codes := make([]Code, 0, len(registry))
for code := range registry {
codes = append(codes, code)
}
sort.Slice(codes, func(i, j int) bool { return codes[i] < codes[j] })
return codes
}
type Error struct {
Code Code `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Retryable bool `json:"retryable"`
Details map[string]string `json:"details,omitempty"`
}
func NewError(code Code, requestID string, details map[string]string) Error {
m, ok := registry[code]
if !ok {
code, m = CodeInternal, registry[CodeInternal]
}
return Error{Code: code, Message: m.message, RequestID: requestID, Retryable: m.retryable, Details: details}
}
func (e Error) Error() string { return string(e.Code) + ": " + e.Message }
// From 把领域错误映射成稳定契约;未分类错误统一成 INTERNAL,绝不透出原始文本。
func From(err error, requestID string) Error {
var apiErr Error
if errors.As(err, &apiErr) {
apiErr.RequestID = requestID
return apiErr
}
code := CodeInternal
switch {
case errors.Is(err, ErrNotFound):
code = CodeNotFound
case errors.Is(err, ErrInvalidState):
code = CodeInvalidState
case errors.Is(err, ErrOutOfStock):
code = CodeOutOfStock
case errors.Is(err, ErrPaymentFailed):
code = CodePaymentFailed
case errors.Is(err, ErrVersionConflict):
code = CodeVersionConflict
case errors.Is(err, ErrIdempotencyConflict):
code = CodeIdempotencyConflict
}
return NewError(code, requestID, nil)
}
func WriteError(w http.ResponseWriter, r *http.Request, err error) {
apiErr := From(err, r.Header.Get("X-Request-Id"))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(Status(apiErr.Code))
_ = json.NewEncoder(w).Encode(apiErr)
}
From 接受 Error 值透传,应用服务可携带 details,处理器统一补 request_id;WriteError 设置 Cache-Control: no-store,避免错误响应被缓存。
版本窗口:日期版本加 Sunset
版本用日期而不是 v1/v2:X-API-Version: 2026-10-01,能表达“这一版从哪天生效”,也方便按时间淘汰。策略是同时支持当前版与上一版,上一版有明确 Sunset;窗口结束后旧版本收到 400 和受支持列表,而不是悄悄按新语义处理。
| 版本 | 状态 | Sunset |
|---|---|---|
2026-10-01 | 当前 | 无 |
2026-04-01 | 维护 | 2026-12-31 |
窗口内只做加法:新增可选字段、错误码、端点;删除字段、改含义、可选改必填都必须开新日期版本。arch-lab 取 90 天窗口,这不是行业标准,是可审计的承诺。
// file: internal/platform/httpserver/version.go
// tier: L1
// verify: go test ./internal/platform/httpserver/
package httpserver
import (
"fmt"
"net/http"
"time"
)
type Version string
const (
Version2026_04 Version = "2026-04-01"
Version2026_10 Version = "2026-10-01"
)
var (
versions = []Version{Version2026_04, Version2026_10}
sunsets = map[Version]time.Time{
Version2026_04: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
}
)
type VersionError struct {
Raw string
Supported []Version
}
func (e *VersionError) Error() string {
return fmt.Sprintf("unsupported api version %q; supported: %v", e.Raw, e.Supported)
}
// Negotiate 选择请求版本;空值落到当前版本,过期版本在窗口结束后拒绝。
func Negotiate(raw string, now time.Time) (Version, error) {
if raw == "" {
return Version2026_10, nil
}
for _, v := range versions {
if Version(raw) != v {
continue
}
if sunset, ok := sunsets[v]; ok && now.After(sunset) {
return "", &VersionError{Raw: raw, Supported: versions}
}
return v, nil
}
return "", &VersionError{Raw: raw, Supported: versions}
}
func SetVersionHeaders(h http.Header, v Version) {
h.Set("X-API-Version", string(v))
if sunset, ok := sunsets[v]; ok {
h.Set("Deprecation", "true")
h.Set("Sunset", sunset.Format(http.TimeFormat))
}
}
Negotiate 接受显式 now,测试不依赖系统时钟;空版本落到当前版,是给内部调用的兼容路径,公开客户端必须显式声明。
用表驱动测试固定三类兼容性
测试分四张表:错误码与状态/重试性、领域错误映射、旧/新字段载荷、版本窗口;字段兼容的标准是“旧读者能否读新载荷、新读者能否读旧载荷”。
// file: internal/platform/httpserver/errors_test.go
// tier: L1
// verify: go test ./internal/platform/httpserver/ -v
package httpserver
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestErrorContractIsStable(t *testing.T) {
cases := []struct {
code Code
status int
retryable bool
}{
{CodeInvalidArgument, 400, false}, {CodeNotFound, 404, false},
{CodeInvalidState, 422, false}, {CodeOutOfStock, 409, false},
{CodePaymentFailed, 402, true}, {CodeVersionConflict, 409, false},
{CodeIdempotencyConflict, 409, false}, {CodeInternal, 500, true},
}
for _, tc := range cases {
if Status(tc.code) != tc.status || Retryable(tc.code) != tc.retryable {
t.Errorf("%s: status=%d retryable=%v", tc.code, Status(tc.code), Retryable(tc.code))
}
raw, _ := json.Marshal(NewError(tc.code, "req-1", nil))
for _, key := range []string{`"code"`, `"message"`, `"request_id"`, `"retryable"`} {
if !strings.Contains(string(raw), key) {
t.Errorf("%s: missing %s", tc.code, key)
}
}
if strings.Contains(string(raw), `"details"`) {
t.Errorf("%s: empty details must be omitted", tc.code)
}
}
want := "IDEMPOTENCY_CONFLICT,INTERNAL,INVALID_ARGUMENT,INVALID_STATE,NOT_FOUND,OUT_OF_STOCK,PAYMENT_FAILED,VERSION_CONFLICT"
var codes []string
for _, code := range AllCodes() {
codes = append(codes, string(code))
}
if strings.Join(codes, ",") != want {
t.Fatalf("codes = %v", codes)
}
}
func TestErrorMapping(t *testing.T) {
cases := []struct {
err error
code Code
}{
{ErrNotFound, CodeNotFound}, {fmt.Errorf("repo: %w", ErrVersionConflict), CodeVersionConflict},
{ErrOutOfStock, CodeOutOfStock}, {ErrPaymentFailed, CodePaymentFailed},
{ErrInvalidState, CodeInvalidState}, {ErrIdempotencyConflict, CodeIdempotencyConflict},
{errors.New("pq: password authentication failed"), CodeInternal},
}
for _, tc := range cases {
got := From(tc.err, "req-9")
if got.Code != tc.code || got.RequestID != "req-9" || strings.Contains(got.Message, "password") {
t.Errorf("From(%v) = %+v", tc.err, got)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders/o-1", nil)
req.Header.Set("X-Request-Id", "req-9")
rec := httptest.NewRecorder()
WriteError(rec, req, ErrNotFound)
if rec.Code != 404 || rec.Header().Get("Cache-Control") != "no-store" ||
!strings.Contains(rec.Body.String(), `"request_id":"req-9"`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body)
}
}
type orderV1 struct {
ID string `json:"id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
}
type orderV11 struct {
ID string `json:"id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
Currency string `json:"currency,omitempty"`
Delivery *string `json:"estimated_delivery,omitempty"`
}
func TestFieldCompatibility(t *testing.T) {
oldPayload := `{"id":"o-1","status":"PAID","total_cents":1000}`
newPayload := `{"id":"o-1","status":"PAID","total_cents":1000,"currency":"CNY","estimated_delivery":"2026-11-09","loyalty_points":12}`
var fromOld orderV11
if err := json.Unmarshal([]byte(oldPayload), &fromOld); err != nil {
t.Fatal(err)
}
if fromOld.ID != "o-1" || fromOld.Currency != "" || fromOld.Delivery != nil {
t.Fatalf("old = %+v", fromOld)
}
var fromNew orderV11
if err := json.Unmarshal([]byte(newPayload), &fromNew); err != nil {
t.Fatal(err)
}
if fromNew.Currency != "CNY" || fromNew.Delivery == nil {
t.Fatalf("new = %+v", fromNew)
}
var older orderV1
if err := json.Unmarshal([]byte(newPayload), &older); err != nil || older.TotalCents != 1000 {
t.Fatalf("older = %+v err=%v", older, err)
}
}
func TestVersionNegotiation(t *testing.T) {
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
cases := []struct {
raw string
now time.Time
want Version
err bool
}{
{"", now, Version2026_10, false}, {"2026-10-01", now, Version2026_10, false},
{"2026-04-01", now, Version2026_04, false}, {"2025-01-01", now, "", true},
{"2026-04-01", time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), "", true},
}
for _, tc := range cases {
got, err := Negotiate(tc.raw, tc.now)
if (err != nil) != tc.err || got != tc.want {
t.Errorf("Negotiate(%q) = %q, %v", tc.raw, got, err)
}
}
headers := http.Header{}
SetVersionHeaders(headers, Version2026_04)
if headers.Get("Sunset") == "" || headers.Get("Deprecation") != "true" {
t.Fatalf("headers = %v", headers)
}
}
orderV11 新增的 currency 与 estimated_delivery 都是可选字段,旧载荷能读成零值;loyalty_points 是未知字段,标准库默认忽略。若把 total_cents 改名,orderV1 会读成 0,测试立刻失败,这就是必须升版的信号。
实测
本机 macOS(darwin/arm64,Go 1.26.0)独立模块的真实输出:
$ gofmt -l .
$ go vet ./...
$ go test ./... -count=1
ok archlab/internal/platform/httpserver 0.288s
gofmt -l . 与 go vet 均无输出,四个测试全部通过;0.288s 只反映本机这次运行。
常见错误与边界
第一,用 message 做机器判断,或把所有 4xx 都标成不可重试;前者文案一改就断,后者让支付失败永远无法补偿。机器分支只认 code 与 retryable,重试性必须逐码定义。
第三,在旧版本里删字段。字段没了,旧客户端读到零值,可能把“未知”当成“免费”或“已支付”;删除只能发生在版本窗口结束之后。
小结
错误模型与版本策略的共同点是“先承诺,再实现”:code、状态、retryable 和 Sunset 都是对外承诺,测试负责让承诺可验证。下一章离开接口层,讨论订单数据的所有权与存储选型。
阅读导航:上一节:5.2 契约优先与 OpenAPI 工作流 · 下一节:6.1 订单数据该放什么存储:所有权与存储选型 ADR 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。