From 32474cd199de1dd0a1cb325038e3b7e4c22234fa Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 22 Oct 2026 12:52:53 -0700 Subject: [PATCH 1/3] chore(deps): pin capi to the fork's integration branch The capi replace moves from fix/si-delete-204 (a3625ed) to the fork's stratos/integration branch (a4e9d75), which merges that fix with two more: - A CF 5xx that outlasts capi's retries used to come back as "giving up after N attempt(s)", with no sentinel and no envelope. It now maps through MapHTTPError like any other error response, so it keeps capi.ErrServerError and CF's v3 error envelope. - Every error MapHTTPError returns is a *capi.StatusError carrying the exact HTTP status. This change does not read it yet. With the first, the native handlers classify a persistent CF 5xx as unreachable and pass CF's envelope through. The associate-user test that pinned the old plain 502 with no reason now expects that. All seven Go modules are tidy. --- src/jetstream/go.mod | 6 ++++-- src/jetstream/go.sum | 4 ++-- .../plugins/cloudfoundry/native_associate_user_test.go | 7 +++---- 3 files changed, 9 insertions(+), 8 deletions(-) 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..f56c4327da 100644 --- a/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go +++ b/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go @@ -181,11 +181,10 @@ 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 and + // envelope: a 502 that names the endpoint unreachable. {"create server error", http.StatusOK, associateLookupOK, http.StatusServiceUnavailable, serverError, - http.StatusBadGateway, "", "", `"error":`}, + http.StatusBadGateway, "unreachable", "", "CF-ServerError"}, {"lookup forbidden", http.StatusForbidden, notAuthorized, http.StatusCreated, `{}`, http.StatusForbidden, "", "403", "CF-NotAuthorized"}, } From e3de6b9e196eb5f7ff05b69f5321b00ae1f9287a Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 22 Oct 2026 12:53:08 -0700 Subject: [PATCH 2/3] fix(jetstream): report CF's exact status for a 5xx upstreamStatusOf read the CF status from the "(status NNN)" text in capi's message, or from a sentinel that stands for one status. A CF v3 error envelope carries no "(status NNN)", and capi.ErrServerError and capi.ErrBadRequest each cover a range, so an envelope 5xx or other 4xx reported no status. The endpoint banner for a CF 5xx read "Request failed." with no code. It now reads *capi.StatusError first, which capi returns for every mapped error response. X-Stratos-Upstream-Status and the error body's upstreamStatus carry CF's code, and the banner reads "HTTP 503: The endpoint is unreachable or down.". The message and sentinel fallbacks stay for errors whose wrap chain no longer reaches the StatusError, such as the "no web process" error that wraps a bare capi.ErrNotFound. statusFromCapiError is unchanged: it picks the response status, which still answers a CF 5xx with 502. --- .../native_associate_user_test.go | 7 ++++--- .../plugins/cloudfoundry/native_errors.go | 19 ++++++++++++------- .../cloudfoundry/native_errors_test.go | 4 ++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go b/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go index f56c4327da..3cd5c2c8ec 100644 --- a/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go +++ b/src/jetstream/plugins/cloudfoundry/native_associate_user_test.go @@ -181,10 +181,11 @@ func TestAssociateUser_CFRejectionKeepsStatus(t *testing.T) { }{ {"create forbidden (non-admin caller)", http.StatusOK, associateLookupOK, http.StatusForbidden, notAuthorized, http.StatusForbidden, "", "403", "CF-NotAuthorized"}, - // A 5xx that outlasts capi's retries keeps its sentinel and - // envelope: a 502 that names the endpoint unreachable. + // 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, "unreachable", "", "CF-ServerError"}, + 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}, From a536ae63e472c74ca8a18017164be90ff2cd89bd Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Thu, 22 Oct 2026 12:53:08 -0700 Subject: [PATCH 3/3] docs(changelog): note the CF 5xx status fix --- changelog.d/0025-capi-5xx-status.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/0025-capi-5xx-status.md 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.