Skip to content
Merged
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
13 changes: 8 additions & 5 deletions api/serverless/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 13 additions & 3 deletions internal/api/serverless/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions internal/api/serverless/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
7 changes: 2 additions & 5 deletions internal/api/serverless/gen/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading