diff --git a/changelog.d/0025-capi-5xx-status.md b/changelog.d/0025-capi-5xx-status.md new file mode 100644 index 0000000000..fdd1321934 --- /dev/null +++ b/changelog.d/0025-capi-5xx-status.md @@ -0,0 +1,6 @@ +[BugFixes] +- When a Cloud Foundry endpoint keeps answering with a server error + (5xx), the endpoint banner now says the endpoint is unreachable or + down and shows Cloud Foundry's status code, for example "HTTP 503: The + endpoint is unreachable or down.". It used to read "Request failed." + with no code. diff --git a/src/jetstream/go.mod b/src/jetstream/go.mod index b5e436c3ec..10cff06a13 100644 --- a/src/jetstream/go.mod +++ b/src/jetstream/go.mod @@ -245,5 +245,7 @@ require ( ) // User-provided service instance deletes answer 204 with no Location; -// fivetwenty-io/capi#16 accepts that. Drop this once a release carries it. -replace github.com/fivetwenty-io/capi/v3 => github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261020094728-a3625edf6e94 +// fivetwenty-io/capi#16 accepts that. A CF 5xx that outlasts capi's +// retries keeps its sentinel, and every mapped error carries its HTTP +// status (capi.StatusError). Drop this once a release carries both. +replace github.com/fivetwenty-io/capi/v3 => github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261022071155-a4e9d752d752 diff --git a/src/jetstream/go.sum b/src/jetstream/go.sum index e2cba750b1..6040741c05 100644 --- a/src/jetstream/go.sum +++ b/src/jetstream/go.sum @@ -460,8 +460,8 @@ github.com/ncruces/go-sqlite3-wasm/v6 v6.3.35304 h1:dBSZlcEFdtBMvNRg34y50mConBPO github.com/ncruces/go-sqlite3-wasm/v6 v6.3.35304/go.mod h1:YvoJzbJpX6phd3BGdtiXu2NuD5RX6G8zsUdzt47GgOY= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= -github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261020094728-a3625edf6e94 h1:khj6P4JmNsiW1SSkUJMAioYePKWKXQVfJnT772ajmV4= -github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261020094728-a3625edf6e94/go.mod h1:jcpCj9Pz+dacd9bVg65lQzQwgAtqAlGz2e6Bt9DKCtE= +github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261022071155-a4e9d752d752 h1:Bg5Hdtrj56wP6XqNopXogJXCW3Y/uqL9O7Nz9LrEK0Q= +github.com/norman-abramovitz/fw-capi/v3 v3.216.7-0.20261022071155-a4e9d752d752/go.mod h1:jcpCj9Pz+dacd9bVg65lQzQwgAtqAlGz2e6Bt9DKCtE= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= diff --git a/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go b/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go index 7519b48e16..3cd5c2c8ec 100644 --- a/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go +++ b/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go @@ -181,11 +181,11 @@ func TestAssociateUser_CFRejectionKeepsStatus(t *testing.T) { }{ {"create forbidden (non-admin caller)", http.StatusOK, associateLookupOK, http.StatusForbidden, notAuthorized, http.StatusForbidden, "", "403", "CF-NotAuthorized"}, - // capi retries a 5xx and then gives up with a transport error - // that carries neither the sentinel nor the envelope; it stays a - // plain 502. + // A 5xx that outlasts capi's retries keeps its sentinel, envelope + // and status: a 502 that names the endpoint unreachable and + // reports CF's 503. {"create server error", http.StatusOK, associateLookupOK, http.StatusServiceUnavailable, serverError, - http.StatusBadGateway, "", "", `"error":`}, + http.StatusBadGateway, "unreachable", "503", "CF-ServerError"}, {"lookup forbidden", http.StatusForbidden, notAuthorized, http.StatusCreated, `{}`, http.StatusForbidden, "", "403", "CF-NotAuthorized"}, } diff --git a/src/jetstream/plugins/cloudfoundry/native_errors.go b/src/jetstream/plugins/cloudfoundry/native_errors.go index aac2a75d8e..a23b3376e4 100644 --- a/src/jetstream/plugins/cloudfoundry/native_errors.go +++ b/src/jetstream/plugins/cloudfoundry/native_errors.go @@ -17,8 +17,8 @@ import ( ) // capiStatusRe matches the "(status NNN)" fragment capi.MapHTTPError embeds in -// its error messages. capi sentinel errors don't expose the numeric HTTP -// status via a field, so this is the only way to recover it for display. +// its non-envelope error messages. It recovers the status from an error +// whose wrap chain no longer reaches the *capi.StatusError. var capiStatusRe = regexp.MustCompile(`\(status (\d{3})\)`) // gorouterRouteMissingRe matches the body gorouter returns when the platform's @@ -175,11 +175,12 @@ func diagFailKind(parent context.Context, err error) string { // upstreamStatusOf extracts the upstream HTTP status when the error carries // one. The UAA token-refresh path provides it directly via -// api.ErrHTTPRequest.Status; capi CF-call errors embed it as "(status NNN)" in -// the message (parsed best-effort). When CF answered with a v3 error -// envelope there is no "(status NNN)", so the status comes from the capi -// sentinel when that names exactly one (see exactCapiStatus). Returns 0 when -// no status is recoverable, in which case it is omitted from the response. +// api.ErrHTTPRequest.Status, and capi CF-call errors via *capi.StatusError, +// envelope responses included. An error that lost the wrap chain falls +// back to the "(status NNN)" capi embeds in non-envelope messages (parsed +// best-effort), then to the capi sentinel when that names exactly one (see +// exactCapiStatus). Returns 0 when no status is recoverable, in which case +// it is omitted from the response. func upstreamStatusOf(err error) int { if err == nil { return 0 @@ -188,6 +189,10 @@ func upstreamStatusOf(err error) int { if errors.As(err, &httpReq) { return httpReq.Status } + var statusErr *capi.StatusError + if errors.As(err, &statusErr) { + return statusErr.StatusCode + } if m := capiStatusRe.FindStringSubmatch(err.Error()); m != nil { if n, convErr := strconv.Atoi(m[1]); convErr == nil { return n diff --git a/src/jetstream/plugins/cloudfoundry/native_errors_test.go b/src/jetstream/plugins/cloudfoundry/native_errors_test.go index 7564603a1e..54f5feaa82 100644 --- a/src/jetstream/plugins/cloudfoundry/native_errors_test.go +++ b/src/jetstream/plugins/cloudfoundry/native_errors_test.go @@ -103,6 +103,10 @@ func TestUpstreamStatusOf(t *testing.T) { {"capi envelope 429", cfEnvelopeErr(capi.ErrRateLimited, 10013, "CF-RateLimitExceeded"), 429}, {"capi envelope wrapped", fmt.Errorf("listing apps: %w", cfEnvelopeErr(capi.ErrNotFound, 10010, "CF-ResourceNotFound")), 404}, {"capi envelope from MapHTTPError", capi.MapHTTPError(422, []byte(`{"errors":[{"code":10008,"title":"CF-UnprocessableEntity","detail":"x"}]}`)), 422}, + // MapHTTPError's *capi.StatusError keeps the exact status even + // where the sentinel covers a range. + {"capi envelope 400 from MapHTTPError", capi.MapHTTPError(400, []byte(`{"errors":[{"code":10005,"title":"CF-BadQueryParameter","detail":"x"}]}`)), 400}, + {"capi envelope 503 from MapHTTPError", fmt.Errorf("listing apps: %w", capi.MapHTTPError(503, []byte(`{"errors":[{"code":10001,"title":"CF-ServerError","detail":"x"}]}`))), 503}, // ErrBadRequest and ErrServerError each cover a range of statuses; // without the exact code there is nothing to report. {"capi envelope other 4xx", cfEnvelopeErr(capi.ErrBadRequest, 10005, "CF-BadQueryParameter"), 0},