aboutsummaryrefslogtreecommitdiff
path: root/docs/backends/custom.rst
blob: c36b1d24d5b110507fd25db5d869df467e1da08f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
.. _custombackends:

Custom backends, logging, metrics
=================================

It is also entirely possible to write custom backends (if doing so, please consider contributing!) or wrap an existing one. One can even write completely generic wrappers for any delegate backend, as each backend comes equipped with a monad for the response type. This brings the possibility to ``map`` and ``flatMap`` over responses.

Possible use-cases for wrapper-backend include:

* logging
* capturing metrics
* request signing (transforming the request before sending it to the delegate)

Request tagging
---------------

Each request contains a ``tags: Map[String, Any]`` map. This map can be used to tag the request with any backend-specific information, and isn't used in any way by sttp itself.

Tags can be added to a request using the ``def tag(k: String, v: Any)`` method, and read using the ``def tag(k: String): Option[Any]`` method.

Backends, or backend wrappers can use tags e.g. for logging, passing a metric name, using different connection pools, or even different delegate backends.

Backend wrappers and redirects
------------------------------

By default redirects are handled at a low level, using a wrapper around the main, concrete backend: each of the backend factory methods, e.g. ``HttpURLConnectionBackend()`` returns a backend wrapped in ``FollowRedirectsBackend``.

This causes any further backend wrappers to handle a request which involves redirects as one whole, without the intermediate requests. However, wrappers which collects metrics, implements tracing or handles request retries might want to handle every request in the redirect chain. This can be achieved by layering another ``FollowRedirectsBackend`` on top of the wrapper. Only the top-level follow redirects backend will handle redirects, other follow redirect wrappers (at lower levels) will be disabled.

For example::

  class MyWrapper[R[_], S] private (delegate: SttpBackend[R, S])
    extends SttpBackend[R, S] {

    ...
  }

  object MyWrapper {
    def apply[R[_], S](delegate: SttpBackend[R, S]): SttpBackend[R, S] = {
      // disables any other FollowRedirectsBackend-s further down the delegate chain
      new FollowRedirectsBackend(new MyWrapper(delegate))
    }
  }

Example metrics backend wrapper
-------------------------------

Below is an example on how to implement a backend wrapper, which sends metrics for completed requests and wraps any ``Future``-based backend::

  // the metrics infrastructure
  trait MetricsServer {
    def reportDuration(name: String, duration: Long): Unit
  }

  class CloudMetricsServer extends MetricsServer {
    override def reportDuration(name: String, duration: Long): Unit = ???
  }

  // the backend wrapper
  class MetricWrapper[S](delegate: SttpBackend[Future, S],
                            metrics: MetricsServer)
      extends SttpBackend[Future, S] {

    override def send[T](request: Request[T, S]): Future[Response[T]] = {
      val start = System.currentTimeMillis()

      def report(metricSuffix: String): Unit = {
        val metricPrefix = request.tag("metric").getOrElse("?")
        val end = System.currentTimeMillis()
        metrics.reportDuration(metricPrefix + "-" + metricSuffix, end - start)
      }

      delegate.send(request).andThen {
        case Success(response) if response.is200 => report("ok")
        case Success(response)                   => report("notok")
        case Failure(t)                          => report("exception")
      }
    }

    override def close(): Unit = delegate.close()

    override def responseMonad: MonadError[Future] = delegate.responseMonad
  }

  // example usage
  implicit val backend = new MetricWrapper(
    AkkaHttpBackend(),
    new CloudMetricsServer()
  )

  sttp
    .get(uri"http://company.com/api/service1")
    .tag("metric", "service1")
    .send()

Example retrying backend wrapper
--------------------------------

Handling retries is a complex problem when it comes to HTTP requests. When is a request retryable? There are a couple of things to take into account:

* connection exceptions are generally good candidates for retries
* only idempotent HTTP methods (such as ``GET``) could potentially be retried
* some HTTP status codes might also be retryable (e.g. ``500 Internal Server Error`` or ``503 Service Unavailable``)

In some cases it's possible to implement a generic retry mechanism; such a mechanism should take into account logging, metrics, limiting the number of retries and a backoff mechanism. These mechanisms could be quite simple, or involve e.g. retry budgets (see `Finagle's <https://twitter.github.io/finagle/guide/Clients.html#retries>`_ documentation on retries). In sttp, it's possible to recover from errors using the ``responseMonad``. A starting point for a retrying backend could be::

  import com.softwaremill.sttp.{MonadError, Request, Response, SttpBackend}

  class RetryingBackend[R[_], S](
      delegate: SttpBackend[R, S],
      shouldRetry: (Request[_, _], Either[Throwable, Response[_]]) => Boolean,
      maxRetries: Int)
      extends SttpBackend[R, S] {

    override def send[T](request: Request[T, S]): R[Response[T]] = {
      sendWithRetryCounter(request, 0)
    }

    private def sendWithRetryCounter[T](request: Request[T, S],
                                        retries: Int): R[Response[T]] = {
      val r = responseMonad.handleError(delegate.send(request)) {
        case t if shouldRetry(request, Left(t)) && retries < maxRetries =>
          sendWithRetryCounter(request, retries + 1)
      }

      responseMonad.flatMap(r) { resp =>
        if (shouldRetry(request, Right(resp)) && retries < maxRetries) {
          sendWithRetryCounter(request, retries + 1)
        } else {
          responseMonad.unit(resp)
        }
      }
    }

    override def close(): Unit = delegate.close()

    override def responseMonad: MonadError[R] = delegate.responseMonad
  }

Note that some backends also have built-in retry mechanisms, e.g. `akka-http <https://doc.akka.io/docs/akka-http/current/scala/http/client-side/host-level.html#retrying-a-request>`_ or `OkHttp <http://square.github.io/okhttp>`_ (see the builder's ``retryOnConnectionFailure`` method).

Example new backend
--------------------------------

Implementing a new backend is made easy as the tests are published in the ``core`` jar file under the ``tests`` classifier. Simply add the follow dependencies to your ``build.sbt``::

  "com.softwaremill.sttp" %% "core" % sttpVersion % "test" classifier "tests",
  "com.github.pathikrit" %% "better-files" % "3.4.0" % "test",
  "com.typesafe.akka" %% "akka-http" % "10.1.1" % "test",
  "com.typesafe.akka" %% "akka-stream" % "2.5.12" % "test",
  "org.scalatest" %% "scalatest" % "3.0.5" % "test"

Implement your backend and extend the ``HttpTest`` class::

  import com.softwaremill.sttp.SttpBackend
  import com.softwaremill.sttp.testing.{ConvertToFuture, HttpTest}

  class MyCustomBackendHttpTest extends HttpTest[Future] {

    override implicit val convertToFuture: ConvertToFuture[Future] = ConvertToFuture.future
    override implicit lazy val backend: SttpBackend[Future, Nothing] = new MyCustomBackend()

  }

You can find a more detailed example in the `sttp-vertx <https://github.com/guymers/sttp-vertx>`_ repository.