From 3d1c5c933ac5192c37049099f88b215c48ce3f40 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:24:48 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(client):=20GET=20=E7=9E=AC=E6=80=81?= =?UTF-8?q?=E6=95=85=E9=9A=9C=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E6=8C=87=E6=95=B0=E9=80=80=E9=81=BF=EF=BC=89=EF=BC=8C=E9=9D=9E?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=96=B9=E6=B3=95=E4=B8=8D=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- doc/changes/get-retry-backoff.md | 23 ++++++ internal/client/client.go | 55 +++++++++++++-- internal/client/retry_test.go | 116 +++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 doc/changes/get-retry-backoff.md create mode 100644 internal/client/retry_test.go diff --git a/doc/changes/get-retry-backoff.md b/doc/changes/get-retry-backoff.md new file mode 100644 index 0000000..3bce4b6 --- /dev/null +++ b/doc/changes/get-retry-backoff.md @@ -0,0 +1,23 @@ +# GET 请求瞬态故障自动重试(指数退避) + +## 背景 + +CLI 目前对任何网络抖动(连接失败、网关 502/503/504、限流 429)都直接失败。 +在 AI Agent / CI 门禁等自动化场景下,一次瞬态故障就会中断整条工作流。 +成熟 CLI(如 gh)默认对幂等请求做有限次重试。 + +## 变更内容 + +- `internal/client`:GET(幂等)请求遇到以下瞬态故障自动重试, + 最多 2 次,指数退避(300ms → 600ms): + - 网络层错误(连接失败、读响应失败) + - HTTP 429 / 502 / 503 / 504 +- 非幂等方法(POST/PUT/DELETE/PATCH)一律不重试,避免重复副作用。 +- 其他 4xx/5xx 状态不重试(非瞬态)。 +- `--debug` 下打印每次重试的原因与退避时长。 + +## 测试 + +`internal/client/retry_test.go` 5 个用例:503 两次后成功、超过重试上限 +停止、POST 不重试、404 不重试、连接拒绝重试后报错。 +`go test ./...` / `go vet` / `gofmt` 全绿。 diff --git a/internal/client/client.go b/internal/client/client.go index 1fb9d80..2f3f1a1 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/internal/config" @@ -20,6 +21,25 @@ 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 + +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{} @@ -83,15 +103,36 @@ 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 + } + delay := retryBaseDelay << attempt + 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 { diff --git a/internal/client/retry_test.go b/internal/client/retry_test.go new file mode 100644 index 0000000..c9a170f --- /dev/null +++ b/internal/client/retry_test.go @@ -0,0 +1,116 @@ +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 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") + } +} -- 2.34.1 From 20f988bf5b2265973884ce3420f58ff556f4da87 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:27:44 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(client):=20=E9=94=99=E8=AF=AF=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E7=BB=9F=E4=B8=80=E9=80=8F=E4=BC=A0=20+=20HTML=20?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=E5=88=A4=E9=94=99=EF=BC=88non=5Fapi=5Frespon?= =?UTF-8?q?se=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- cmd/api/api.go | 2 +- .../get-retry-and-error-consistency.md | 36 +++++++++++++++++++ doc/changes/get-retry-backoff.md | 23 ------------ internal/client/client.go | 22 ++++++++++++ internal/client/retry_test.go | 18 ++++++++++ 5 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 doc/changes/get-retry-and-error-consistency.md delete mode 100644 doc/changes/get-retry-backoff.md diff --git a/cmd/api/api.go b/cmd/api/api.go index cae531a..64b9843 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -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 diff --git a/doc/changes/get-retry-and-error-consistency.md b/doc/changes/get-retry-and-error-consistency.md new file mode 100644 index 0000000..90168b8 --- /dev/null +++ b/doc/changes/get-retry-and-error-consistency.md @@ -0,0 +1,36 @@ +# 网络健壮性与错误语义一致性 + +## 背景 + +对标成熟 CLI(gh 等)的生产标准,本次补齐三个缺口: + +1. 任何网络抖动(连接失败、网关 502/503/504、限流 429)都直接失败, + AI Agent / CI 门禁场景下一次瞬态故障即中断整条工作流。 +2. HTTP 层错误(4xx/5xx)不携带修复建议,而 body 错误有——同一错误 + 两种呈现,Agent 无法统一消费。 +3. 生产实测发现:请求不存在的 API 路径时,网关回落到 Web 前端返回 + 200 + HTML 首页,CLI 误判为成功并把整页 HTML 当数据输出 `ok:true`。 + +## 变更内容 + +- `internal/client`:GET(幂等)请求遇瞬态故障自动重试,最多 2 次, + 指数退避(300ms → 600ms):网络层错误、HTTP 429/502/503/504。 + 非幂等方法(POST/PUT/DELETE/PATCH)一律不重试,避免重复副作用; + 其他状态码不重试。`--debug` 下打印每次重试原因与退避时长。 +- `APIError` 新增 `Suggestion` 字段;HTTP 层错误与 body 错误统一 + 携带 `suggestFix` 修复建议,`api` 命令错误信封透传该建议。 +- 非 JSON 响应若为 HTML 页(Content-Type 或 doctype 探测),返回 + `non_api_response` 错误信封(`ok:false` + 建议检查路径), + 不再把 HTML 首页当成功数据。 + +## 生产验证 + +- `api GET /nonexistent-endpoint-xyz` → `ok:false, code:non_api_response` + (修复前:`ok:true` + 整页 HTML)。 +- `repo +info` 正常端点行为不变。 + +## 测试 + +`internal/client/retry_test.go` 6 个用例:503 重试后成功、超上限停止、 +POST 不重试、404 不重试、连接拒绝重试后报错、HTML 回落判错。 +`go test ./...` / `go vet` / `gofmt` 全绿。 diff --git a/doc/changes/get-retry-backoff.md b/doc/changes/get-retry-backoff.md deleted file mode 100644 index 3bce4b6..0000000 --- a/doc/changes/get-retry-backoff.md +++ /dev/null @@ -1,23 +0,0 @@ -# GET 请求瞬态故障自动重试(指数退避) - -## 背景 - -CLI 目前对任何网络抖动(连接失败、网关 502/503/504、限流 429)都直接失败。 -在 AI Agent / CI 门禁等自动化场景下,一次瞬态故障就会中断整条工作流。 -成熟 CLI(如 gh)默认对幂等请求做有限次重试。 - -## 变更内容 - -- `internal/client`:GET(幂等)请求遇到以下瞬态故障自动重试, - 最多 2 次,指数退避(300ms → 600ms): - - 网络层错误(连接失败、读响应失败) - - HTTP 429 / 502 / 503 / 504 -- 非幂等方法(POST/PUT/DELETE/PATCH)一律不重试,避免重复副作用。 -- 其他 4xx/5xx 状态不重试(非瞬态)。 -- `--debug` 下打印每次重试的原因与退避时长。 - -## 测试 - -`internal/client/retry_test.go` 5 个用例:503 两次后成功、超过重试上限 -停止、POST 不重试、404 不重试、连接拒绝重试后报错。 -`go test ./...` / `go vet` / `gofmt` 全绿。 diff --git a/internal/client/client.go b/internal/client/client.go index 2f3f1a1..50e4b80 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -44,6 +44,7 @@ type APIError struct { StatusCode int Code interface{} Message string + Suggestion string } func (e *APIError) Error() string { @@ -145,12 +146,24 @@ 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 { + // 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 } @@ -185,6 +198,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o StatusCode: int(bodyCode), Code: int(bodyCode), Message: bodyMsg, + Suggestion: suggestion, } } @@ -214,6 +228,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, "GitLink`) + })) + 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) {})) -- 2.34.1 From 71289605297f843b921038d7edb7ab770cf438b8 Mon Sep 17 00:00:00 2001 From: laurencewannamaker Date: Sun, 5 Jul 2026 22:53:21 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(client):=20429=20=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E9=81=B5=E5=BE=AA=20Retry-After=20=E5=A4=B4=EF=BC=885s=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E6=8A=A4=E6=A0=8F=EF=BC=89+=206=20=E8=A1=A8?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../get-retry-and-error-consistency.md | 1 + internal/client/client.go | 32 ++++++++++++++++++- internal/client/retry_test.go | 28 ++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/doc/changes/get-retry-and-error-consistency.md b/doc/changes/get-retry-and-error-consistency.md index 90168b8..a72ffd4 100644 --- a/doc/changes/get-retry-and-error-consistency.md +++ b/doc/changes/get-retry-and-error-consistency.md @@ -15,6 +15,7 @@ - `internal/client`:GET(幂等)请求遇瞬态故障自动重试,最多 2 次, 指数退避(300ms → 600ms):网络层错误、HTTP 429/502/503/504。 + 服务端返回 `Retry-After` 头(秒)时优先遵循,并以 5s 上限护栏保持 CLI 响应性。 非幂等方法(POST/PUT/DELETE/PATCH)一律不重试,避免重复副作用; 其他状态码不重试。`--debug` 下打印每次重试原因与退避时长。 - `APIError` 新增 `Suggestion` 字段;HTTP 层错误与 body 错误统一 diff --git a/internal/client/client.go b/internal/client/client.go index 50e4b80..8512243 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -29,6 +30,31 @@ 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, @@ -122,7 +148,11 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o if method != http.MethodGet || !transient || attempt >= maxGetRetries { break } - delay := retryBaseDelay << attempt + 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) diff --git a/internal/client/retry_test.go b/internal/client/retry_test.go index 86b0da5..28c3465 100644 --- a/internal/client/retry_test.go +++ b/internal/client/retry_test.go @@ -132,3 +132,31 @@ func TestGetRetriesConnectionError(t *testing.T) { 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) + } + } +} -- 2.34.1 From 515612ddc53ce14ea657e60b2f7f8c4fe3cf6ef8 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 14:36:56 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(client):=20=E9=A1=B6=E5=B1=82=20JSON=20?= =?UTF-8?q?=E6=95=B0=E7=BB=84/=E6=A0=87=E9=87=8F=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=99=8D=E7=BA=A7=E4=B8=BA=E8=BD=AC=E4=B9=89?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../get-retry-and-error-consistency.md | 8 ++++++ internal/client/client.go | 7 +++++ internal/client/client_test.go | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/doc/changes/get-retry-and-error-consistency.md b/doc/changes/get-retry-and-error-consistency.md index a72ffd4..5b45005 100644 --- a/doc/changes/get-retry-and-error-consistency.md +++ b/doc/changes/get-retry-and-error-consistency.md @@ -10,6 +10,9 @@ 两种呈现,Agent 无法统一消费。 3. 生产实测发现:请求不存在的 API 路径时,网关回落到 Web 前端返回 200 + HTML 首页,CLI 误判为成功并把整页 HTML 当数据输出 `ok:true`。 +4. 生产实测发现:返回顶层 JSON 数组的遗留端点(如 + `/:owner/:repo/branches`)被降级为转义字符串输出,`--jq`/表格渲染 + 等下游能力全部失效。 ## 变更内容 @@ -23,15 +26,20 @@ - 非 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` 全绿。 diff --git a/internal/client/client.go b/internal/client/client.go index 8512243..66776e5 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -183,6 +183,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o // 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) { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c46226b..b5434b5 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -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") -- 2.34.1