Repository: http4s/http4s
Pull request #5101 (2021-08-20 13:58:39)

Add fetch options+timeout to FetchClient.
Add timeout capability to `FetchClient` and allow passing all the options present in `RequestInit`.

armanbilge: Also, since these options are per-request instead of being used for the `FetchClient` constructor they should be provided as `attributes` on the request itself.
rpiaggio: >Thanks for the PR, glad this module is getting attention!

Thank you for implementing this.

>aren't many of these "options" simply headers that should be provided in the request? 

Mostly they control how the Fetch client will interact with the browser. See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters

I now changed the parameters to `Option` and implemented the Builder pattern. Note that this is a breaking change for people that were using `FetchClient` directly.

>Also, since these options are per-request instead of being used for the FetchClient constructor they should be provided as attributes on the request itself.

I'm not so sure about this. In JS there's really no client instance, so it's up to us whether to interpret `Fetch.fetch` parameters as belonging to the client or the request. It seems to me it's more convenient for them to be client properties, insofar as `requestTimeout` is also a client property and not a request one. For example, we now have a 
``` scala
private val client = FetchClientBuilder[F]
    .withCredentials(RequestCredentials.include)
    .resource
```
that we use for different requests.

armanbilge: Thanks for iterating on my feedback!

> In JS there's really no client instance, so it's up to us whether to interpret Fetch.fetch parameters as belonging to the client or the request.

Ok, this is a fair point. How about this: what if we define these as per-request attributes, and then in the fetch client builder they can also provide some default attributes? Also:

* `requestTimeout`: ✅ client parameter, this seems standard for other http4s clients
* `referrer`: [this is a header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer)
* `referrerPolicy`: [this is a header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy)

armanbilge: [Reading about the `integrity` parameter](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity): 

> Subresource Integrity (SRI) is a security feature that enables browsers to verify that resources they fetch (for example, from a CDN) are delivered without unexpected manipulation. It works by allowing you to provide a cryptographic hash that a fetched resource must match.

This is _definitely_ a per-request option.
rpiaggio: Ok on all comments. How does this look?

* `requestTimeout`: client (I think it would be trivial to support at request level too, but that may be inconsistent with other clients).
* `cache`: client & request.
* `credentials`: client & request.
* `integrity`: request.
* `keepalive`:  client & request.
* `mode`:  client & request.
* `redirect`:  client & request.
* `referrer`:  client & request.
* `referrerPolicy`:  client & request.

Although there are indeed `referrer` and `referrerPolicy` headers, they don't behave the same way when passed as parameters as when passed as headers. As far as I can tell:
* `referrer` allows to set a specific URL (which would be the same as setting the header), but it also allows other behaviors. Notably, passing `"client"` derives the `Referer` header from the current client URL.
* `referrerPolicy` seems to be a header usually used in responses to indicate when the browser will send the `Referer` header in future requests. I can't think of a use as a request header. In this case, the parameter is used to indicate when the `Referer` header will be used in the Fetch request, not to set the header.

https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer


isomarcte: @rpiaggio @armanbilge there are some request headers which are forbidden to be set in the `init.headers` section, `Referer` for example is one. 

https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
armanbilge: > there are some request headers which are forbidden to be set in the init.headers section, Referer for example is one.

Thanks, that's a good point. I think the idiomatic thing to do here is let users set that as a header, and add logic in the client to extract it out.
armanbilge: @rpiaggio 👍 to your list. The only other one I think should be request-only is `keepalive`, this seems like something that should be used conservatively.

> The keepalive option indicates that the request may “outlive” the webpage that initiated it.

Also setting it for the entire client may cause problems:
> We can’t send megabytes: the body limit for keepalive requests is 64KB ... This limit applies to all keepalive requests together. In other words, we can perform multiple keepalive requests in parallel, but the sum of their body lengths should not exceed 64KB.

Via https://javascript.info/fetch-api#keepalive.

Regarding `Referer`, thanks for explaining, lots of subtleties to those I missed. 👍 and maybe we should look for the header and extract its value into `init`, if the user specifies it that way?
rpiaggio: >maybe we should look for the header and extract its value into init, if the user specifies it that way?

I'm on the fence about this. What would happen if it's both on the headers and in options?
armanbilge: > What would happen if it's both on the headers and in options?

Good question. By options, I assume you mean the per-request attributes? Request-level config should always have priority. I think the priority should be (request attribute) > (request header) > (client config). The thinking is, a user should be able to set the `Referer` header idiomatically (as it would for any client) without needing to know that it's actually using the fetch client.

On the flipside, suppose we only set `Referer` either by client parameter or per-request attribute. What happens when there is a `Referer` header provided in the request? Silently ignore? Raise an error because it's forbidden?
rpiaggio: > By options, I assume you mean the per-request attributes?

Yes.


>Request-level config should always have priority.

Agreed. That's how I implemented it.

> a user should be able to set the Referer header idiomatically (as it would for any client) without needing to know that it's actually using the fetch client.

Agreed that this is desirable.

>On the flipside, suppose we only set Referer either by client parameter or per-request attribute. What happens when there is a Referer header provided in the request? Silently ignore? Raise an error because it's forbidden?

In this case Fetch will do whatever it does when it receives a Referer header. Probably throw an exception.

>I think the priority should be (request attribute) > (request header) > (client config).

Hmm.. I implemented an override logic between the client and the request parameters. Prioritizing as you suggest would interfere with that logic, with the header getting in the way of the override. Would it be OK if it was `(request header) > (request attribute) > (client config)`; or even `(request attribute) > (client config) > (request header)`? Alternatively, we could raise an error if it's present both in headers and request options.
armanbilge: @rpiaggio can't we just do option-chaining like this? (pseudocode)

```scala
request.attributes.lookup(RefererKey)
  .orElse(request.headers.get(`Referer`))
  .orElse(client.attributes.lookup(RefererKey))
```
rpiaggio: > @rpiaggio can't we just do option-chaining like this? (pseudocode)

Yes, we definitely can add exceptional logic to handle this particular option. IMHO, header should have the least priority. We are setting other client and/or request option after all. But I'll go ahead and implement it as middle priority.


rpiaggio: It dawned on me that we should also have a type for the `referrer` option, instead of just having a `String` as exposed in the facade. So I also added `FetchReferrer`.
rpiaggio: An unrelated (`TomcatServerSuite`) seems to have failed in one of the setups. Is this sporadic? Can someone with enough privileges re-run its jobs?
armanbilge: > An unrelated (TomcatServerSuite) seems to have failed in one of the setups. Is this sporadic? Can someone with enough privileges re-run its jobs?

Don't worry about it, just general flakiness :) the chrome tests are the important ones. Also firefox if you can run locally? Otherwise I'll do it before at some point.
rpiaggio: I did a `publishLocal` and tested application where we use the snippet I copied above, which uses `.resource` and `.withCredentials`, in addition to `.create` and `.withCache` in other places. It seems to work fine.

rpiaggio: BTW, I'm having trouble running locally the tests in `FetchClientSuite`. Is there something I should set up?

```
[error] Failed: Total 12, Failed 12, Errors 0, Passed 0
[error] Failed tests:.FetchClientSuite 0s
[error]         org.http4s.dom.FetchClientSuite
[error] (Test / testOnly) sbt.TestsFailedException: Tests unsuccessful
```
armanbilge: Oh, oops. Yes. Run `./scripts/scaffold_server.js` and `./scripts/static_server.py` to start the servers. You also need to do in sbt: `set useJSEnv := JSEnv.Firefox` or `set useJSEnv := JSEnv.Chrome`. And install geckodriver for firefox or chromedriver for chrome 😅 

Is this ready for another look?
rpiaggio: Thank you.

Yes, it's ready for review.
isomarcte: @rpiaggio a lot of my feedback here is subjective, but I do think we need to address the `FetchReferrer` behavior discrepancy I'm seeing, e.g. `client` vs. `about:client`, `""` vs `no-referrer`.
armanbilge: @rpiaggio would you mind merging in or rebasing on main? I brought back CI for Firefox in #5123, would be good to run that here.
rpiaggio: > @rpiaggio would you mind merging in or rebasing on main? I brought back CI for Firefox in #5123, would be good to run that here.

:+1: done

Patch:
diff --git a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala
index b72c28375b..926c436ba8 100644
--- a/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala
+++ b/blaze-client/src/main/scala/org/http4s/blaze/client/BlazeClientBuilder.scala
@@ -57,7 +57,7 @@ import scala.concurrent.duration._
   * @param channelOptions custom socket options
   * @param customDnsResolver customDnsResolver to use other than the system default
   */
-sealed abstract class BlazeClientBuilder[F[_]] private (
+final class BlazeClientBuilder[F[_]] private (
     val responseHeaderTimeout: Duration,
     val idleTimeout: Duration,
     val requestTimeout: Duration,
@@ -131,7 +131,7 @@ sealed abstract class BlazeClientBuilder[F[_]] private (
       asynchronousChannelGroup = asynchronousChannelGroup,
       channelOptions = channelOptions,
       customDnsResolver = customDnsResolver
-    ) {}
+    )
 
   def withResponseHeaderTimeout(responseHeaderTimeout: Duration): BlazeClientBuilder[F] =
     copy(responseHeaderTimeout = responseHeaderTimeout)
@@ -346,7 +346,7 @@ object BlazeClientBuilder {
       asynchronousChannelGroup = None,
       channelOptions = ChannelOptions(Vector.empty),
       customDnsResolver = None
-    ) {}
+    )
 
   def getAddress(requestKey: RequestKey): Either[Throwable, InetSocketAddress] =
     requestKey match {
diff --git a/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClient.scala b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClient.scala
index 4d7e24b793..681ebbb5a8 100644
--- a/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClient.scala
+++ b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClient.scala
@@ -19,31 +19,71 @@ package dom
 
 import cats.effect.Async
 import cats.effect.Resource
+import cats.effect.Poll
+import cats.effect.syntax.all._
 import cats.syntax.all._
+import java.util.concurrent.TimeoutException
 import org.http4s.client.Client
+import org.http4s.headers.{`Referer`}
 import org.scalajs.dom.crypto._
+import org.scalajs.dom.experimental.AbortController
 import org.scalajs.dom.experimental.Fetch
 import org.scalajs.dom.experimental.Headers
 import org.scalajs.dom.experimental.HttpMethod
 import org.scalajs.dom.experimental.RequestInit
+import org.scalajs.dom.experimental.{Response => FetchResponse}
+import scala.concurrent.duration._
 
-object FetchClient {
+private[dom] object FetchClient {
 
-  def apply[F[_]](implicit F: Async[F]): Client[F] = Client[F] { req: Request[F] =>
+  private[dom] def makeClient[F[_]](
+      requestTimeout: Duration,
+      options: FetchOptions
+  )(implicit F: Async[F]): Client[F] = Client[F] { (req: Request[F]) =>
     Resource.eval(req.body.chunkAll.filter(_.nonEmpty).compile.last).flatMap { body =>
       Resource
-        .makeCase {
-          F.fromPromise {
-            F.delay {
-              val init = new RequestInit {}
+        .makeCaseFull { (poll: Poll[F]) =>
+          F.delay(new AbortController()).flatMap { abortController =>
+            val requestOptions = req.attributes.lookup(FetchOptions.Key)
+            val mergedOptions = requestOptions.fold(options)(options.merge)
 
-              init.method = req.method.name.asInstanceOf[HttpMethod]
-              init.headers = new Headers(toDomHeaders(req.headers))
-              body.foreach { body =>
-                init.body = arrayBuffer2BufferSource(body.toJSArrayBuffer)
-              }
+            val init = new RequestInit {}
 
-              Fetch.fetch(req.uri.renderString, init)
+            init.method = req.method.name.asInstanceOf[HttpMethod]
+            init.headers = new Headers(toDomHeaders(req.headers))
+            body.foreach { body =>
+              init.body = arrayBuffer2BufferSource(body.toJSArrayBuffer)
+            }
+            init.signal = abortController.signal
+            mergedOptions.cache.foreach(init.cache = _)
+            mergedOptions.credentials.foreach(init.credentials = _)
+            mergedOptions.integrity.foreach(init.integrity = _)
+            mergedOptions.keepAlive.foreach(init.keepalive = _)
+            mergedOptions.mode.foreach(init.mode = _)
+            mergedOptions.redirect.foreach(init.redirect = _)
+            // Referer headers are forbidden in Fetch, but we make a best effort to preserve behavior across clients.
+            // See https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name
+            // If there's a Referer header, it will have more priority than the client's `referrer` (if present)
+            // but less priority than the request's `referrer` (if present).
+            requestOptions
+              .flatMap(_.referrer)
+              .orElse(req.headers.get[Referer].map(_.uri))
+              .orElse(options.referrer)
+              .foreach(referrer => init.referrer = referrer.renderString)
+            mergedOptions.referrerPolicy.foreach(init.referrerPolicy = _)
+
+            val fetch = poll(F.fromPromise(F.delay(Fetch.fetch(req.uri.renderString, init))))
+              .onCancel(F.delay(abortController.abort()))
+
+            requestTimeout match {
+              case d: FiniteDuration =>
+                fetch.timeoutTo(
+                  d,
+                  F.raiseError[FetchResponse](new TimeoutException(
+                    s"Request to ${req.uri.renderString} timed out after ${d.toMillis} ms"))
+                )
+              case _ =>
+                fetch
             }
           }
         } { case (r, exitCase) =>
@@ -58,7 +98,6 @@ object FetchClient {
             )
           }
         }
-
     }
   }
 
diff --git a/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClientBuilder.scala b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClientBuilder.scala
new file mode 100644
index 0000000000..6f36cb1b4c
--- /dev/null
+++ b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchClientBuilder.scala
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2021 http4s.org
+ *
+ * Licensed 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.http4s.dom
+
+import cats.effect.Async
+import cats.effect.Resource
+import org.http4s.client.{Client, defaults}
+import org.http4s.internal.BackendBuilder
+import org.scalajs.dom.experimental.ReferrerPolicy
+import org.scalajs.dom.experimental.RequestCache
+import org.scalajs.dom.experimental.RequestCredentials
+import org.scalajs.dom.experimental.RequestMode
+import org.scalajs.dom.experimental.RequestRedirect
+import scala.concurrent.duration._
+
+/** Configure and obtain a FetchClient
+  *
+  * @param requestTimeout maximum duration from the submission of a request through reading the body before a timeout.
+  * @param cache how the request will interact with the browser’s HTTP cache
+  * @param credentials what browsers do with credentials (cookies, HTTP authentication entries, and TLS client certificates)
+  * @param mode mode you want to use for the request, e.g., cors, no-cors, or same-origin
+  * @param redirect how to handle a redirect response
+  * @param referrer referrer of the request
+  * @param referrerPolicy referrer policy to use for the request
+  */
+sealed abstract class FetchClientBuilder[F[_]] private (
+    val requestTimeout: Duration,
+    val cache: Option[RequestCache],
+    val credentials: Option[RequestCredentials],
+    val mode: Option[RequestMode],
+    val redirect: Option[RequestRedirect],
+    val referrer: Option[FetchReferrer],
+    val referrerPolicy: Option[ReferrerPolicy]
+)(override implicit protected val F: Async[F])
+    extends BackendBuilder[F, Client[F]] {
+
+  private def copy(
+      requestTimeout: Duration = requestTimeout,
+      cache: Option[RequestCache] = cache,
+      credentials: Option[RequestCredentials] = credentials,
+      mode: Option[RequestMode] = mode,
+      redirect: Option[RequestRedirect] = redirect,
+      referrer: Option[FetchReferrer] = referrer,
+      referrerPolicy: Option[ReferrerPolicy] = referrerPolicy
+  ): FetchClientBuilder[F] =
+    new FetchClientBuilder[F](
+      requestTimeout,
+      cache,
+      credentials,
+      mode,
+      redirect,
+      referrer,
+      referrerPolicy
+    ) {}
+
+  def withRequestTimeout(requestTimeout: Duration): FetchClientBuilder[F] =
+    copy(requestTimeout = requestTimeout)
+
+  def withCacheOption(cache: Option[RequestCache]): FetchClientBuilder[F] =
+    copy(cache = cache)
+  def withCache(cache: RequestCache): FetchClientBuilder[F] =
+    withCacheOption(Some(cache))
+  def withDefaultCache: FetchClientBuilder[F] =
+    withCacheOption(None)
+
+  def withCredentialsOption(credentials: Option[RequestCredentials]): FetchClientBuilder[F] =
+    copy(credentials = credentials)
+  def withCredentials(credentials: RequestCredentials): FetchClientBuilder[F] =
+    withCredentialsOption(Some(credentials))
+  def withDefaultCredentials: FetchClientBuilder[F] =
+    withCredentialsOption(None)
+
+  def withModeOption(mode: Option[RequestMode]): FetchClientBuilder[F] =
+    copy(mode = mode)
+  def withMode(mode: RequestMode): FetchClientBuilder[F] =
+    withModeOption(Some(mode))
+  def withDefaultMode: FetchClientBuilder[F] =
+    withModeOption(None)
+
+  def withRedirectOption(redirect: Option[RequestRedirect]): FetchClientBuilder[F] =
+    copy(redirect = redirect)
+  def withRedirect(redirect: RequestRedirect): FetchClientBuilder[F] =
+    withRedirectOption(Some(redirect))
+  def withDefaultRedirect: FetchClientBuilder[F] =
+    withRedirectOption(None)
+
+  def withReferrerOption(referrer: Option[FetchReferrer]): FetchClientBuilder[F] =
+    copy(referrer = referrer)
+  def withReferrer(referrer: FetchReferrer): FetchClientBuilder[F] =
+    withReferrerOption(Some(referrer))
+  def withDefaultReferrer: FetchClientBuilder[F] =
+    withReferrerOption(None)
+
+  def withReferrerPolicyOption(referrerPolicy: Option[ReferrerPolicy]): FetchClientBuilder[F] =
+    copy(referrerPolicy = referrerPolicy)
+  def withReferrerPolicy(referrerPolicy: ReferrerPolicy): FetchClientBuilder[F] =
+    withReferrerPolicyOption(Some(referrerPolicy))
+  def withDefaultReferrerPolicy: FetchClientBuilder[F] =
+    withReferrerPolicyOption(None)
+
+  /** Creates a [[Client]].
+    */
+  def create: Client[F] = FetchClient.makeClient(
+    requestTimeout,
+    FetchOptions(
+      cache = cache,
+      credentials = credentials,
+      mode = mode,
+      redirect = redirect,
+      referrer = referrer,
+      referrerPolicy = referrerPolicy
+    ))
+
+  override def resource: Resource[F, Client[F]] =
+    Resource.eval(F.delay(create))
+}
+
+object FetchClientBuilder {
+
+  /** Creates a FetchClientBuilder
+    */
+  def apply[F[_]: Async]: FetchClientBuilder[F] =
+    new FetchClientBuilder[F](
+      requestTimeout = defaults.RequestTimeout,
+      cache = None,
+      credentials = None,
+      mode = None,
+      redirect = None,
+      referrer = None,
+      referrerPolicy = None
+    ) {}
+}
diff --git a/dom-fetch-client/src/main/scala/org/http4s/dom/FetchOptions.scala b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchOptions.scala
new file mode 100644
index 0000000000..a07d2716c5
--- /dev/null
+++ b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchOptions.scala
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2021 http4s.org
+ *
+ * Licensed 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.http4s.dom
+
+import cats.effect.SyncIO
+import org.scalajs.dom.experimental.ReferrerPolicy
+import org.scalajs.dom.experimental.RequestCache
+import org.scalajs.dom.experimental.RequestCredentials
+import org.scalajs.dom.experimental.RequestMode
+import org.scalajs.dom.experimental.RequestRedirect
+import org.typelevel.vault
+
+/** Options to pass to Fetch when using a FetchClient
+  *
+  * @param requestTimeout maximum duration from the submission of a request through reading the body before a timeout.
+  * @param cache how the request will interact with the browser’s HTTP cache
+  * @param credentials what browsers do with credentials (cookies, HTTP authentication entries, and TLS client certificates)
+  * @param integrity subresource integrity value of the request
+  * @param keepAlive used to allow the request to outlive the page
+  * @param mode mode you want to use for the request, e.g., cors, no-cors, or same-origin
+  * @param redirect how to handle a redirect response
+  * @param referrer referrer of the request, this can be a same-origin URL, about:client, or an empty string.
+  * @param referrerPolicy referrer policy to use for the request
+  */
+sealed abstract class FetchOptions extends Product with Serializable {
+  def cache: Option[RequestCache]
+  def credentials: Option[RequestCredentials]
+  def integrity: Option[String]
+  def keepAlive: Option[Boolean]
+  def mode: Option[RequestMode]
+  def redirect: Option[RequestRedirect]
+  def referrer: Option[FetchReferrer]
+  def referrerPolicy: Option[ReferrerPolicy]
+
+  def withCacheOption(cache: Option[RequestCache]): FetchOptions
+  final def withCache(cache: RequestCache): FetchOptions =
+    withCacheOption(Some(cache))
+  final def withoutCache: FetchOptions =
+    withCacheOption(None)
+
+  def withCredentialsOption(credentials: Option[RequestCredentials]): FetchOptions
+  final def withCredentials(credentials: RequestCredentials): FetchOptions =
+    withCredentialsOption(Some(credentials))
+  final def withoutCredentials: FetchOptions =
+    withCredentialsOption(None)
+
+  def withIntegrityOption(integrity: Option[String]): FetchOptions
+  final def withIntegrity(integrity: String): FetchOptions =
+    withIntegrityOption(Some(integrity))
+  final def withoutIntegrity: FetchOptions =
+    withIntegrityOption(None)
+
+  def withKeepAliveOption(keepAlive: Option[Boolean]): FetchOptions
+  final def withKeepAlive(keepAlive: Boolean): FetchOptions =
+    withKeepAliveOption(Some(keepAlive))
+  final def withoutKeepAlive: FetchOptions =
+    withKeepAliveOption(None)
+
+  def withModeOption(mode: Option[RequestMode]): FetchOptions
+  final def withMode(mode: RequestMode): FetchOptions =
+    withModeOption(Some(mode))
+  final def withoutMode: FetchOptions =
+    withModeOption(None)
+
+  def withRedirectOption(redirect: Option[RequestRedirect]): FetchOptions
+  final def withRedirect(redirect: RequestRedirect): FetchOptions =
+    withRedirectOption(Some(redirect))
+  final def withoutRedirect: FetchOptions =
+    withRedirectOption(None)
+
+  def withReferrerOption(referrer: Option[FetchReferrer]): FetchOptions
+  final def withReferrer(referrer: FetchReferrer): FetchOptions =
+    withReferrerOption(Some(referrer))
+  final def withoutReferrer: FetchOptions =
+    withReferrerOption(None)
+
+  def withReferrerPolicyOption(referrerPolicy: Option[ReferrerPolicy]): FetchOptions
+  final def withReferrerPolicy(referrerPolicy: ReferrerPolicy): FetchOptions =
+    withReferrerPolicyOption(Some(referrerPolicy))
+  final def withoutReferrerPolicy: FetchOptions =
+    withReferrerPolicyOption(None)
+
+  /** Merge Two FetchOptions. `other` is prioritized. */
+  final def merge(other: FetchOptions): FetchOptions =
+    FetchOptions(
+      cache = other.cache.orElse(cache),
+      credentials = other.credentials.orElse(credentials),
+      integrity = other.integrity.orElse(integrity),
+      keepAlive = other.keepAlive.orElse(keepAlive),
+      mode = other.mode.orElse(mode),
+      redirect = other.redirect.orElse(redirect),
+      referrer = other.referrer.orElse(referrer),
+      referrerPolicy = other.referrerPolicy.orElse(referrerPolicy)
+    )
+}
+
+object FetchOptions {
+  private[this] final case class FetchOptionsImpl(
+      override final val cache: Option[RequestCache],
+      override final val credentials: Option[RequestCredentials] = None,
+      override final val integrity: Option[String] = None,
+      override final val keepAlive: Option[Boolean] = None,
+      override final val mode: Option[RequestMode] = None,
+      override final val redirect: Option[RequestRedirect] = None,
+      override final val referrer: Option[FetchReferrer] = None,
+      override final val referrerPolicy: Option[ReferrerPolicy] = None
+  ) extends FetchOptions {
+
+    override def withCacheOption(cache: Option[RequestCache]): FetchOptions =
+      copy(cache = cache)
+
+    override def withCredentialsOption(credentials: Option[RequestCredentials]): FetchOptions =
+      copy(credentials = credentials)
+
+    override def withIntegrityOption(integrity: Option[String]): FetchOptions =
+      copy(integrity = integrity)
+
+    override def withKeepAliveOption(keepAlive: Option[Boolean]): FetchOptions =
+      copy(keepAlive = keepAlive)
+
+    override def withModeOption(mode: Option[RequestMode]): FetchOptions = copy(mode = mode)
+
+    override def withRedirectOption(redirect: Option[RequestRedirect]): FetchOptions =
+      copy(redirect = redirect)
+
+    override def withReferrerOption(referrer: Option[FetchReferrer]): FetchOptions =
+      copy(referrer = referrer)
+
+    override def withReferrerPolicyOption(referrerPolicy: Option[ReferrerPolicy]): FetchOptions =
+      copy(referrerPolicy = referrerPolicy)
+  }
+
+  def apply(
+      cache: Option[RequestCache] = None,
+      credentials: Option[RequestCredentials] = None,
+      integrity: Option[String] = None,
+      keepAlive: Option[Boolean] = None,
+      mode: Option[RequestMode] = None,
+      redirect: Option[RequestRedirect] = None,
+      referrer: Option[FetchReferrer] = None,
+      referrerPolicy: Option[ReferrerPolicy] = None
+  ): FetchOptions =
+    FetchOptionsImpl(
+      cache,
+      credentials,
+      integrity,
+      keepAlive,
+      mode,
+      redirect,
+      referrer,
+      referrerPolicy)
+
+  val Key: vault.Key[FetchOptions] = vault.Key.newKey[SyncIO, FetchOptions].unsafeRunSync()
+}
diff --git a/dom-fetch-client/src/main/scala/org/http4s/dom/FetchReferrer.scala b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchReferrer.scala
new file mode 100644
index 0000000000..1c6a06db8d
--- /dev/null
+++ b/dom-fetch-client/src/main/scala/org/http4s/dom/FetchReferrer.scala
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2021 http4s.org
+ *
+ * Licensed 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.http4s.dom
+
+import org.http4s.Uri
+import org.http4s.util.Writer
+import org.http4s.util.Renderable
+
+/** Fetch's `RequestInit.referrer` parameter can be one of:
+  *   - Same-origin URL
+  *   - "about:client"
+  *   - the empty string
+  *
+  * To ensure same-origin URLs, we only allow relative URLs in the form of [[Uri.Path]].
+  *
+  * See https://fetch.spec.whatwg.org/#ref-for-dom-request-referrer%E2%91%A0
+  */
+sealed abstract class FetchReferrer extends Renderable
+object FetchReferrer {
+  case object NoReferrer extends FetchReferrer {
+    override def render(writer: Writer): writer.type = writer << ""
+  }
+  case object Client extends FetchReferrer {
+    override def render(writer: Writer): writer.type = writer << "about:client"
+  }
+  final case class Path(path: Uri.Path) extends FetchReferrer {
+    override def render(writer: Writer): writer.type = writer << path.renderString
+  }
+}


Test patch:
diff --git a/dom-fetch-client/src/test/scala/org/http4s/dom/FetchClientSuite.scala b/dom-fetch-client/src/test/scala/org/http4s/dom/FetchClientSuite.scala
index 8a477c582b..f6951c3a9b 100644
--- a/dom-fetch-client/src/test/scala/org/http4s/dom/FetchClientSuite.scala
+++ b/dom-fetch-client/src/test/scala/org/http4s/dom/FetchClientSuite.scala
@@ -23,5 +23,5 @@ import org.http4s.client.Client
 import org.http4s.client.ClientRouteTestBattery
 
 class FetchClientSuite extends ClientRouteTestBattery("FetchClient") with Http4sSuite {
-  override def clientResource: Resource[IO, Client[IO]] = Resource.pure(FetchClient[IO])
+  override def clientResource: Resource[IO, Client[IO]] = FetchClientBuilder[IO].resource
 }
diff --git a/dom-service-worker-tests/src/test/scala/org/http4s/dom/ServiceWorkerAppSuite.scala b/dom-service-worker-tests/src/test/scala/org/http4s/dom/ServiceWorkerAppSuite.scala
index 2718e8aa24..fdc036f695 100644
--- a/dom-service-worker-tests/src/test/scala/org/http4s/dom/ServiceWorkerAppSuite.scala
+++ b/dom-service-worker-tests/src/test/scala/org/http4s/dom/ServiceWorkerAppSuite.scala
@@ -42,7 +42,7 @@ class ServiceWorkerAppSuite extends ServerRouteTestBattery("ServiceWorkerApp") {
   else
     BuildInfo.scalaVersion
 
-  override def clientResource: Resource[IO, Client[IO]] = Resource.pure(FetchClient[IO])
+  override def clientResource: Resource[IO, Client[IO]] = FetchClientBuilder[IO].resource
 
   override def serverResource(app: HttpApp[IO]): Resource[IO, Server] =
     Resource