Repository: http4s/http4s
Pull request #6776 (2022-10-28 20:10:45)

More efficient string building from `Headers`
```
[info] Benchmark                            (size)  Mode    Cnt      Score      Error  Units
[info] currentMkString                        10     avgt    5      653.514 ±   37.092  ns/op
[info] currentMkString                        100    avgt    5     6576.612 ±  907.011  ns/op
[info] currentMkString                        1000   avgt    5    58623.540 ± 1841.507  ns/op
[info] newMkString                            10     avgt    5      422.176 ±   12.110  ns/op
[info] newMkString                            100    avgt    5     4197.781 ±   15.698  ns/op
[info] newMkString                            1000   avgt    5    34725.574 ±  234.841  ns/op
```
This adds new methods for `Headers` for string building. Also, this brings 55-70% performance improvement and definitely should reduce memory consumption quite much.

```scala
@Benchmark
def currentMkString: String =
  headers.redactSensitive().headers.mkString("Headers(", ", ", ")")

@Benchmark
def newMkString: String =
  headers.mkString("Headers(", ", ", ")")
```


Patch:
diff --git a/client/shared/src/main/scala/org/http4s/client/middleware/Retry.scala b/client/shared/src/main/scala/org/http4s/client/middleware/Retry.scala
index 27d940f437..384c86fc64 100644
--- a/client/shared/src/main/scala/org/http4s/client/middleware/Retry.scala
+++ b/client/shared/src/main/scala/org/http4s/client/middleware/Retry.scala
@@ -58,7 +58,7 @@ object Retry {
       logRetries: Boolean = true,
   )(client: Client[F])(implicit F: Temporal[F]): Client[F] = {
     def showRequest(request: Request[F], redactWhen: CIString => Boolean): String = {
-      val headers = request.headers.redactSensitive(redactWhen).headers.mkString(",")
+      val headers = request.headers.mkString(",", redactWhen)
       val uri = request.uri.renderString
       val method = request.method
       s"method=$method uri=$uri headers=$headers"
diff --git a/core/shared/src/main/scala/org/http4s/Header.scala b/core/shared/src/main/scala/org/http4s/Header.scala
index 96e2851e4e..ffdd6a2d02 100644
--- a/core/shared/src/main/scala/org/http4s/Header.scala
+++ b/core/shared/src/main/scala/org/http4s/Header.scala
@@ -55,7 +55,7 @@ trait Header[A, T <: Header.Type] {
 
 object Header {
   final case class Raw(name: CIString, value: String) {
-    override def toString: String = s"${name}: ${value}"
+    override def toString: String = Raw.toString(name, value)
 
     /** True if [[name]] is a valid field-name per RFC7230.  Where it
       * is not, the header may be dropped by the backend.
@@ -70,6 +70,9 @@ object Header {
   }
 
   object Raw {
+    @inline private[http4s] def toString(name: CIString, value: String): String =
+      s"${name.toString}: $value"
+
     implicit lazy val catsInstancesForHttp4sHeaderRaw
         : Order[Raw] with Hash[Raw] with Show[Raw] with Renderer[Raw] = new Order[Raw]
       with Hash[Raw]
diff --git a/core/shared/src/main/scala/org/http4s/Headers.scala b/core/shared/src/main/scala/org/http4s/Headers.scala
index 301cde93ab..b2f0c4e31d 100644
--- a/core/shared/src/main/scala/org/http4s/Headers.scala
+++ b/core/shared/src/main/scala/org/http4s/Headers.scala
@@ -153,6 +153,54 @@ final class Headers(val headers: List[Header.Raw]) extends AnyVal {
 
   def foreach(f: Header.Raw => Unit): Unit = headers.foreach(f)
 
+  /** Creates a string representation for a list of headers
+    * and redacts sensitive headers' values.
+    *
+    *  @param start       the starting string
+    *  @param separator   the separator string
+    *  @param end         the ending string
+    *  @param redactWhen  the function for filtering out header values of sensitive headers
+    *  @return            a string representation of the list of headers.
+    *                      The resulting string begins with the string `start`
+    *                      and ends with the string `end`. Inside, the string
+    *                      representations of all headers are separated
+    *                      by the string `separator`. Sensitive headers' values
+    *                      are redacted with the `redactWhen` function.
+    */
+  def mkString(
+      start: String,
+      separator: String,
+      end: String,
+      redactWhen: CIString => Boolean,
+  ): String =
+    headers.iterator
+      .map {
+        case h if redactWhen(h.name) => Header.Raw.toString(h.name, "<REDACTED>")
+        case h => Header.Raw.toString(h.name, h.value)
+      }
+      .mkString(start, separator, end)
+
+  /** Creates a string representation for a list of headers
+    * and redacts sensitive headers' values.
+    *
+    *  @param separator   the separator string
+    *  @param redactWhen  the function for filtering out header values of sensitive headers
+    *  @return            a string representation of the list of headers.
+    *                      The resulting string is constructed from the string representations
+    *                      of all headers separated by the string `separator`. Sensitive headers'
+    *                      values are redacted with the `redactWhen` function.
+    */
+  def mkString(
+      separator: String,
+      redactWhen: CIString => Boolean,
+  ): String =
+    headers.iterator
+      .map {
+        case h if redactWhen(h.name) => Header.Raw.toString(h.name, "<REDACTED>")
+        case h => Header.Raw.toString(h.name, h.value)
+      }
+      .mkString(separator)
+
   override def toString: String =
     this.show
 }
diff --git a/core/shared/src/main/scala/org/http4s/internal/Logger.scala b/core/shared/src/main/scala/org/http4s/internal/Logger.scala
index 20947a8853..4267382a61 100644
--- a/core/shared/src/main/scala/org/http4s/internal/Logger.scala
+++ b/core/shared/src/main/scala/org/http4s/internal/Logger.scala
@@ -35,7 +35,7 @@ object Logger {
       redactHeadersWhen: CIString => Boolean = Headers.SensitiveHeaders.contains,
   ): String =
     if (logHeaders)
-      message.headers.redactSensitive(redactHeadersWhen).headers.mkString("Headers(", ", ", ")")
+      message.headers.mkString("Headers(", ", ", ")", redactHeadersWhen)
     else ""
 
   def defaultLogBody[F[_]: Concurrent](


Test patch:
diff --git a/tests/shared/src/test/scala/org/http4s/HeadersSpec.scala b/tests/shared/src/test/scala/org/http4s/HeadersSpec.scala
index 42d9080aef..d1d94d5dab 100644
--- a/tests/shared/src/test/scala/org/http4s/HeadersSpec.scala
+++ b/tests/shared/src/test/scala/org/http4s/HeadersSpec.scala
@@ -114,6 +114,28 @@ class HeadersSpec extends Http4sSuite {
     assertEquals(Headers.apply(headers), headers)
   }
 
+  test("Headers#mkString") {
+    val h1 = Headers("Header-One" -> "value one", "Header-Two" -> "value two")
+    val h2 = Headers(
+      "Header-One" -> "value one",
+      "Header-Two" -> "value two",
+      "Header-Three" -> "value three",
+    )
+    val expectedString1 = "Headers(Header-One: value one, Header-Two: value two)"
+    val expectedString2 =
+      "Headers(Header-One: value one, Header-Two: value two, Header-Three: <REDACTED>)"
+    val expectedString3 = "Header-One: value one, Header-Two: value two"
+    val expectedString4 = "Header-One: value one, Header-Two: value two, Header-Three: <REDACTED>"
+
+    assertEquals(
+      h1.mkString("Headers(", ", ", ")", Headers.SensitiveHeaders.contains),
+      expectedString1,
+    )
+    assertEquals(h2.mkString("Headers(", ", ", ")", _.toString == "Header-Three"), expectedString2)
+    assertEquals(h1.mkString(", ", Headers.SensitiveHeaders.contains), expectedString3)
+    assertEquals(h2.mkString(", ", _.toString == "Header-Three"), expectedString4)
+  }
+
   checkAll("Monoid[Headers]", MonoidTests[Headers].monoid)
   checkAll("Order[Headers]", OrderTests[Headers].order)
 }