fix(#79): retry transient SYB read failures
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user