diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0f614..b0ddaa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Client: preserve HTTP error status and the read-error cause when an upstream error body is truncated or interrupted. + - Client: reject responses larger than 1 MiB without decoding truncated data, and report unhandled HTTP redirects as API errors instead of successful results. - Homebrew: install through a Formula (`brew install openclaw/tap/goplaces`) instead of the retired Cask; quarantine is preserved. diff --git a/error_response_read_test.go b/error_response_read_test.go new file mode 100644 index 0000000..7429a81 --- /dev/null +++ b/error_response_read_test.go @@ -0,0 +1,49 @@ +package goplaces + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTruncatedErrorResponsePreservesHTTPStatusAndReadCause(t *testing.T) { + for _, status := range []int{http.StatusTooManyRequests, http.StatusServiceUnavailable, http.StatusUnauthorized} { + t.Run(http.StatusText(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"error":"partial-test-key`)) + })) + defer server.Close() + client := NewClient(Options{APIKey: "test-key", BaseURL: server.URL}) + _, err := client.Search(context.Background(), SearchRequest{Query: "coffee"}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != status { + t.Errorf("status lost: %v", err) + } + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("read cause lost: %v", err) + } + if strings.Contains(err.Error(), "test-key") { + t.Errorf("error exposed API key: %v", err) + } + }) + } +} + +func TestTruncatedSuccessResponseRetainsReadFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + _, _ = w.Write([]byte(`{"places":[]}`)) + })) + defer server.Close() + _, err := NewClient(Options{APIKey: "test-key", BaseURL: server.URL}).Search(context.Background(), SearchRequest{Query: "coffee"}) + var apiErr *APIError + if errors.As(err, &apiErr) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("success read failure: %v", err) + } +} diff --git a/internal/places/client.go b/internal/places/client.go index 54fd8ff..4a35149 100644 --- a/internal/places/client.go +++ b/internal/places/client.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -119,7 +120,13 @@ func (c *Client) doRequest( // Read one extra byte to distinguish an exact-size response from truncation. payload, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1)) if err != nil { - return nil, c.requestError("read response", err) + readErr := c.requestError("read response", err) + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + // The status remains actionable even if its diagnostic body is incomplete. + apiErr := &APIError{StatusCode: response.StatusCode} + return nil, fmt.Errorf("%w: %w", apiErr, readErr) + } + return nil, readErr } tooLarge := len(payload) > maxResponseBytes