diff --git a/cmd/langsmith/main.go b/cmd/langsmith/main.go index 4908552..e992aab 100644 --- a/cmd/langsmith/main.go +++ b/cmd/langsmith/main.go @@ -16,7 +16,7 @@ var ( func main() { rootCmd := cmd.NewRootCmd(version, fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)) if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, cmd.FormatErrorMessage(err)) os.Exit(1) } } diff --git a/internal/cmd/errors.go b/internal/cmd/errors.go new file mode 100644 index 0000000..677831f --- /dev/null +++ b/internal/cmd/errors.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + langsmith "github.com/langchain-ai/langsmith-go" +) + +type apiErrorBody struct { + Error string `json:"error"` + Message string `json:"message"` + ErrorDescription string `json:"error_description"` + Detail json.RawMessage `json:"detail"` +} + +type apiValidationDetail struct { + Loc []any `json:"loc"` + Msg string `json:"msg"` + Type string `json:"type"` +} + +func FormatErrorMessage(err error) string { + if err == nil { + return "" + } + var apiErr *langsmith.Error + if !errors.As(err, &apiErr) { + return err.Error() + } + + message := formatAPIError(apiErr) + if message == "" { + return err.Error() + } + if prefix := strings.TrimSuffix(err.Error(), apiErr.Error()); prefix != err.Error() { + return prefix + message + } + return message +} + +func formatAPIError(err *langsmith.Error) string { + statusCode := err.StatusCode + if statusCode == 0 && err.Response != nil { + statusCode = err.Response.StatusCode + } + message := formatAPIErrorBody([]byte(err.JSON.RawJSON())) + if statusCode == 0 { + return message + } + status := http.StatusText(statusCode) + if status == "" { + status = "HTTP" + } + if message == "" { + return fmt.Sprintf("%d %s", statusCode, status) + } + return fmt.Sprintf("%d %s: %s", statusCode, status, message) +} + +func formatAPIErrorBody(body []byte) string { + var parsed apiErrorBody + if err := json.Unmarshal(body, &parsed); err != nil { + return strings.TrimSpace(string(body)) + } + if message := formatAPIErrorDetail(parsed.Detail); message != "" { + return message + } + for _, message := range []string{parsed.Message, parsed.ErrorDescription, parsed.Error} { + if message = strings.TrimSpace(message); message != "" { + return message + } + } + return strings.TrimSpace(string(body)) +} + +func formatAPIErrorDetail(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var message string + if err := json.Unmarshal(raw, &message); err == nil { + return strings.TrimSpace(message) + } + var details []apiValidationDetail + if err := json.Unmarshal(raw, &details); err == nil { + parts := make([]string, 0, len(details)) + for _, detail := range details { + msg := strings.TrimSpace(detail.Msg) + if msg == "" { + continue + } + if loc := formatValidationLoc(detail.Loc); loc != "" { + msg = loc + ": " + msg + } + parts = append(parts, msg) + } + return strings.Join(parts, "; ") + } + return strings.TrimSpace(string(raw)) +} + +func formatValidationLoc(loc []any) string { + parts := make([]string, 0, len(loc)) + for _, part := range loc { + value := strings.TrimSpace(fmt.Sprint(part)) + if value == "" || value == "body" { + continue + } + parts = append(parts, value) + } + return strings.Join(parts, ".") +} diff --git a/internal/cmd/errors_test.go b/internal/cmd/errors_test.go new file mode 100644 index 0000000..b1ddfe2 --- /dev/null +++ b/internal/cmd/errors_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + langsmith "github.com/langchain-ai/langsmith-go" + "github.com/stretchr/testify/require" +) + +func testAPIError(t *testing.T, statusCode int, body string) *langsmith.Error { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, "https://api.example.com/v2/sandboxes/boxes", nil) + require.NoError(t, err) + + apiErr := &langsmith.Error{ + StatusCode: statusCode, + Request: req, + Response: &http.Response{ + StatusCode: statusCode, + }, + } + require.NoError(t, json.Unmarshal([]byte(body), apiErr)) + return apiErr +} + +func TestFormatErrorMessageSimplifiesAPIValidationDetail(t *testing.T) { + apiErr := testAPIError(t, http.StatusUnprocessableEntity, `{ + "detail": [{ + "loc": ["body", "snapshot_id"], + "msg": "field required", + "type": "value_error.missing" + }, { + "loc": ["body", "snapshot_name"], + "msg": "field required", + "type": "value_error.missing" + }] + }`) + + got := FormatErrorMessage(fmt.Errorf("creating sandbox: %w", apiErr)) + + require.Equal(t, "creating sandbox: 422 Unprocessable Entity: snapshot_id: field required; snapshot_name: field required", got) + require.NotContains(t, got, "POST") + require.NotContains(t, got, `"detail"`) +} + +func TestFormatErrorMessageSimplifiesBodyLevelAPIValidationDetail(t *testing.T) { + apiErr := testAPIError(t, http.StatusUnprocessableEntity, `{ + "detail": [{ + "loc": ["body"], + "msg": "one of snapshot_id or snapshot_name is required", + "type": "value_error" + }] + }`) + + got := FormatErrorMessage(fmt.Errorf("creating sandbox: %w", apiErr)) + + require.Equal(t, "creating sandbox: 422 Unprocessable Entity: one of snapshot_id or snapshot_name is required", got) +} + +func TestFormatErrorMessageUsesMessageFields(t *testing.T) { + apiErr := testAPIError(t, http.StatusForbidden, `{ + "error": "Forbidden", + "message": "workspace access required" + }`) + + got := FormatErrorMessage(apiErr) + + require.Equal(t, "403 Forbidden: workspace access required", got) +} + +func TestFormatErrorMessageReturnsNonAPIErrorString(t *testing.T) { + err := errors.New("plain error") + + got := FormatErrorMessage(err) + + require.Equal(t, "plain error", got) +} diff --git a/internal/cmd/sandbox_test.go b/internal/cmd/sandbox_test.go index ed19a75..3d44c22 100644 --- a/internal/cmd/sandbox_test.go +++ b/internal/cmd/sandbox_test.go @@ -2,8 +2,10 @@ package cmd import ( "encoding/json" + "net/http" "os" "path/filepath" + "strings" "testing" ) @@ -303,6 +305,42 @@ func TestSandboxCreateCmd_ProxyConfigFlag(t *testing.T) { } } +func TestSandboxCreateCmd_RendersAPIValidationError(t *testing.T) { + ts := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/sandboxes/boxes" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]any{ + "detail": []map[string]any{{ + "loc": []string{"body"}, + "msg": "one of snapshot_id or snapshot_name is required", + "type": "value_error", + }}, + }) + }) + + root := NewRootCmd("test", "test") + root.SetArgs([]string{"--api-key", "test-key", "--api-url", ts.URL, "sandbox", "create", "ramonn-test", "--snapshot-id", "snap-123"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error") + } + got := err.Error() + if !strings.Contains(got, `POST "`) || !strings.Contains(got, `"detail"`) { + t.Fatalf("expected original SDK error, got %q", got) + } + + display := FormatErrorMessage(err) + if !strings.Contains(display, "creating sandbox: 422 Unprocessable Entity: one of snapshot_id or snapshot_name is required") { + t.Fatalf("unexpected error: %q", got) + } + if strings.Contains(display, "POST ") || strings.Contains(display, `"detail"`) { + t.Fatalf("expected simplified error, got %q", display) + } +} + func TestSandboxUpdateCmd_ProxyConfigFlag(t *testing.T) { cmd := sandboxUpdateCommand.Cobra() f := cmd.Flags().Lookup("proxy-config")