From a66a3064f96406a9004772cda73c8c3435e8d514 Mon Sep 17 00:00:00 2001 From: DEBORAH FUNMILOLA OLABOYE Date: Wed, 28 Jan 2026 16:36:45 +0100 Subject: [PATCH] [2.x] fix: Display HTTP response body when bundle upload fails (#8630) When a bundle upload to Central Portal fails, the error now displays the HTTP response body instead of just the status code. This provides more useful debugging information, as the response body typically contains detailed error messages from the server. --- .../main/scala/sbt/internal/sona/Sona.scala | 27 ++++++++++++++++++- .../sbt/internal/sona/SonaClientTest.scala | 15 +++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala index 66f62e8d2..3ef4a80aa 100644 --- a/main-actions/src/main/scala/sbt/internal/sona/Sona.scala +++ b/main-actions/src/main/scala/sbt/internal/sona/Sona.scala @@ -82,7 +82,7 @@ class SonaClient(reqTransform: Request => Request, uploadRequestTimeout: FiniteD ) ) .withRequestTimeout(uploadRequestTimeout) - http.run(reqTransform(req), Gigahorse.asString) + http.run(reqTransform(req), SonaClient.asStringWithErrorBody) } awaitWithMessage(res, "uploading...", log, totalAwaitDuration) } @@ -200,6 +200,17 @@ object SonaClient { Parser.parseFromByteBuffer(r.bodyAsByteBuffer).get def as[A1: JsonFormat]: FullResponse => A1 = asJson.andThen(Converter.fromJsonUnsafe[A1]) val asPublisherStatus: FullResponse => PublisherStatus = as[PublisherStatus] + + /** + * Response handler that returns the body as a String on success (2xx status), + * or throws a [[SonaStatusError]] with both the status code and response body on failure. + * This provides more detailed error information than [[gigahorse.StatusError]]. + */ + val asStringWithErrorBody: FullResponse => String = { response => + val body = response.bodyAsString + if (response.status >= 200 && response.status < 300) body + else throw new SonaStatusError(response.status, body) + } def oauthClient( userName: String, userToken: String, @@ -270,3 +281,17 @@ object PublishingType { case object Automatic extends PublishingType case object UserManaged extends PublishingType } + +/** + * Exception thrown when an HTTP request to the Sonatype API fails with a non-2xx status. + * Unlike [[gigahorse.StatusError]], this exception includes the response body which + * typically contains useful error details from the server. + * + * @param status the HTTP status code + * @param body the response body content + */ +class SonaStatusError(val status: Int, val body: String) + extends RuntimeException( + if (body.nonEmpty) s"Unexpected status: $status\n$body" + else s"Unexpected status: $status" + ) diff --git a/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala index 0e5fa4bf1..a376519b3 100644 --- a/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala +++ b/main-actions/src/test/scala/sbt/internal/sona/SonaClientTest.scala @@ -17,6 +17,21 @@ import scala.collection.immutable object SonaClientTest extends BasicTestSuite: + test("SonaStatusError should include both status and body in message"): + val error = new SonaStatusError(401, "Invalid token") + assert(error.status == 401) + assert(error.body == "Invalid token") + assert(error.getMessage == "Unexpected status: 401\nInvalid token") + + test("SonaStatusError should handle empty body"): + val error = new SonaStatusError(500, "") + assert(error.getMessage == "Unexpected status: 500") + + test("SonaStatusError should preserve multiline error body"): + val body = """{"error": "Unauthorized", "message": "Invalid credentials"}""" + val error = new SonaStatusError(401, body) + assert(error.getMessage.contains(body)) + private def doTest( errorsJsonText: Option[String], expectedErrorMessage: String,