From c301fea850fa58d07b912e046950d8bf404fc9d3 Mon Sep 17 00:00:00 2001 From: Connor Church <59625834+connorckong@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:03:55 -0700 Subject: [PATCH] fix(functions): treat invoke 2xx as success regardless of body shape Invoke responses can be plain text, non-object JSON, or empty; classifying success only via the generated typed JSON fields falsely errored on genuine 2xx. Classify by HTTP status and decode the body leniently instead. Co-authored-by: Cursor --- internal/api/client_test.go | 62 +++++++++++++++++++++++++++++++++++++ internal/api/functions.go | 42 ++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 20983c5..bb2e221 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -638,6 +638,68 @@ func TestInvokeFunctionErrorsNormalize(t *testing.T) { require.ErrorContains(t, err, "HTTP 429: rate limited") } +func TestInvokeFunctionSuccessWithNonObjectBody(t *testing.T) { + functionID := mustProjectID(t, "22222222-2222-4222-8222-222222222222") + + testCases := []struct { + name string + contentType string + status int + body string + want map[string]any + }{ + { + name: "plain text body", + contentType: "text/plain; charset=utf-8", + status: http.StatusOK, + body: "plain text ok", + want: map[string]any{"body": "plain text ok"}, + }, + { + name: "no content type", + contentType: "", + status: http.StatusOK, + body: `{"ok":true}`, + want: map[string]any{"ok": true}, + }, + { + name: "json array body", + contentType: "application/json", + status: http.StatusOK, + body: `["a","b"]`, + want: map[string]any{"body": []any{"a", "b"}}, + }, + { + name: "empty body", + contentType: "", + status: http.StatusNoContent, + body: "", + want: map[string]any{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tc.contentType != "" { + w.Header().Set("Content-Type", tc.contentType) + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "", WithHTTPClient(server.Client())) + require.NoError(t, err) + + resp, err := client.InvokeFunction(context.Background(), functionID, FunctionInvokeInput{}) + require.NoError(t, err, "a genuine 2xx response must not be reported as an error") + require.NotNil(t, resp) + assert.Equal(t, tc.want, map[string]any(*resp)) + }) + } +} + func mustProjectID(t *testing.T, value string) uuid.UUID { t.Helper() id, err := uuid.Parse(value) diff --git a/internal/api/functions.go b/internal/api/functions.go index ca4075e..f3148c5 100644 --- a/internal/api/functions.go +++ b/internal/api/functions.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "mime/multipart" "github.com/google/uuid" @@ -112,6 +113,15 @@ func (c *Client) UpdateFunctionVisibility(ctx context.Context, projectID, functi } // InvokeFunction invokes one function by ID. +// +// The invoke endpoint passes through whatever the target function handler +// returns, so unlike other endpoints its response body isn't guaranteed to +// be a JSON object matching the generated schema (it may be plain text, +// HTML, empty, or non-object JSON). The generated WithResponse client only +// populates JSON200/JSONDefault when the body is a JSON object, so this +// calls the raw client method and classifies success/failure on the HTTP +// status code directly, decoding the body leniently rather than erroring +// out on a genuine 2xx. func (c *Client) InvokeFunction(ctx context.Context, functionID uuid.UUID, input FunctionInvokeInput) (*apiclient.FunctionInvocationResponse, error) { body := apiclient.InvokeFunctionJSONRequestBody{} if input.Payload != nil { @@ -119,14 +129,38 @@ func (c *Client) InvokeFunction(ctx context.Context, functionID uuid.UUID, input body.Payload = &payload } - resp, err := c.client.InvokeFunctionWithResponse(ctx, functionID, body) + httpResp, err := c.client.InvokeFunction(ctx, functionID, body) if err != nil { return nil, err } - if resp.JSONDefault != nil && resp.StatusCode() >= 200 && resp.StatusCode() < 300 { - return resp.JSONDefault, nil + defer func() { _ = httpResp.Body.Close() }() + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { + return decodeInvocationResponseBody(respBody), nil } - return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON429, resp.JSON503) + return nil, apiError(httpResp.StatusCode, respBody) +} + +// decodeInvocationResponseBody interprets a successful invocation's raw body +// leniently: a JSON object is used as-is, any other JSON value or plain-text +// body is wrapped under a "body" key, and an empty body yields an empty map. +func decodeInvocationResponseBody(body []byte) *apiclient.FunctionInvocationResponse { + if len(body) == 0 { + return &apiclient.FunctionInvocationResponse{} + } + var asMap apiclient.FunctionInvocationResponse + if json.Unmarshal(body, &asMap) == nil { + return &asMap + } + var asValue any + if json.Unmarshal(body, &asValue) == nil { + return &apiclient.FunctionInvocationResponse{"body": asValue} + } + return &apiclient.FunctionInvocationResponse{"body": string(body)} } // ListFunctionRuntimes returns the function runtime catalog.