From 6eefbaa265ffe2aceac8cf582c013105ab5a3d86 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Fri, 25 Sep 2026 22:16:38 +0100 Subject: [PATCH] fix(serverless): show why a deploy was refused for credit A 402 shortfall is a money object, so create app failed to decode the problem and hid whether the balance was short or the account was suspended. --- api/serverless/openapi.yaml | 13 ++++-- internal/api/serverless/client_test.go | 56 +++++++++++++++++++++++ internal/api/serverless/errors.go | 16 +++++-- internal/api/serverless/errors_test.go | 16 ++++--- internal/api/serverless/gen/client.gen.go | 7 +-- 5 files changed, 88 insertions(+), 20 deletions(-) diff --git a/api/serverless/openapi.yaml b/api/serverless/openapi.yaml index 4db8a51..b9807a9 100644 --- a/api/serverless/openapi.yaml +++ b/api/serverless/openapi.yaml @@ -5256,12 +5256,15 @@ components: items: $ref: '#/components/schemas/ProblemError' shortfall: - type: string + # allOf so the description is not a sibling of $ref (ignored in OAS 3.0). + allOf: + - $ref: '#/components/schemas/MoneyAmount' description: > - Extension member. Present on `402` credit refusals. The amount of - credit to add before retrying the same request. Units match the - platform credit display. - example: "12.50" + Extension member. Present on an `insufficient-credit` or a + `credit-suspended` problem (`402`): the amount to add to the + organization's balance before retrying. The request is unchanged by + the refusal and succeeds as sent once the credit is there. Read this + rather than parsing `detail`. ProblemError: type: object diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index 99a9a97..511603f 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -201,6 +201,62 @@ func TestCreateApp(t *testing.T) { } } +func TestCreateApp_PaymentRequiredReportsTheCreditRefusal(t *testing.T) { + t.Run("insufficient credit", func(t *testing.T) { + assertCreateAppPaymentRequired(t, "available credit cannot cover the requested capacity", `{ + "type":"https://docs.runware.ai/serverless/errors#insufficient-credit", + "title":"Payment Required", + "status":402, + "detail":"available credit cannot cover the requested capacity", + "shortfall":{"amount":"12.50","currency":"USD"} + }`) + }) + t.Run("suspended credit", func(t *testing.T) { + assertCreateAppPaymentRequired(t, "credit is suspended until the balance is funded back", `{ + "type":"https://docs.runware.ai/serverless/errors#credit-suspended", + "title":"Payment Required", + "status":402, + "detail":"credit is suspended until the balance is funded back", + "shortfall":{"amount":"12.50","currency":"USD"} + }`) + }) +} + +func assertCreateAppPaymentRequired(t *testing.T, detail, body string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusPaymentRequired) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.CreateApp(context.Background(), AppCreate{ + AppId: testAppID, + AppName: "My App", + Configuration: WorkerConfigCreate{ + GpuType: testGPUType, + MaxWorkers: 1, + IdleTtlSecs: 60, + ScalingDelaySecs: 10, + }, + }) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.Code != transport.CodeQuota { + t.Errorf("expected CodeQuota, got %v", re.Code) + } + if !strings.Contains(re.Message, detail) { + t.Errorf("missing problem detail: %q", re.Message) + } + if !strings.Contains(re.Message, "shortfall: 12.50 USD") { + t.Errorf("missing shortfall: %q", re.Message) + } +} + func TestCreateApp_Conflict(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/problem+json") diff --git a/internal/api/serverless/errors.go b/internal/api/serverless/errors.go index c1d309e..6fce557 100644 --- a/internal/api/serverless/errors.go +++ b/internal/api/serverless/errors.go @@ -29,8 +29,8 @@ func problemToError(p *gen.ProblemDetails, statusCode int) error { if extra := formatProblemErrors(p.Errors); extra != "" { msg = msg + "\n" + extra } - if p.Shortfall != nil && *p.Shortfall != "" { - msg = msg + "\n shortfall: " + *p.Shortfall + if shortfall := formatShortfall(p.Shortfall); shortfall != "" { + msg = msg + "\n shortfall: " + shortfall } } return transport.CreateRunwareError( @@ -58,6 +58,16 @@ func formatProblemErrors(errors *[]gen.ProblemError) string { return strings.Join(lines, "\n") } +func formatShortfall(amount *gen.MoneyAmount) string { + if amount == nil || amount.Amount == "" { + return "" + } + if amount.Currency == "" { + return amount.Amount + } + return amount.Amount + " " + string(amount.Currency) +} + // problemFromBody attempts to decode an RFC 9457 ProblemDetails from a response // body when the generated client did not bind a typed problem for this status. // Falls back to a status-only error when the body is empty or not a problem. @@ -72,7 +82,7 @@ func problemFromBody(body []byte, statusCode int) error { } func isProblemDetails(p gen.ProblemDetails) bool { - return p.Title != "" || p.Type != "" || p.Status != 0 || (p.Detail != nil && *p.Detail != "") || (p.Errors != nil && len(*p.Errors) > 0) || (p.Shortfall != nil && *p.Shortfall != "") + return p.Title != "" || p.Type != "" || p.Status != 0 || (p.Detail != nil && *p.Detail != "") || (p.Errors != nil && len(*p.Errors) > 0) || (p.Shortfall != nil && p.Shortfall.Amount != "") } // rawCodeForStatus maps an HTTP status to a raw error code string that diff --git a/internal/api/serverless/errors_test.go b/internal/api/serverless/errors_test.go index 98e4ff7..ad62dad 100644 --- a/internal/api/serverless/errors_test.go +++ b/internal/api/serverless/errors_test.go @@ -40,13 +40,15 @@ func TestProblemToError_IncludesValidationErrors(t *testing.T) { } func TestProblemToError_PaymentRequiredIncludesShortfall(t *testing.T) { - detail := "Organization credit cannot cover the requested capacity" - shortfall := "12.50" + detail := "available credit cannot cover the requested capacity" p := &gen.ProblemDetails{ - Title: "Payment Required", - Status: 402, - Detail: &detail, - Shortfall: &shortfall, + Title: "Payment Required", + Status: 402, + Detail: &detail, + Shortfall: &gen.MoneyAmount{ + Amount: "12.50", + Currency: gen.USD, + }, } err := problemToError(p, http.StatusPaymentRequired) @@ -63,7 +65,7 @@ func TestProblemToError_PaymentRequiredIncludesShortfall(t *testing.T) { if !strings.Contains(re.Message, detail) { t.Errorf("missing problem detail: %q", re.Message) } - if !strings.Contains(re.Message, "shortfall: 12.50") { + if !strings.Contains(re.Message, "shortfall: 12.50 USD") { t.Errorf("missing shortfall: %q", re.Message) } } diff --git a/internal/api/serverless/gen/client.gen.go b/internal/api/serverless/gen/client.gen.go index a47bab7..acbd3bf 100644 --- a/internal/api/serverless/gen/client.gen.go +++ b/internal/api/serverless/gen/client.gen.go @@ -1276,11 +1276,8 @@ type ProblemDetails struct { // RequestId Extension member. Correlation id for this request, echoed in the `X-Request-Id` response header; quote it when reporting problems. RequestId *string `json:"requestId,omitempty"` - // Shortfall Extension member. Present on `402` credit refusals. The amount of credit to add before retrying the same request. Units match the platform credit display. - // - // - // Example: 12.50 - Shortfall *string `json:"shortfall,omitempty"` + // Shortfall Extension member. Present on an `insufficient-credit` or a `credit-suspended` problem (`402`): the amount to add to the organization's balance before retrying. The request is unchanged by the refusal and succeeds as sent once the credit is there. Read this rather than parsing `detail`. + Shortfall *MoneyAmount `json:"shortfall,omitempty"` // Status HTTP status code generated for this occurrence. //