feat(client): GET 请求瞬态故障自动重试(指数退避),非幂等方法不重试 #339

Open
Taoyouce wants to merge 4 commits from Taoyouce/gitlink-cli:feat/get-retry-backoff into master
5 changed files with 343 additions and 8 deletions

View File

@ -98,7 +98,7 @@ func runAPI(c *cobra.Command, args []string) error {
if err != nil {
var apiErr *client.APIError
if errors.As(err, &apiErr) {
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "")
errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion)
return output.Print(errEnv, resolveFormat())
}
return err

View File

@ -0,0 +1,45 @@
# 网络健壮性与错误语义一致性
## 背景
对标成熟 CLIgh 等)的生产标准,本次补齐三个缺口:
1. 任何网络抖动(连接失败、网关 502/503/504、限流 429都直接失败
AI Agent / CI 门禁场景下一次瞬态故障即中断整条工作流。
2. HTTP 层错误4xx/5xx不携带修复建议而 body 错误有——同一错误
两种呈现Agent 无法统一消费。
3. 生产实测发现:请求不存在的 API 路径时,网关回落到 Web 前端返回
200 + HTML 首页CLI 误判为成功并把整页 HTML 当数据输出 `ok:true`
4. 生产实测发现:返回顶层 JSON 数组的遗留端点(如
`/:owner/:repo/branches`)被降级为转义字符串输出,`--jq`/表格渲染
等下游能力全部失效。
## 变更内容
- `internal/client`GET幂等请求遇瞬态故障自动重试最多 2 次,
指数退避300ms → 600ms网络层错误、HTTP 429/502/503/504。
服务端返回 `Retry-After` 头(秒)时优先遵循,并以 5s 上限护栏保持 CLI 响应性。
非幂等方法POST/PUT/DELETE/PATCH一律不重试避免重复副作用
其他状态码不重试。`--debug` 下打印每次重试原因与退避时长。
- `APIError` 新增 `Suggestion` 字段HTTP 层错误与 body 错误统一
携带 `suggestFix` 修复建议,`api` 命令错误信封透传该建议。
- 非 JSON 响应若为 HTML 页Content-Type 或 doctype 探测),返回
`non_api_response` 错误信封(`ok:false` + 建议检查路径),
不再把 HTML 首页当成功数据。
- 顶层 JSON 数组/标量响应按解码后的结构原样进入数据信封,不再降级为
转义字符串。
## 生产验证
- `api GET /nonexistent-endpoint-xyz``ok:false, code:non_api_response`
(修复前:`ok:true` + 整页 HTML
- `api GET /gitlink/gitlink-cli/branches``data` 为结构化数组
(修复前:整个数组被输出为一条转义字符串)。
- `repo +info` 正常端点行为不变。
## 测试
`internal/client/retry_test.go` 6 个用例503 重试后成功、超上限停止、
POST 不重试、404 不重试、连接拒绝重试后报错、HTML 回落判错。
`client_test.go` 新增顶层数组解码用例。
`go test ./...` / `go vet` / `gofmt` 全绿。

View File

@ -7,7 +7,9 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/config"
@ -20,10 +22,55 @@ type Client struct {
Debug bool
}
// maxGetRetries is the number of extra attempts made for idempotent GET
// requests that fail with a transient network error or a retryable
// gateway status (429/502/503/504).
const maxGetRetries = 2
// retryBaseDelay is the initial backoff delay, doubled on each retry.
var retryBaseDelay = 300 * time.Millisecond
// maxRetryAfter caps how long a server-provided Retry-After header can
// extend the backoff, keeping the CLI responsive.
const maxRetryAfter = 5 * time.Second
// retryDelay returns the exponential backoff for the given attempt, honoring
// a Retry-After header (in seconds) when the server provides one.
func retryDelay(attempt int, resp *http.Response) time.Duration {
delay := retryBaseDelay << attempt
if resp == nil {
return delay
}
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(strings.TrimSpace(ra)); err == nil && secs > 0 {
d := time.Duration(secs) * time.Second
if d > maxRetryAfter {
d = maxRetryAfter
}
if d > delay {
delay = d
}
}
}
return delay
}
func retryableStatus(code int) bool {
switch code {
case http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
}
return false
}
type APIError struct {
StatusCode int
Code interface{}
Message string
Suggestion string
}
func (e *APIError) Error() string {
@ -83,15 +130,40 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
fmt.Printf("→ %s %s\n", method, fullURL)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
var resp *http.Response
var respData []byte
for attempt := 0; ; attempt++ {
resp, err = c.HTTP.Do(req)
if err == nil {
respData, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
err = fmt.Errorf("failed to read response: %w", err)
}
} else {
err = fmt.Errorf("request failed: %w", err)
}
respData, err := io.ReadAll(resp.Body)
transient := err != nil || retryableStatus(resp.StatusCode)
if method != http.MethodGet || !transient || attempt >= maxGetRetries {
break
}
var respForDelay *http.Response
if err == nil {
respForDelay = resp
}
delay := retryDelay(attempt, respForDelay)
if c.Debug {
if err != nil {
fmt.Printf("↻ retry %d/%d in %v after error: %v\n", attempt+1, maxGetRetries, delay, err)
} else {
fmt.Printf("↻ retry %d/%d in %v after HTTP %d\n", attempt+1, maxGetRetries, delay, resp.StatusCode)
}
}
time.Sleep(delay)
}
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
return nil, err
}
if c.Debug {
@ -104,12 +176,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Suggestion: suggestFix(resp.StatusCode),
}
}
// Parse JSON
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// Some legacy endpoints (e.g. /:owner/:repo/branches) answer with a
// top-level JSON array or scalar; keep the decoded value instead of
// degrading it to an escaped string.
var nonObject interface{}
if jsonErr := json.Unmarshal(respData, &nonObject); jsonErr == nil {
return output.SuccessEnvelope(nonObject, nil), nil
}
// Unknown API paths fall through to the web frontend, which answers
// 200 with an HTML page; surface that as an error instead of data.
if isHTMLResponse(resp, respData) {
suggestion := "接口路径不存在或不是 API 端点,请检查路径是否正确"
return output.ErrorEnvelope("non_api_response", "endpoint returned an HTML page instead of API data", suggestion), &APIError{
StatusCode: resp.StatusCode,
Code: "non_api_response",
Message: "endpoint returned an HTML page instead of API data",
Suggestion: suggestion,
}
}
// Not JSON, return as-is
return output.SuccessEnvelope(string(respData), nil), nil
}
@ -144,6 +235,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: int(bodyCode),
Code: int(bodyCode),
Message: bodyMsg,
Suggestion: suggestion,
}
}
@ -173,6 +265,14 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return output.SuccessEnvelope(raw, meta), nil
}
func isHTMLResponse(resp *http.Response, body []byte) bool {
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
return true
}
trimmed := strings.TrimSpace(string(body))
return strings.HasPrefix(trimmed, "<!doctype") || strings.HasPrefix(trimmed, "<!DOCTYPE") || strings.HasPrefix(trimmed, "<html")
}
func shouldAppendJSONSuffix(path string) bool {
if strings.HasSuffix(path, ".json") {
return false

View File

@ -149,6 +149,34 @@ func TestClientDoNonJSON(t *testing.T) {
}
}
func TestClientDoTopLevelArray(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[{"name":"master"},{"name":"develop"}]`))
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Do("GET", "/api/test/branches", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !env.OK {
t.Fatal("expected OK=true for array response")
}
items, ok := env.Data.([]interface{})
if !ok {
t.Fatalf("expected decoded array, got %T", env.Data)
}
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
first, ok := items[0].(map[string]interface{})
if !ok || first["name"] != "master" {
t.Fatalf("unexpected first item: %#v", items[0])
}
}
func TestClientDoStatusError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

View File

@ -0,0 +1,162 @@
package client
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func withZeroRetryDelay(t *testing.T) {
t.Helper()
old := retryBaseDelay
retryBaseDelay = 0
t.Cleanup(func() { retryBaseDelay = old })
}
func TestGetRetriesTransientStatus(t *testing.T) {
withZeroRetryDelay(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":1}`)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Get("/thing", nil)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !env.OK {
t.Fatalf("env.OK = false, want true")
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
func TestGetStopsAfterMaxRetries(t *testing.T) {
withZeroRetryDelay(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadGateway)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Get("/thing", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if calls != 1+maxGetRetries {
t.Fatalf("calls = %d, want %d", calls, 1+maxGetRetries)
}
}
func TestPostDoesNotRetry(t *testing.T) {
withZeroRetryDelay(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Post("/thing", map[string]interface{}{"a": 1})
if err == nil {
t.Fatal("expected error, got nil")
}
if calls != 1 {
t.Fatalf("calls = %d, want 1 (non-idempotent methods must not retry)", calls)
}
}
func TestGetDoesNotRetryNonTransientStatus(t *testing.T) {
withZeroRetryDelay(t)
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
_, err := c.Get("/thing", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if calls != 1 {
t.Fatalf("calls = %d, want 1 (4xx other than 429 must not retry)", calls)
}
}
func TestHTMLFallthroughIsError(t *testing.T) {
withZeroRetryDelay(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!doctype html><html><head><title>GitLink</title></head></html>`)
}))
defer server.Close()
c := &Client{HTTP: server.Client(), BaseURL: server.URL}
env, err := c.Get("/nonexistent", nil)
if err == nil {
t.Fatal("expected error for HTML fallthrough response, got nil")
}
if env == nil || env.OK {
t.Fatalf("env = %+v, want error envelope with OK=false", env)
}
}
func TestGetRetriesConnectionError(t *testing.T) {
withZeroRetryDelay(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
server.Close() // connection refused from now on
c := &Client{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: server.URL}
start := time.Now()
_, err := c.Get("/thing", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if time.Since(start) > 10*time.Second {
t.Fatal("retries took too long")
}
}
func TestRetryDelayHonorsRetryAfter(t *testing.T) {
mkResp := func(ra string) *http.Response {
h := http.Header{}
if ra != "" {
h.Set("Retry-After", ra)
}
return &http.Response{Header: h}
}
cases := []struct {
name string
attempt int
resp *http.Response
want time.Duration
}{
{"nil response uses backoff", 0, nil, retryBaseDelay},
{"second attempt doubles backoff", 1, nil, retryBaseDelay << 1},
{"retry-after extends delay", 0, mkResp("2"), 2 * time.Second},
{"retry-after capped", 0, mkResp("60"), maxRetryAfter},
{"invalid retry-after ignored", 0, mkResp("soon"), retryBaseDelay},
{"shorter retry-after keeps backoff", 1, mkResp("0"), retryBaseDelay << 1},
}
for _, tc := range cases {
if got := retryDelay(tc.attempt, tc.resp); got != tc.want {
t.Errorf("%s: retryDelay = %v, want %v", tc.name, got, tc.want)
}
}
}