This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git


The following commit(s) were added to refs/heads/main by this push:
     new 02a70d2f Report malformed response headers as a gRPC status (#870)
02a70d2f is described below

commit 02a70d2f064e808c2bea4916da72d6f585a661b9
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 1 11:21:24 2026 +0100

    Report malformed response headers as a gRPC status (#870)
    
    Motivation:
    Two spots in the pekko-http client turned a malformed response from the peer
    into a raw JVM exception rather than a `StatusRuntimeException`, so a caller
    matching on gRPC status saw something it could not handle.
    
    `Status.fromCodeValue(statusCode.toInt)` threw `NumberFormatException` for a
    server sending `grpc-status: abc`. Building the response metadata
    base64-decodes every `-bin` header, so a malformed one threw
    `IllegalArgumentException` - and `mapToStatusException` builds that 
metadata for
    every error response, so an invalid binary header replaced the error 
actually
    being reported with an exception about itself.
    
    Both are reachable from any peer. The netty backend rejects these at the
    transport layer, so this was pekko-http only.
    
    Modification:
    - A non-numeric `grpc-status` is reported as `INTERNAL`, quoting the 
offending
      value. `parseResponseStatus` already matches on `Some("0")`, so such a
      response was on the failure path regardless; it now fails with a status.
    - Metadata construction falls back to empty metadata if a header cannot be
      represented, so the status and message still reach the caller.
    
    Result:
    A broken or hostile peer produces a `StatusRuntimeException` like any other
    failure, instead of an exception the caller is not expecting.
    
    Tests:
    - 2 cases in `PekkoHttpClientUtilsSpec`: a non-numeric `grpc-status`, and a
      malformed `-bin` header alongside a real error that must still be reported
    - Confirmed both guards bite: restoring `toInt` fails the first, removing 
the
      metadata fallback fails the second
    - sbt "runtime/testOnly ...PekkoHttpClientUtilsSpec" - 8 passed
    - sbt "runtime/mimaReportBinaryIssues" - passed
    - sbt scalafmtAll scalafmtSbt - applied
    - sbt "runtime/test" - not run locally, left to CI
    
    Note: `HeaderMetadataImpl.getBinary` and `asList` still throw on a malformed
    binary header when a user calls them directly on a successful response. 
That is
    left as is - an explicit accessor call is a different contract from the 
error
    path, where the exception displaces an unrelated failure.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../pekko/grpc/internal/PekkoHttpClientUtils.scala | 33 ++++++++++++++++++++--
 .../grpc/internal/PekkoHttpClientUtilsSpec.scala   | 32 +++++++++++++++++++++
 2 files changed, 63 insertions(+), 2 deletions(-)

diff --git 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
index f513f72d..a5c7eb65 100644
--- 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
+++ 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtils.scala
@@ -42,6 +42,7 @@ import scala.concurrent.{ ExecutionContext, Future, Promise }
 import scala.concurrent.duration.DurationLong
 import scala.jdk.FutureConverters._
 import scala.util.{ Failure, Success }
+import scala.util.control.NonFatal
 
 /**
  * INTERNAL API
@@ -400,7 +401,7 @@ object PekkoHttpClientUtils {
 
   private def mapToStatusException(response: HttpResponse, trailers: 
Seq[HttpHeader]): StatusRuntimeException = {
     val allHeaders = response.headers ++ trailers
-    val metadata: io.grpc.Metadata = new MetadataImpl(new 
HeaderMetadataImpl(allHeaders).asList).toGoogleGrpcMetadata()
+    val metadata: io.grpc.Metadata = metadataOf(allHeaders)
     allHeaders.find(_.name == "grpc-status").map(_.value) match {
       case None =>
         new StatusRuntimeException(mapHttpStatus(response).withDescription("No 
grpc-status found"), metadata)
@@ -409,10 +410,38 @@ object PekkoHttpClientUtils {
         // before it reaches the caller. The server side encodes it in 
`Status-Message`.
         val description =
           allHeaders.find(_.name == "grpc-message").map(h => 
PercentEncoding.Decoder.decode(h.value))
-        new 
StatusRuntimeException(Status.fromCodeValue(statusCode.toInt).withDescription(description.orNull),
 metadata)
+        statusCodeOf(statusCode) match {
+          case Some(code) =>
+            new 
StatusRuntimeException(Status.fromCodeValue(code).withDescription(description.orNull),
 metadata)
+          case None =>
+            // a peer that sends a non-numeric grpc-status is broken, but that 
is a protocol
+            // error to report, not an exception to throw at the caller
+            new StatusRuntimeException(
+              Status.INTERNAL.withDescription(s"Invalid grpc-status 
[$statusCode] in response"),
+              metadata)
+        }
     }
   }
 
+  /**
+   * The response metadata, or empty metadata if a header cannot be 
represented.
+   *
+   * Binary headers are base64-decoded here, which throws on a malformed 
value. That is
+   * reachable from any peer, and it happens while building the metadata for 
an error that is
+   * already being reported, so it must not replace that error with an 
exception of its own.
+   */
+  private def metadataOf(allHeaders: Seq[HttpHeader]): io.grpc.Metadata =
+    try new MetadataImpl(new 
HeaderMetadataImpl(allHeaders).asList).toGoogleGrpcMetadata()
+    catch {
+      case NonFatal(_) => new io.grpc.Metadata()
+    }
+
+  private def statusCodeOf(value: String): Option[Int] =
+    try Some(value.toInt)
+    catch {
+      case _: NumberFormatException => None
+    }
+
   /**
    * See 
https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
    */
diff --git 
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
index b3dfe42c..c5e5486f 100644
--- 
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
+++ 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/PekkoHttpClientUtilsSpec.scala
@@ -131,6 +131,38 @@ class PekkoHttpClientUtilsSpec extends 
TestKit(ActorSystem()) with AnyWordSpecLi
       failure.asInstanceOf[StatusRuntimeException].getStatus.getDescription 
should be("broken %ZZ escape")
     }
 
+    "report a non-numeric grpc-status as INTERNAL rather than throwing" in {
+      // a broken peer must not surface as a NumberFormatException to the 
caller
+      val responseHeaders = RawHeader("grpc-status", "abc") :: Nil
+      val response =
+        Future.successful(HttpResponse(OK, responseHeaders, 
Strict(GrpcProtocolNative.contentType, ByteString.empty)))
+
+      val failure = PekkoHttpClientUtils.responseToSource(response, 
null).run().failed.futureValue
+
+      failure shouldBe a[StatusRuntimeException]
+      val status = failure.asInstanceOf[StatusRuntimeException].getStatus
+      status.getCode should be(Status.Code.INTERNAL)
+      status.getDescription should include("abc")
+    }
+
+    "still report the gRPC status when a binary header is not valid base64" in 
{
+      // building the metadata base64-decodes `-bin` headers, which throws on 
a malformed value.
+      // That must not replace the error already being reported with an 
exception of its own.
+      val responseHeaders = RawHeader("grpc-status", "9") ::
+        RawHeader("grpc-message", "the real failure") ::
+        RawHeader("custom-key-bin", "!!!not valid base64!!!") ::
+        Nil
+      val response =
+        Future.successful(HttpResponse(OK, responseHeaders, 
Strict(GrpcProtocolNative.contentType, ByteString.empty)))
+
+      val failure = PekkoHttpClientUtils.responseToSource(response, 
null).run().failed.futureValue
+
+      failure shouldBe a[StatusRuntimeException]
+      val status = failure.asInstanceOf[StatusRuntimeException].getStatus
+      status.getCode should be(Status.Code.FAILED_PRECONDITION)
+      status.getDescription should be("the real failure")
+    }
+
     "map a strict 200 response with non-0 gRPC error code with a trailer to a 
failed stream with trailer metadata" in {
       val responseHeaders = List(RawHeader("grpc-status", "9"))
       val responseTrailers = Trailer(


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to