fix(#79): retry transient SYB read failures

This commit is contained in:
QiuSW
2026-08-24 17:31:34 +08:00
parent 5c9b75fc53
commit af3dd5563b
4 changed files with 208 additions and 13 deletions
+50 -9
View File
@@ -60,8 +60,14 @@ type Client struct {
baseURL string
http *http.Client
jar *cookiejar.Jar
// readRetryDelays only applies to the explicitly listed, semantically
// read-only stock endpoints. It is a field so tests can remove real waits
// without changing the production retry boundary.
readRetryDelays []time.Duration
}
var defaultReadRetryDelays = []time.Duration{time.Second, 2 * time.Second}
// New 创建一个新的顺运宝客户端,带一个空的 Cookie Jar。
func New(baseURL string) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
@@ -73,13 +79,14 @@ func New(baseURL string) (*Client, error) {
return nil, fmt.Errorf("创建顺运宝客户端的 Cookie Jar 失败: %w", err)
}
return &Client{
baseURL: baseURL,
jar: jar,
baseURL: baseURL,
jar: jar,
readRetryDelays: append([]time.Duration(nil), defaultReadRetryDelays...),
http: &http.Client{
Jar: jar,
// 5 秒连接 + 30 秒读取是示例脚本用的值(见 上游示例脚本),
// 这里简化成一个总超时,量级一致。
Timeout: 30 * time.Second,
// SYB 偶尔会在繁忙分页中超过 30 秒才返回响应头。单次请求允许
// 60 秒,外层同步仍有 30 分钟总时限。
Timeout: 60 * time.Second,
},
}, nil
}
@@ -223,6 +230,40 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values,
return env.Data, nil
}
// doReadWithRetry is deliberately not a generic POST retry. The caller may
// only use it for SYB endpoints whose POST requests are queries and have no
// write semantics. Login, captcha and inner-code writes continue to call do
// exactly once.
func (c *Client) doReadWithRetry(ctx context.Context, path string, query url.Values, body any) (json.RawMessage, error) {
for attempt := 0; ; attempt++ {
data, err := c.do(ctx, http.MethodPost, path, query, body)
if err == nil {
return data, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
var transient requestOutcomeUnknownError
if !errors.As(err, &transient) || attempt >= len(c.readRetryDelays) {
return nil, err
}
delay := c.readRetryDelays[attempt]
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return nil, ctx.Err()
case <-timer.C:
}
}
}
// isSessionInvalidMessage 判断业务失败信息是不是"明确未登录",
// 规则见 12 §3.5:msg 含"未登录"/"登录过期",或 code 是 -2
// (数字或字符串两种写法都算,服务端返回哪种没有实测确认过)。
@@ -512,7 +553,7 @@ func orderNumberListPayload(orderNumber string, start, pageIndex, pageSize int)
// ListTotal 查某个日期范围内的货运单总数:POST /am/stock/listTotal。
// `[必须]` data 是裸整数,不是对象,见 12 §2。
func (c *Client) ListTotal(ctx context.Context, dateFrom, dateTo string, pageSize int) (int, error) {
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
data, err := c.doReadWithRetry(ctx, "/am/stock/listTotal", nil,
listPayload(dateFrom, dateTo, 0, 1, pageSize))
if err != nil {
return 0, err
@@ -546,7 +587,7 @@ func (c *Client) ListPage(ctx context.Context, dateFrom, dateTo string, start, p
}
func (c *Client) listPage(ctx context.Context, payload map[string]any) ([]StockRow, int, error) {
data, err := c.do(ctx, http.MethodPost, "/am/stock/list", nil,
data, err := c.doReadWithRetry(ctx, "/am/stock/list", nil,
payload)
if err != nil {
return nil, 0, err
@@ -601,7 +642,7 @@ func (c *Client) ListByOrderNumber(ctx context.Context, orderNumber string) ([]S
}
}
data, err := c.do(ctx, http.MethodPost, "/am/stock/listTotal", nil,
data, err := c.doReadWithRetry(ctx, "/am/stock/listTotal", nil,
orderNumberListPayload(orderNumber, 0, 1, orderNumberPageSize))
if err != nil {
return nil, err
@@ -688,7 +729,7 @@ func (c *Client) DetailListByStock(ctx context.Context, ids []int64) ([]StockDet
return nil, fmt.Errorf("单批查询明细最多 100 个 id,实际传了 %d 个,请分批调用", len(ids))
}
data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/listByStock",
data, err := c.doReadWithRetry(ctx, "/am/stock/detail/listByStock",
url.Values{"hist": {"0"}}, map[string]any{"ids": ids})
if err != nil {
return nil, err
+145
View File
@@ -335,6 +335,151 @@ func TestClient_ListTotal和ListPage(t *testing.T) {
}
}
func TestClient_只读货运接口遇到暂时故障后重试恢复(t *testing.T) {
for _, test := range []struct {
name string
path string
invoke func(*Client) error
data any
}{
{
name: "总数", path: "/am/stock/listTotal", data: 1,
invoke: func(client *Client) error {
_, err := client.ListTotal(context.Background(), "2026-08-24", "2026-08-24", 20)
return err
},
},
{
name: "列表", path: "/am/stock/list", data: map[string]any{"total": 1, "list": []map[string]any{{"id": 1, "code": "ORDER"}}},
invoke: func(client *Client) error {
_, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 0, 1, 20)
return err
},
},
{
name: "明细", path: "/am/stock/detail/listByStock", data: map[string]any{"list": []map[string]any{{"id": 1, "code": "ORDER", "details": []any{}}}},
invoke: func(client *Client) error {
_, err := client.DetailListByStock(context.Background(), []int64{1})
return err
},
},
} {
t.Run(test.name, func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != test.path {
t.Fatalf("path=%s want=%s", r.URL.Path, test.path)
}
if requests.Add(1) < 3 {
w.WriteHeader(http.StatusBadGateway)
return
}
w.Write(envelopeBody(t, true, "ok", test.data, nil))
}))
defer server.Close()
client, _ := New(server.URL)
client.readRetryDelays = []time.Duration{0, 0}
if err := test.invoke(client); err != nil {
t.Fatalf("第三次请求应恢复成功: %v", err)
}
if requests.Load() != 3 {
t.Fatalf("requests=%d want=3", requests.Load())
}
})
}
}
func TestClient_只读货运接口达到重试上限后返回最后错误(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
client, _ := New(server.URL)
client.readRetryDelays = []time.Duration{0, 0}
_, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 440, 23, 20)
if err == nil || !strings.Contains(err.Error(), "返回 503") {
t.Fatalf("应返回最后一次 503 错误,实际: %v", err)
}
if requests.Load() != 3 {
t.Fatalf("requests=%d want=3", requests.Load())
}
}
func TestClient_只读货运列表响应头超时后可以恢复(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempt := requests.Add(1)
if attempt < 3 {
time.Sleep(30 * time.Millisecond)
return
}
w.Write(envelopeBody(t, true, "ok", map[string]any{
"total": 1, "list": []map[string]any{{"id": 1, "code": "ORDER"}},
}, nil))
}))
defer server.Close()
client, _ := New(server.URL)
client.http.Timeout = 10 * time.Millisecond
client.readRetryDelays = []time.Duration{0, 0}
rows, _, err := client.ListPage(context.Background(), "2026-08-24", "2026-08-24", 0, 1, 20)
if err != nil || len(rows) != 1 {
t.Fatalf("第三次请求应在响应头超时后恢复,rows=%v err=%v", rows, err)
}
if requests.Load() != 3 {
t.Fatalf("requests=%d want=3", requests.Load())
}
}
func TestClient_只读货运接口明确失败不重试(t *testing.T) {
for _, test := range []struct {
name string
code int
body []byte
}{
{"会话失效", http.StatusUnauthorized, nil},
{"业务失败", http.StatusOK, envelopeBody(t, false, "查询条件错误", nil, 1)},
} {
t.Run(test.name, func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(test.code)
w.Write(test.body)
}))
defer server.Close()
client, _ := New(server.URL)
client.readRetryDelays = []time.Duration{0, 0}
_, err := client.ListTotal(context.Background(), "2026-08-24", "2026-08-24", 20)
if err == nil || requests.Load() != 1 {
t.Fatalf("err=%v requests=%d", err, requests.Load())
}
})
}
}
func TestClient_只读货运接口等待重试时响应Context取消(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
cancel()
w.WriteHeader(http.StatusBadGateway)
}))
defer server.Close()
client, _ := New(server.URL)
client.readRetryDelays = []time.Duration{time.Hour, time.Hour}
started := time.Now()
_, err := client.ListTotal(ctx, "2026-08-24", "2026-08-24", 20)
if !errors.Is(err, context.Canceled) || time.Since(started) > time.Second {
t.Fatalf("取消后应立即停止,err=%v elapsed=%v", err, time.Since(started))
}
if requests.Load() != 1 {
t.Fatalf("取消后不得继续请求,requests=%d", requests.Load())
}
}
func TestClient_ListByOrderNumber_使用Allcode精确查询(t *testing.T) {
const orderNumber = "260812711E49CU"
var paths []string