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
2 changes: 1 addition & 1 deletion cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func apiRun(opts *APIOptions) error {

resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Expand Down
44 changes: 44 additions & 0 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,50 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
}
}

func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})

reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-invalidjson", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})

cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}

var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}

func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
Expand Down
68 changes: 68 additions & 0 deletions internal/client/api_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package client

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"

"github.com/larksuite/cli/internal/output"
)

const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."

// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
// actionable API errors for raw `lark-cli api` calls. All other failures
// remain network errors.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}

// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
// into API errors with hints instead of generic parse failures.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
if len(bytes.TrimSpace(body)) == 0 {
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
}
if isJSONDecodeError(err, true) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}

func isJSONDecodeError(err error, allowEOF bool) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError

if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}

msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
}
68 changes: 68 additions & 0 deletions internal/client/api_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package client

import (
"encoding/json"
"errors"
"io"
"strings"
"testing"

"github.com/larksuite/cli/internal/output"
)

func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}

var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}

func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}

var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
}
}

func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
}

var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}
4 changes: 2 additions & 2 deletions internal/client/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result); apiErr != nil {
return apiErr
Expand Down Expand Up @@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
Expand Down
43 changes: 43 additions & 0 deletions internal/client/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package client
import (
"bytes"
"errors"
"io"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -75,6 +76,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
}
}

func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Fatal("expected error for empty body")
}
if !errors.Is(err, io.EOF) {
t.Fatalf("expected wrapped io.EOF, got %v", err)
}
}

func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -219,6 +231,37 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
}
}

func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})

var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error for empty JSON body")
}

var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if exitErr.Detail.Message != "API returned an empty JSON response body" {
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}

func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
Expand Down
Loading