Skip to content
Draft
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/langsmith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
116 changes: 116 additions & 0 deletions internal/cmd/errors.go
Original file line number Diff line number Diff line change
@@ -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, ".")
}
82 changes: 82 additions & 0 deletions internal/cmd/errors_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
38 changes: 38 additions & 0 deletions internal/cmd/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package cmd

import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -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")
Expand Down
Loading