Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions internal/helpers/aitable_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,11 +441,17 @@ func newAitableRecordQueryCommand(runner executor.Runner) *cobra.Command {
params["fieldIds"] = parseAitableCSVValues(fieldIDs)
}
if filtersRaw := aitableStringFlag(cmd, "filters"); filtersRaw != "" {
filters, err := parseAitableJSONObject(filtersRaw, "filters")
if err != nil {
return err
// Support both JSON array (e.g. [{"field":"f","operator":"is","value":"v"}])
// and JSON object formats — try array first, then fall back to object.
// This preserves backward compatibility with existing object-format users
// while also accepting the array format that AITable API supports.
if arrayVal, arrayErr := parseAitableJSONArray(filtersRaw, "filters"); arrayErr == nil {
params["filters"] = arrayVal
} else if objVal, objErr := parseAitableJSONObject(filtersRaw, "filters"); objErr == nil {
params["filters"] = objVal
} else {
return apperrors.NewValidation("--filters JSON parse failed: expected a JSON array or object")
}
params["filters"] = filters
}
if sortRaw := aitableStringFlag(cmd, "sort"); sortRaw != "" {
sortValue, err := parseAitableJSONArray(sortRaw, "sort")
Expand Down
104 changes: 98 additions & 6 deletions internal/transport/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ const (
defaultHTTPTimeout = 30 * time.Second

// Default retry parameters for JSON-RPC calls.
defaultMaxRetries = 1
// Increased from 1 to 2 (3 total attempts) to improve resilience against
// transient EOF errors during batch operations and brief 429 rate-limiting.
defaultMaxRetries = 2
defaultRetryDelay = 500 * time.Millisecond
defaultRetryMaxDelay = 5 * time.Second

Expand Down Expand Up @@ -389,11 +391,25 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
logging.LogResponse(c.FileLogger, request.Method, endpoint, c.ExecutionId, resp.StatusCode, len(data), time.Since(callStart), err)
if err != nil {
// Detect EOF during response body read — this typically happens when
// the server closes the connection mid-transfer (e.g. under high load
// during batch operations). Mark as retryable so the retry loop can
// recover automatically.
isEOF := errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
reason := reasonForMethod(request.Method, "response_read_failed")
hint := i18n.T("检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。")
retryable := false
if isEOF {
reason = reasonForMethod(request.Method, "response_eof")
hint = i18n.T("连接被服务端提前关闭(EOF);批量操作时可能因响应过大触发,建议稍后重试或减少单批数据量。")
retryable = true
}
return apperrors.NewDiscovery(
"failed to read JSON-RPC response",
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "response_read_failed")),
apperrors.WithHint(i18n.T("检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。")),
apperrors.WithReason(reason),
apperrors.WithRetryable(retryable),
apperrors.WithHint(hint),
apperrors.WithActions(discoveryActions("")...),
apperrors.WithTraceID(headerTraceID),
)
Expand All @@ -405,7 +421,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
logging.LogResponseBody(c.FileLogger, request.Method, c.ExecutionId, resp.StatusCode, data, headerTraceID)
return httpStatusError(request.Method, endpoint, resp.StatusCode, snapshotPath, headerTraceID)
return httpStatusError(request.Method, endpoint, resp.StatusCode, data, snapshotPath, headerTraceID)
}

if !expectResponse {
Expand Down Expand Up @@ -545,6 +561,38 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
)
}

// extractBodyPreview extracts a human-readable preview from an HTTP response body.
// It first attempts to parse the body as JSON and extract common error description
// fields (message, msg, error, detail, etc.). If JSON parsing fails or no known
// field is found, it falls back to the raw body truncated to maxLen bytes.
// Returns an empty string when body is nil or empty, so callers can safely
// append it to hints without extra nil checks.
func extractBodyPreview(body []byte, maxLen int) string {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return ""
}

// Try to extract a descriptive field from a JSON object response.
var obj map[string]any
if json.Unmarshal(trimmed, &obj) == nil {
for _, key := range []string{"message", "msg", "error", "detail", "description", "errorMessage"} {
if v, ok := obj[key].(string); ok && strings.TrimSpace(v) != "" {
if len(v) > maxLen {
return v[:maxLen] + "..."
}
return v
}
}
}

// Fallback: return raw body truncated to maxLen.
if len(trimmed) > maxLen {
return string(trimmed[:maxLen]) + "..."
}
return string(trimmed)
}

func retryable(statusCode int) bool {
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
}
Expand Down Expand Up @@ -601,6 +649,14 @@ func classifyRequestFailure(err error) (reason, hint string) {
case strings.Contains(msg, "i/o timeout"):
return "io_timeout",
i18n.T("网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。")

// EOF during the HTTP request phase: the server closed the connection
// before sending a complete response. Common under high server load or
// when batch requests produce very large responses.
case errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) ||
msg == "EOF" || strings.Contains(msg, "unexpected EOF"):
return "connection_eof",
i18n.T("连接被服务端提前关闭(EOF);可能因服务端重启或负载过高触发,建议稍后重试。")
default:
return "request_failed",
i18n.T("请检查网络连通性和 MCP 服务状态后重试。")
Expand Down Expand Up @@ -767,7 +823,11 @@ func sanitizeBearerToken(raw string) string {
return token
}

func httpStatusError(method, endpoint string, statusCode int, snapshotPath, headerTraceID string) error {
// httpStatusError builds a structured error for non-2xx HTTP responses.
// The responseBody parameter carries the raw server response so that a
// human-readable preview can be appended to the hint, giving users
// actionable context (e.g. which parameter was rejected).
func httpStatusError(method, endpoint string, statusCode int, responseBody []byte, snapshotPath, headerTraceID string) error {
message := fmt.Sprintf("request to %s returned HTTP %d", RedactURL(endpoint), statusCode)
opts := []apperrors.Option{
apperrors.WithOperation(method),
Expand Down Expand Up @@ -796,9 +856,29 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath, head
apperrors.WithActions(authActions(snapshotPath)...),
)
return apperrors.NewAuth(message, opts...)

// Dedicated 429 handling: provide clear rate-limiting guidance instead of
// the generic "check params" hint that the old 4xx catch-all produced.
case statusCode == http.StatusTooManyRequests:
hint := i18n.T("请求频率过高,服务端触发限流;建议稍后重试,短时高频场景请降低调用频率或增加请求间隔。")
if preview := extractBodyPreview(responseBody, 200); preview != "" {
hint += " " + i18n.T("服务端响应") + ": " + preview
}
opts = append(opts,
apperrors.WithHint(hint),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
return apperrors.NewAPI(message, opts...)

// Generic 4xx: append server response body preview to hint so users
// can see *why* the request was rejected (fixes "HTTP 400 no details").
case statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError:
hint := i18n.T("请求被上游服务拒绝;请检查参数、认证和权限配置。")
if preview := extractBodyPreview(responseBody, 200); preview != "" {
hint += " " + i18n.T("服务端响应") + ": " + preview
}
opts = append(opts,
apperrors.WithHint(i18n.T("请求被上游服务拒绝;请检查参数、认证和权限配置。")),
apperrors.WithHint(hint),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
if method != "tools/call" {
Expand Down Expand Up @@ -852,6 +932,18 @@ func jsonrpcEnvelopeError(method string, rpcErr *RPCError, snapshotPath, headerT
return apperrors.NewValidation(message, opts...)
}

// DingTalk business error code 2064 indicates cluster high load / rate
// limiting. Provide a dedicated hint and mark as retryable so users know
// this is a transient server-side throttle, not a client-side bug.
if rpcErr.Code == 2064 || diag.ServerErrorCode == "2064" {
opts = append(opts,
apperrors.WithHint(i18n.T("集群负载较高(错误码 2064),服务端触发限流;请稍后重试,短时高频场景建议增加请求间隔或启用指数退避策略。")),
apperrors.WithRetryable(true),
apperrors.WithActions(runtimeActions(snapshotPath)...),
)
return apperrors.NewAPI(message, opts...)
}

if looksAuthRPCError(rpcErr) {
opts = append(opts,
apperrors.WithHint(i18n.T("调用被拒绝;请检查认证状态、租户身份或访问权限。")),
Expand Down
2 changes: 1 addition & 1 deletion internal/transport/client_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func TestHttpStatusError(t *testing.T) {
http.StatusInternalServerError,
}
for _, code := range codes {
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, "", "")
err := httpStatusError("tools/call", "https://api.example.com/mcp", code, nil, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/transport/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func TestCallTool_RetriesOn502(t *testing.T) {
func TestHttpStatusError_AllCodes(t *testing.T) {
t.Parallel()
for _, code := range []int{400, 401, 403, 404, 429, 500, 502, 503} {
err := httpStatusError("tools/call", "https://api.example.com", code, "", "")
err := httpStatusError("tools/call", "https://api.example.com", code, nil, "", "")
if err == nil {
t.Fatalf("expected error for status %d", code)
}
Expand All @@ -303,7 +303,7 @@ func TestHttpStatusError_AllCodes(t *testing.T) {

func TestHttpStatusError_WithSnapshot(t *testing.T) {
t.Parallel()
err := httpStatusError("initialize", "https://api.example.com", 500, "/tmp/snap.json", "")
err := httpStatusError("initialize", "https://api.example.com", 500, nil, "/tmp/snap.json", "")
if err == nil {
t.Fatal("expected error")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/transport/recovery_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

func TestHTTPStatusErrorIncludesCallMetadata(t *testing.T) {
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, "", "")
err := httpStatusError("tools/call", "https://mcp.dingtalk.com/server", http.StatusTooManyRequests, nil, "", "")

var callErr *CallError
if !errors.As(err, &callErr) {
Expand Down