From 38a8cf407ac34cb74ecdb08de859521c7896492f Mon Sep 17 00:00:00 2001 From: poyrazK <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 7 May 2026 21:46:34 +0300 Subject: [PATCH 1/2] fix(sdk): parse API error responses into typed errors When the API returns an error response with JSON like: {"error": {"type": "BUCKET_NOT_FOUND", "message": "bucket not found"}} The SDK now parses this and returns a proper errors.Error type instead of a generic "api error: {json}" string. This fixes the issue where CLI commands show "An unexpected error occurred" instead of the actual API error message. Now callers can use errors.Is() to check for specific error types. Changes: - Add parseAPIError() function to client.go - Update getContext, postContext, deleteWithContext, putWithContext, patchWithContext - Map API error type strings (case-insensitive) to errors.Type - Update TestClientAPIError to verify proper error type parsing Fixes #472 --- pkg/sdk/client.go | 73 ++++++++++++++++++++++++++++++++++++++--- pkg/sdk/compute_test.go | 3 +- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 25f1bdca..7009a2f8 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -3,11 +3,13 @@ package sdk import ( "context" + "encoding/json" "fmt" "strings" "github.com/go-resty/resty/v2" "github.com/google/uuid" + "github.com/poyrazk/thecloud/internal/errors" ) // Client is the API client for the platform. @@ -50,6 +52,67 @@ type ErrorResponse struct { Message string `json:"message"` } +// APIErrorType maps API error type strings (case-insensitive) to internal error types. +var APIErrorType = map[string]errors.Type{ + "BUCKET_NOT_FOUND": errors.BucketNotFound, + "OBJECT_NOT_FOUND": errors.ObjectNotFound, + "INVALID_INPUT": errors.InvalidInput, + "BAD_REQUEST": errors.InvalidInput, + "NOT_FOUND": errors.NotFound, + "INTERNAL": errors.Internal, + "FORBIDDEN": errors.Forbidden, + "UNAUTHORIZED": errors.Unauthorized, + "CONFLICT": errors.Conflict, + "RESOURCE_LIMIT_EXCEEDED": errors.ResourceLimitExceeded, + "QUOTA_EXCEEDED": errors.QuotaExceeded, + "NOT_IMPLEMENTED": errors.NotImplemented, + "PORT_CONFLICT": errors.PortConflict, + "TOO_MANY_PORTS": errors.TooManyPorts, + "INSTANCE_NOT_RUNNING": errors.InstanceNotRunning, + "LB_NOT_FOUND": errors.LBNotFound, + "LB_TARGET_EXISTS": errors.LBTargetExists, + "LB_CROSS_VPC": errors.LBCrossVPC, + "PERMISSION_DENIED": errors.PermissionDenied, + // Lowercase variants + "bucket_not_found": errors.BucketNotFound, + "object_not_found": errors.ObjectNotFound, + "invalid_input": errors.InvalidInput, + "bad_request": errors.InvalidInput, + "not_found": errors.NotFound, + "internal": errors.Internal, + "forbidden": errors.Forbidden, + "unauthorized": errors.Unauthorized, + "conflict": errors.Conflict, + "resource_limit_exceeded": errors.ResourceLimitExceeded, + "quota_exceeded": errors.QuotaExceeded, + "not_implemented": errors.NotImplemented, + "port_conflict": errors.PortConflict, + "too_many_ports": errors.TooManyPorts, + "instance_not_running": errors.InstanceNotRunning, + "lb_not_found": errors.LBNotFound, + "lb_target_exists": errors.LBTargetExists, + "lb_cross_vpc": errors.LBCrossVPC, + "permission_denied": errors.PermissionDenied, +} + +// parseAPIError parses the response body as an error response and returns a typed error. +func parseAPIError(body []byte) error { + var apiResp Response[any] + if err := json.Unmarshal(body, &apiResp); err != nil { + return errors.New(errors.Internal, fmt.Sprintf("api error: %s", string(body))) + } + if apiResp.Error == nil { + return errors.New(errors.Internal, fmt.Sprintf("api error: %s", string(body))) + } + + errType := errors.Internal + if t, ok := APIErrorType[apiResp.Error.Type]; ok { + errType = t + } + + return errors.New(errType, apiResp.Error.Message) +} + // get performs a GET request against the API. func (c *Client) get(path string, result interface{}) error { return c.getContext(context.Background(), path, result) @@ -66,7 +129,7 @@ func (c *Client) getContext(ctx context.Context, path string, result interface{} } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -95,7 +158,7 @@ func (c *Client) postContext(ctx context.Context, path string, body interface{}, } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -121,7 +184,7 @@ func (c *Client) deleteWithContext(ctx context.Context, path string, result inte } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -146,7 +209,7 @@ func (c *Client) putWithContext(ctx context.Context, path string, body interface } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil @@ -171,7 +234,7 @@ func (c *Client) patchWithContext(ctx context.Context, path string, body interfa } if resp.IsError() { - return fmt.Errorf(errAPIError, resp.String()) + return parseAPIError(resp.Body()) } return nil diff --git a/pkg/sdk/compute_test.go b/pkg/sdk/compute_test.go index 14f1388a..fdaf5e12 100644 --- a/pkg/sdk/compute_test.go +++ b/pkg/sdk/compute_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/poyrazk/thecloud/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -229,7 +230,7 @@ func TestClientAPIError(t *testing.T) { _, err := client.ListInstances() require.Error(t, err) - assert.Contains(t, err.Error(), "api error") + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput error, got: %v", err) assert.Contains(t, err.Error(), "invalid input") } From 47c27da1a2c49f38635dca6e37966f49d78be2a1 Mon Sep 17 00:00:00 2001 From: poyrazK <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 7 May 2026 21:58:16 +0300 Subject: [PATCH 2/2] test(sdk): add comprehensive tests for parseAPIError Add 11 tests verifying: - parseAPIError correctly parses various error types (BUCKET_NOT_FOUND, NOT_FOUND, INVALID_INPUT, FORBIDDEN) - Lowercase error types work - Unknown error types fall back to Internal - Invalid JSON falls back to Internal - Missing error field falls back to Internal - Full HTTP methods (get, post, delete) correctly parse API errors All tests pass. --- pkg/sdk/client_test.go | 104 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/pkg/sdk/client_test.go b/pkg/sdk/client_test.go index c59d585e..442ec746 100644 --- a/pkg/sdk/client_test.go +++ b/pkg/sdk/client_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "testing" + "github.com/poyrazk/thecloud/internal/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -190,3 +191,106 @@ func TestResolveID_ListError(t *testing.T) { "abc") require.Error(t, err) } + +func TestParseAPIError_BucketNotFound(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "BUCKET_NOT_FOUND", "message": "bucket not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.BucketNotFound), "expected BucketNotFound, got: %v", err) + assert.Contains(t, err.Error(), "bucket not found") +} + +func TestParseAPIError_NotFound(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "NOT_FOUND", "message": "resource not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.NotFound), "expected NotFound, got: %v", err) + assert.Contains(t, err.Error(), "resource not found") +} + +func TestParseAPIError_InvalidInput(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "INVALID_INPUT", "message": "invalid input provided"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput, got: %v", err) + assert.Contains(t, err.Error(), "invalid input provided") +} + +func TestParseAPIError_Forbidden(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "FORBIDDEN", "message": "access denied"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Forbidden), "expected Forbidden, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +func TestParseAPIError_LowercaseType(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "not_found", "message": "item not found"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.NotFound), "expected NotFound (lowercase), got: %v", err) +} + +func TestParseAPIError_UnknownType(t *testing.T) { + jsonBody := []byte(`{"error": {"type": "SOME_UNKNOWN_ERROR", "message": "something went wrong"}}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for unknown type, got: %v", err) + assert.Contains(t, err.Error(), "something went wrong") +} + +func TestParseAPIError_InvalidJSON(t *testing.T) { + jsonBody := []byte(`not valid json`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for invalid JSON, got: %v", err) +} + +func TestParseAPIError_MissingErrorField(t *testing.T) { + jsonBody := []byte(`{"data": "something"}`) + err := parseAPIError(jsonBody) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Internal), "expected Internal for missing error field, got: %v", err) +} + +func TestClientGet_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": {"type": "BUCKET_NOT_FOUND", "message": "bucket not found"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.get("/buckets/test", &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.BucketNotFound), "expected BucketNotFound, got: %v", err) +} + +func TestClientPost_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": {"type": "INVALID_INPUT", "message": "missing required field"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.post("/test", map[string]string{"a": "b"}, &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.InvalidInput), "expected InvalidInput, got: %v", err) +} + +func TestClientDelete_ParsesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error": {"type": "FORBIDDEN", "message": "cannot delete"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, clientTestAPIKey) + var res Response[any] + err := client.delete("/test/123", &res) + require.Error(t, err) + assert.True(t, errors.Is(err, errors.Forbidden), "expected Forbidden, got: %v", err) +}