《后端架构设计·架构基础与边界》5.3 错误模型、版本演进与兼容性

把错误码、HTTP 状态、retryable 与版本窗口固化成可测试的契约,用表驱动测试覆盖领域错误映射、字段兼容和版本协商,说明哪些变更只能新增、哪些必须升版,并解释 Sunset 过期后为何必须拒绝,以及测试如何证明承诺没有被破坏的边界。

错误不是字符串,是契约

调用方不会读 message 来决定重试,它读 code、HTTP 状态和 retryable。因此第一条规则是:code 一旦发布就不能改语义,message 可以改措辞,HTTP 状态尽量不改。 第二条是:错误码只能追加,删除一个枚举值等于给旧客户端制造未知错误。

codeHTTPretryable含义
INVALID_ARGUMENT400否请求形状或字段范围不合法
NOT_FOUND404否订单不存在
INVALID_STATE422否当前状态不允许该命令
OUT_OF_STOCK409否库存不足,需换商品或补货
PAYMENT_FAILED402是支付被拒,换支付方式后可重试
VERSION_CONFLICT409否版本前置条件失败,需重读
IDEMPOTENCY_CONFLICT409否同键异体,需换键或核对请求
INTERNAL500是未分类错误,不暴露内部细节

从领域错误到 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查