You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pekko.apache.org by fa...@apache.org on 2025/05/28 08:36:46 UTC

(pekko-grpc) branch main updated: pre-announce trailers in server streaming calls (#456)

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

fanningpj 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 3c51f0c0 pre-announce trailers in server streaming calls (#456)
3c51f0c0 is described below

commit 3c51f0c009f6b07470e5c1ecb06951e73b749572
Author: navid <na...@moia.io>
AuthorDate: Wed May 28 10:36:41 2025 +0200

    pre-announce trailers in server streaming calls (#456)
    
    * preannounce trailers in server streaming calls
    
    * use ArraySeq.unsafeWrapArray
    
    * add test for checking `trailer` is part of metadata, make `trailer` header private to scaladsl, add comment linking to rfc
    
    * make trailer visible to `grpc` package, make test simpler, add comment on top of test
    
    * extend comment on top of response fn
    
    * move test into runtime module
    
    * copy paste header from somewhere else
    
    * generate header with sbt instead
---
 .../apache/pekko/grpc/scaladsl/PowerApiSpec.scala  |  1 -
 .../pekko/grpc/internal/GrpcResponseHelpers.scala  |  6 ++-
 .../pekko/grpc/scaladsl/headers/headers.scala      | 27 +++++++++++
 .../grpc/internal/GrpcResponseHelpersSpec.scala    | 53 ++++++++++++++++++++++
 4 files changed, 85 insertions(+), 2 deletions(-)

diff --git a/interop-tests/src/test/scala/org/apache/pekko/grpc/scaladsl/PowerApiSpec.scala b/interop-tests/src/test/scala/org/apache/pekko/grpc/scaladsl/PowerApiSpec.scala
index 9a38b1bf..c1560c4a 100644
--- a/interop-tests/src/test/scala/org/apache/pekko/grpc/scaladsl/PowerApiSpec.scala
+++ b/interop-tests/src/test/scala/org/apache/pekko/grpc/scaladsl/PowerApiSpec.scala
@@ -183,5 +183,4 @@ abstract class PowerApiSpec(backend: String)
       metadataServer.terminate(3.seconds)
     }
   }
-
 }
diff --git a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpers.scala b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpers.scala
index 848fc629..58fef5a6 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpers.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpers.scala
@@ -97,7 +97,11 @@ object GrpcResponseHelpers {
 
   private def response(entity: Source[ChunkStreamPart, NotUsed])(implicit writer: GrpcProtocolWriter) = {
     HttpResponse(
-      headers = immutable.Seq(headers.`Message-Encoding`(writer.messageEncoding.name)),
+      headers = immutable.Seq(
+        headers.`Message-Encoding`(writer.messageEncoding.name),
+        // Pre-announcing trailers: See https://www.rfc-editor.org/rfc/rfc7230 #Section 4.4
+        headers.`Trailer`(headers.`Status`.name)
+      ),
       entity = HttpEntity.Chunked(writer.contentType, entity))
   }
 
diff --git a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/headers.scala b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/headers.scala
index 86c09741..d607b943 100644
--- a/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/headers.scala
+++ b/runtime/src/main/scala/org/apache/pekko/grpc/scaladsl/headers/headers.scala
@@ -19,6 +19,7 @@ import pekko.http.scaladsl.model.HttpHeader
 import pekko.http.scaladsl.model.headers.{ ModeledCustomHeader, ModeledCustomHeaderCompanion }
 import pekko.http.javadsl.{ model => jm }
 
+import scala.collection.compat.immutable.ArraySeq
 import scala.collection.immutable
 import scala.util.Try
 
@@ -111,3 +112,29 @@ object `Status-Message` extends ModeledCustomHeaderCompanion[`Status-Message`] {
   def findIn(headers: immutable.Seq[HttpHeader]): Option[String] =
     headers.collectFirst { case h if h.is(name) => h.value() }
 }
+
+private[grpc] final class `Trailer` private (values: immutable.Seq[String]) extends ModeledCustomHeader[`Trailer`] {
+
+  override def companion: ModeledCustomHeaderCompanion[`Trailer`] = `Trailer`
+
+  override def value(): String = values.mkString(", ")
+
+  override def renderInRequests(): Boolean = true
+
+  override def renderInResponses(): Boolean = true
+}
+
+private[grpc] object `Trailer` extends ModeledCustomHeaderCompanion[`Trailer`] {
+  def apply(values: immutable.Seq[String]): `Trailer` = new `Trailer`(values.map(_.trim))
+
+  override val name = "trailer"
+
+  override val lowercaseName: String = super.lowercaseName
+
+  override def parse(value: String): Try[`Trailer`] = Try(`Trailer`(ArraySeq.unsafeWrapArray(value.split(','))))
+
+  def findIn(headers: immutable.Seq[HttpHeader]): Option[immutable.Seq[String]] =
+    headers.collectFirst {
+      case header if header.is(name) => ArraySeq.unsafeWrapArray(header.value().split(',').map(_.trim))
+    }
+}
diff --git a/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpersSpec.scala b/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpersSpec.scala
new file mode 100644
index 00000000..e45b3a50
--- /dev/null
+++ b/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcResponseHelpersSpec.scala
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.grpc.internal
+
+import grpc.reflection.v1alpha.reflection.{ ServerReflection, ServerReflectionResponse }
+import io.grpc.Status
+import org.apache.pekko.actor.ActorSystem
+import org.apache.pekko.grpc.GrpcProtocol
+import org.apache.pekko.grpc.scaladsl.{ headers, ScalapbProtobufSerializer }
+import org.apache.pekko.stream.scaladsl.Source
+import org.apache.pekko.testkit.TestKit
+import org.scalatest.matchers.should.Matchers._
+import org.scalatest.wordspec.AnyWordSpecLike
+
+final class GrpcResponseHelpersSpec extends TestKit(ActorSystem("GrpcResponseHelpersSpec")) with AnyWordSpecLike {
+  "GrpcResponseHelpers" should {
+
+    /**
+     * Pre-announcing trailers: See https://www.rfc-editor.org/rfc/rfc7230 #Section 4.4
+     * Certain reverse proxies (e.g. tyk) will not behave correctly if trailers are not pre-announced and
+     * errors are not reported with trailer-only optimization.
+     */
+    "pre-announce trailers in the headers" in {
+      implicit val serializer: ScalapbProtobufSerializer[ServerReflectionResponse] =
+        ServerReflection.Serializers.ServerReflectionResponseSerializer
+      implicit val writer: GrpcProtocol.GrpcProtocolWriter = GrpcProtocolNative.newWriter(Identity)
+      val response =
+        GrpcResponseHelpers(
+          e = Source.single(ServerReflectionResponse()),
+          trail = Source.single(GrpcEntityHelpers.trailer(Status.OK))
+        )
+
+      val preAnnouncedTrailers = headers.`Trailer`.findIn(response.headers)
+
+      preAnnouncedTrailers shouldBe Some(Seq(headers.`Status`.name))
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pekko.apache.org
For additional commands, e-mail: commits-help@pekko.apache.org