├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── hardhat.ex └── hardhat │ ├── application.ex │ ├── builder.ex │ ├── defaults.ex │ └── middleware │ ├── deadline_propagation.ex │ ├── path_params.ex │ ├── regulator.ex │ └── timeout.ex ├── mix.exs ├── mix.lock └── test ├── hardhat_test.exs ├── middleware ├── deadline_propagation_test.exs ├── fuse_test.exs ├── opentelemetry_test.exs ├── path_params_test.exs ├── regulator_test.exs ├── retry_test.exs ├── telemetry_test.exs └── timeout_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:tesla] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | name: Test (${{matrix.elixir}}/${{matrix.otp}}) 16 | 17 | strategy: 18 | matrix: 19 | otp: [26.x, 27.x] 20 | elixir: [1.15.x, 1.16.x, 1.17.x] 21 | exclude: 22 | - otp: 27.x 23 | elixir: 1.15.x 24 | - otp: 27.x 25 | elixir: 1.16.x 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: erlef/setup-beam@v1 30 | with: 31 | otp-version: ${{matrix.otp}} 32 | elixir-version: ${{matrix.elixir}} 33 | - uses: actions/cache@v3 34 | with: 35 | path: | 36 | deps 37 | _build 38 | key: ${{ runner.os }}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{ hashFiles('**/mix.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-mix-${{matrix.otp}}-${{matrix.elixir}}- 41 | 42 | - name: Install Dependencies 43 | run: mix deps.get 44 | 45 | - name: Compile 46 | env: 47 | MIX_ENV: test 48 | run: mix compile 49 | 50 | - name: Run Tests 51 | run: mix test 52 | 53 | formatter: 54 | runs-on: ubuntu-latest 55 | name: Formatter (1.15.x/26.x) 56 | 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: erlef/setup-beam@v1 60 | id: beam 61 | with: 62 | otp-version: 26.x 63 | elixir-version: 1.15.x 64 | - uses: actions/cache@v3 65 | with: 66 | path: | 67 | deps 68 | _build 69 | key: ${{ runner.os }}-mix-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-${{ hashFiles('**/mix.lock') }} 70 | restore-keys: | 71 | ${{ runner.os }}-mix-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}- 72 | 73 | - name: Install Dependencies 74 | run: mix deps.get 75 | 76 | - name: Run Formatter 77 | run: mix format --check-formatted 78 | 79 | dialyzer: 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | - name: Set up Elixir 84 | id: beam 85 | uses: erlef/setup-beam@v1 86 | with: 87 | otp-version: 26.x 88 | elixir-version: 1.15.x 89 | 90 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 91 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 92 | - name: Restore PLT cache 93 | uses: actions/cache/restore@v3 94 | id: plt_cache 95 | with: 96 | key: | 97 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 98 | restore-keys: | 99 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 100 | path: | 101 | priv/plts 102 | 103 | - name: Install Dependencies 104 | run: mix deps.get 105 | 106 | # Create PLTs if no cache was found 107 | - name: Create PLTs 108 | if: steps.plt_cache.outputs.cache-hit != 'true' 109 | run: mix dialyzer --plt 110 | 111 | # By default, the GitHub Cache action will only save the cache if all steps in the job succeed, 112 | # so we separate the cache restore and save steps in case running dialyzer fails. 113 | - name: Save PLT cache 114 | uses: actions/cache/save@v3 115 | if: steps.plt_cache.outputs.cache-hit != 'true' 116 | id: plt_cache_save 117 | with: 118 | key: | 119 | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt 120 | path: | 121 | priv/plts 122 | 123 | - name: Run dialyzer 124 | run: mix dialyzer --format github 125 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | release: 14 | name: Release package to Hex.pm 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | otp: [26.x] 20 | elixir: [1.15.x] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: erlef/setup-beam@v1 25 | with: 26 | otp-version: ${{matrix.otp}} 27 | elixir-version: ${{matrix.elixir}} 28 | - uses: actions/cache@v3 29 | with: 30 | path: | 31 | deps 32 | _build 33 | key: ${{ runner.os }}-mix-${{matrix.otp}}-${{matrix.elixir}}-${{ hashFiles('**/mix.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-mix-${{matrix.otp}}-${{matrix.elixir}}- 36 | 37 | - name: Install Dependencies 38 | run: mix deps.get 39 | 40 | - name: Compile 41 | run: mix compile 42 | 43 | - name: Generate docs 44 | run: mix docs 45 | 46 | - name: Release to hex.pm 47 | env: 48 | HEX_API_KEY: ${{ secrets.HEX_PM_KEY }} 49 | run: mix hex.publish --yes 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | hardhat-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # `mix decompile` output 29 | Elixir.*.ex 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.1.0 4 | 5 | * Bump finch from 0.17.0 to 0.18.0 by [@dependabot](https://github.com/dependabot) ([#18](https://github.com/seancribbs/hardhat/pull/18)) 6 | * Bump opentelemetry_process_propagator from 0.2.2 to 0.3.0 by [@dependabot](https://github.com/dependabot) ([#19](https://github.com/seancribbs/hardhat/pull/19)) 7 | * Bump opentelemetry_tesla from 2.3.0 to 2.4.0 by [@dependabot](https://github.com/dependabot) ([#20](https://github.com/seancribbs/hardhat/pull/20)) 8 | * Bump opentelemetry from 1.3.1 to 1.4.0 by [@dependabot](https://github.com/dependabot) ([#22](https://github.com/seancribbs/hardhat/pull/22)) 9 | * Bump ex_doc from 0.31.1 to 0.32.1 by [@dependabot](https://github.com/dependabot) ([#24](https://github.com/seancribbs/hardhat/pull/24)) 10 | * Bump ex_doc from 0.32.1 to 0.32.2 by [@dependabot](https://github.com/dependabot) ([#25](https://github.com/seancribbs/hardhat/pull/25)) 11 | * Bump ex_doc from 0.32.2 to 0.33.0 by [@dependabot](https://github.com/dependabot) ([#26](https://github.com/seancribbs/hardhat/pull/26)) 12 | * Bump ex_doc from 0.33.0 to 0.34.0 by [@dependabot](https://github.com/dependabot) ([#27](https://github.com/seancribbs/hardhat/pull/27)) 13 | * Relax version constraints on dependencies by [John Wilger](https://github.com/jwilger) ([#28](https://github.com/seancribbs/hardhat/pull/28))) 14 | * Bump ex_doc from 0.34.0 to 0.34.1 by [@dependabot](https://github.com/dependabot) ([#30](https://github.com/seancribbs/hardhat/pull/30)) 15 | * Bump tesla from 1.8.0 to 1.11.0 by [@dependabot](https://github.com/dependabot) ([#29](https://github.com/seancribbs/hardhat/pull/29)) 16 | 17 | ## v1.0.2 18 | 19 | * Bump ex_doc from 0.30.9 to 0.31.0 by [@dependabot](https://github.com/dependabot) ([#14](https://github.com/seancribbs/hardhat/pull/14)) 20 | * Bump dialyxir from 1.4.2 to 1.4.3 by [@dependabot](https://github.com/dependabot) ([#15](https://github.com/seancribbs/hardhat/pull/15)) 21 | * Bump ex_doc from 0.31.0 to 0.31.1 by [@dependabot](https://github.com/dependabot) ([#17](https://github.com/seancribbs/hardhat/pull/17)) 22 | * Bump finch from 0.16.0 to 0.17.0 [@dependabot](https://github.com/dependabot) ([#16](https://github.com/seancribbs/hardhat/pull/16)) 23 | * Update documentation for changed Finch pool options by [@seancribbs](https://github.com/seancribbs) 24 | 25 | ## v1.0.1 26 | 27 | * Small stylistic improvements by [@hissssst](https://github.com/hissssst) ([#12](https://github.com/seancribbs/hardhat/pull/12)) 28 | * Bump `regulator` from 0.5.0 to 0.6.0 by [@dependabot](https://github.com/dependabot) ([#13](https://github.com/seancribbs/hardhat/pull/13)) 29 | 30 | ## v1.0.0 31 | 32 | Initial release! 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sean Cribbs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hardhat 2 | 3 | [![Build status](https://github.com/seancribbs/hardhat/actions/workflows/ci.yaml/badge.svg)](https://github.com/seancribbs/hardhat/actions/workflows/ci.yaml) [![Hex.pm](https://img.shields.io/hexpm/v/hardhat.svg)](https://hex.pm/packages/hardhat) 4 | 5 | 6 | An opinionated, production-ready HTTP client for Elixir services. 👷🌐 7 | 8 | ## What's included 9 | 10 | - Connection pooling per-client module 11 | - Integration with `telemetry` and `opentelemetry` instrumentation 12 | - Circuit breaking for repeatedly failed requests 13 | - Automatic retries for failed requests 14 | - Timeout and `deadline` support 15 | 16 | ## Why Hardhat? 17 | 18 | In 2021, my employer was in the process of [refactoring its monolithic Phoenix application into a small number of decoupled services](https://www.youtube.com/watch?v=Py8WK4rBNqQ), so we needed better reliability and observability at the boundaries of our services. We had experienced multiple production incidents related to exhaustion of a single, shared connection pool for outgoing HTTP requests. Additionally, we had built a number of custom clients for external SaaS APIs but had no consistency between them. 19 | 20 | I set out to address these problems by creating a standard HTTP client library, upon which individual teams could build clients for internal and external APIs and get reliability and observability, relatively for-free. `Hardhat` was born (its name comes from "hardened HTTP client", and that you should wear a hardhat to protect your head in dangerous construction areas). 21 | 22 | `Hardhat` attempts to walk the line of baking-in sensible defaults so the upfront effort is minimal, but also allowing you to customize and extend almost every part of the built-in functionality. It is not a low-level HTTP client, but adds functionality on top of `Tesla` and `Finch`, and draws upon well-crafted libraries like `:opentelemetry`, `:telemetry`, `:fuse`, `Regulator`, and `Deadline`. 23 | 24 | Regrettably, my employer did not see fit to release `Hardhat` as open-source software, so this library recreates it from scratch, built only from my own recollections and the help of the community. 25 | 26 | ## Installation 27 | 28 | Add `hardhat` to the dependencies in your `mix.exs`: 29 | 30 | ```elixir 31 | def deps do 32 | [ 33 | {:hardhat, "~> 1.0.0"} 34 | ] 35 | end 36 | ``` 37 | 38 | ## Getting started 39 | 40 | `Hardhat` is designed to be easy for creating quick wrappers around HTTP APIs, 41 | but includes many options for customization. To define a simple client, do something like the following: 42 | 43 | ```elixir 44 | # Define a client module: 45 | defmodule SocialMediaAPI do 46 | use Hardhat 47 | end 48 | 49 | # Add it to your supervisor (required): 50 | defmodule MyApp.Sup do 51 | use Supervisor 52 | 53 | def start_link(init_arg) do 54 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 55 | end 56 | 57 | @impl true 58 | def init(_init_arg) do 59 | children = [ 60 | SocialMediaAPI 61 | ] 62 | 63 | Supervisor.init(children, strategy: :one_for_one) 64 | end 65 | end 66 | 67 | # Use your client to make requests: 68 | SocialMediaAPI.get("http://media-api.social/posts") 69 | ``` 70 | 71 | As mentioned in the example above, it is **imperative** for you to supervise the client module that includes the `use Hardhat` macro. Without starting the client under supervision, you will not be able to make requests. See [Connection pools](#module-connection-pools) below for more information. 72 | 73 | ## General behavior 74 | 75 | `Hardhat` is built on top of `Tesla`, and uses `Finch` as the adapter. Because 76 | `Tesla` is the foundation, you are welcome to use publicly available 77 | `Tesla.Middleware` modules in your `Hardhat`-based client (with the exception 78 | that [we recommend](#module-timeouts-and-deadlines) you use 79 | `Hardhat.Middleware.Timeout` instead of `Tesla.Middleware.Timeout`). 80 | 81 | ```elixir 82 | defmodule SomeJSONAPI do 83 | use Hardhat 84 | 85 | plug Tesla.Middleware.BaseUrl, "https://my-json.api/" 86 | plug Tesla.Middleware.JSON 87 | end 88 | ``` 89 | 90 | In addition to the adapter selection and default `Tesla` behavior, 91 | `use Hardhat` will inject the common functionality [listed above](#module-what-s-included) *after* any middleware that you supply via [`plug`](`Tesla.Builder.plug/2`). The current list is as follows: 92 | 93 | * `Hardhat.Middleware.DeadlinePropagation` ([see below](#module-deadline-propagation)) 94 | * `Tesla.Middleware.Retry` ([see below](#module-retries)) 95 | * Either `Tesla.Middleware.Fuse` or `Hardhat.Middleware.Regulator` ([see below](#module-failure-detection)) 96 | * `Tesla.Middleware.Telemetry` ([see below](#module-telemetry-and-tracing)) 97 | * `Tesla.Middleware.OpenTelemetry` ([see below](#module-telemetry-and-tracing)) 98 | * `Hardhat.Middleware.PathParams` 99 | 100 | Each of the included middlewares that have configuration have defaults defined by functions in `Hardhat.Defaults` and can be customized by defining a function of the same name in your client module. Inside those functions you can set your own static defaults or get runtime configuration using `Application.get_env/3`. The [options](`t:Keyword.t/0`) you return will be merged with the defaults when the middleware is invoked. Examples of this pattern are in each of the sections below. 101 | 102 | ## Connection pools 103 | 104 | As mentioned above, `Hardhat` uses `Finch` as the adapter. By [default](`Hardhat.Defaults.pool_configuration/1`), `Hardhat` specifies a connection pool of size `10` but sets no [other options](`Finch.start_link/1`) on the adapter. The name of the `Finch` process is proscribed by the `use Hardhat` macro, but you can set any other options for the pool that you like, including creating more than one pool or setting the HTTP protocol or TLS options by overriding the `pool_configuration/1` function. 105 | 106 | ```elixir 107 | defmodule H2Client do 108 | use Hardhat 109 | 110 | # This function overrides the configuration coming from `Hardhat.Defaults`. 111 | # The `overrides` will be passed from your process supervision initial 112 | # arguments. 113 | def pool_configuration(_overrides \\ %{}) do 114 | %{ 115 | # By default we'll use HTTP/2, with 3 pools of one connection each 116 | :default => [ 117 | protocols: [:http2], 118 | count: 3 119 | ], 120 | # For this host only, we're using HTTP/1.1 and a single pool of 20 121 | # connections 122 | "https://some-http1-only-host.com/" => [ 123 | size: 20 124 | ] 125 | } 126 | end 127 | end 128 | ``` 129 | 130 | ## Telemetry and tracing 131 | 132 | `Hardhat` includes the stock `Tesla.Middleware.Telemetry` for injecting your own metrics and monitoring systems into its operation. The events emitted by this middleware are: 133 | 134 | * `[:tesla, :request, :start]` - at the beginning of the request 135 | * `[:tesla, :request, :stop]` - at the completion of the request 136 | * `[:tesla, :request, :exception]` - when a non-HTTP-status exception occurs 137 | 138 | ```elixir 139 | defmodule TelemetryClient do 140 | use Hardhat 141 | end 142 | 143 | :telemetry.attach( 144 | "my handler", 145 | [:tesla, :request, :stop], 146 | fn _event, measures, _metadata, _config -> 147 | # Don't do this, attach to your metrics system instead 148 | IO.puts("Made a request in #{measures.duration}") 149 | end, 150 | nil 151 | ) 152 | ``` 153 | 154 | `Hardhat` wraps each request in an [OpenTelemetry](`Tesla.Middleware.OpenTelemetry`) span and propagates the trace context to the destination host. It will observe any [path parameters](`Hardhat.Middleware.PathParams`) you interpolate into the URL so that similar spans can be easily aggregated. To disable propagation or change other behavior of the tracing, override the `opentelemetry_opts/0` function: 155 | 156 | ```elixir 157 | defmodule NoPropagationClient do 158 | use Hardhat 159 | 160 | def opentelemety_opts do 161 | [ 162 | # Disable trace propagation for talking to a third-party API 163 | propagator: :none 164 | ] 165 | end 166 | end 167 | ``` 168 | 169 | For more information about the options available to tracing, see `Hardhat.Defaults.opentelemetry_opts/0` or `Tesla.Middleware.OpenTelemetry`. 170 | 171 | ## Failure detection 172 | 173 | `Hardhat` provides two different failure detection and backoff strategies: 174 | 175 | * Static circuit breaking with `:fuse` (`Tesla.Middleware.Fuse`) 176 | * Dynamic request rate regulation ([AIMD](`Regulator.Limit.AIMD`)) with `Regulator` (via `Hardhat.Middleware.Regulator`) 177 | 178 | These strategies cannot be used together safely, so you must choose one when defining your client. If your needs are simple and hard failures are relatively rare, `:fuse` is an easier strategy to comprehend and implement because it uses a straightforward failure-counting algorithm, and completely turns off requests when the configured threshold is reached. If you have a more complicated setup or high traffic, and do not want to spend as much time tuning your failure behavior, the `:regulator` strategy might be for you. `Regulator` allows your client to adapt to rapidly changing conditions by reducing the amount of concurrent work in the presence of failure, without causing a hard stop to activity. On the other hand, if your concurrent utilization is low, it might also bound your maximum concurrency even when requests are not failing. 179 | 180 | ### The Fuse strategy 181 | 182 | The `:fuse` failure detection strategy is configured with two functions in your client which have default implementations that are injected at compile-time: 183 | 184 | * [`fuse_opts()`](`Hardhat.Defaults.fuse_opts/1`) - configuration for the middleware 185 | * [`should_melt(result)`](`Hardhat.Defaults.should_melt/1`) - whether the result of the request is considered a failure 186 | 187 | You can override their default behavior by redefining the functions: 188 | 189 | ```elixir 190 | # This module uses `:fuse` for failure detection and backoff. 191 | defmodule HardCutoffClient do 192 | use Hardhat # defaults to `:fuse` 193 | 194 | # This is also valid: 195 | # use Hardhat, strategy: :fuse 196 | 197 | # Customize fuse's circuit-breaking behavior 198 | def fuse_opts do 199 | [ 200 | # 10 failed requests in 0.5sec flips the breaker, which resets after 1sec 201 | opts: {{:standard, 10, 500}, {:reset, 1_000}} 202 | ] 203 | end 204 | 205 | # Customize how responses are determined to be failures, 206 | # in this case only TCP/adapter-type errors are considered 207 | # failures, any valid response is fine. 208 | def should_melt(result) do 209 | case result do 210 | {:error, _} -> true 211 | {:ok, %Tesla.Env{}} -> false 212 | end 213 | end 214 | end 215 | ``` 216 | 217 | ### The Regulator strategy 218 | 219 | The `:regulator` failure detection strategy is configured with two functions in your client which have default implementations that are injected at compile-time: 220 | 221 | * [`regulator_opts()`](`Hardhat.Defaults.regulator_opts/1`) - configuration for the middleware 222 | * [`should_regulate(result)`](`Hardhat.Defaults.should_regulate/1`) - whether the result of the request is considered a failure 223 | 224 | You can override their default behavior by redefining the functions: 225 | 226 | ```elixir 227 | # This module uses `Regulator` for failure detection and backoff 228 | defmodule DynamicRegulationClient do 229 | use Hardhat, strategy: :regulator # overrides default of `:fuse` 230 | 231 | # Customize Regulator's ramp-up and backoff strategy 232 | def regulator_opts do 233 | [ 234 | # Backoff on failure by half instead of 90% 235 | backoff_ratio: 0.5 236 | ] 237 | end 238 | 239 | # Customize how responses are determined to be failures, 240 | # in this case TCP/adapter-level errors are considered failures, 241 | # as well as HTTP `429 Too Many Requests` responses. 242 | def should_regulate(result) do 243 | case result do 244 | {:error, _} -> true 245 | {:ok, %Tesla.Env{status: 429}} -> true 246 | {:ok, %Tesla.Env{}} -> false 247 | end 248 | end 249 | end 250 | ``` 251 | 252 | ### Disabling failure detection 253 | 254 | > #### Warning {: .warning} 255 | > We do not recommend disabling failure detection and backoff strategies because they expose you to encountering cascading failure and slowdown when the target service or network is encountering issues. 256 | 257 | If you want to disable failure detection, set the strategy to `:none`: 258 | 259 | ```elixir 260 | defmodule WildAndFree do 261 | use Hardhat, strategy: :none 262 | end 263 | ``` 264 | 265 | ## Retries 266 | 267 | `Hardhat` injects automatic retries on your requests using `Tesla.Middleware.Retry`. Retries are configured with two functions in your client which have default implementations that are injected at compile-time: 268 | 269 | * [`retry_opts()`](`Hardhat.Defaults.retry_opts/1`) - configuration for the middleware 270 | * [`should_retry(result)`](`Hardhat.Defaults.should_retry/1`) - whether the result of the request can be retried 271 | 272 | You can override their default behavior by redefining the functions: 273 | 274 | ```elixir 275 | # This client retries requests 276 | defmodule SomeRetriesClient do 277 | use Hardhat 278 | 279 | def retry_opts do 280 | [ 281 | # Retry up to 5 times 282 | max_retries: 5, 283 | # Delay at least 75ms between attempts 284 | delay: 75, 285 | # Delay at most 500ms between any attempts 286 | max_delay: 500 287 | ] 288 | end 289 | end 290 | 291 | # This client disables retries entirely! 292 | defmodule NoRetriesClient do 293 | use Hardhat 294 | 295 | # Override should_retry to disable retries 296 | def should_retry(_), do: false 297 | end 298 | ``` 299 | 300 | > ### Interaction with failure detection {: .warning} 301 | > Retries can interact very negatively with [failure detection](#module-failure-detection), potentially triggering backoff behavior too quickly. Be sure to avoid retrying when the failure detector returns `{:error, :unavailable}`, which indicates that the circuit breaker has blown in the `:fuse` strategy, or the limiter is out of capacity in the `:regulator` strategy. 302 | > 303 | > The default implementation of `should_retry/1` implements this behavior. 304 | 305 | ## Timeouts and deadlines 306 | 307 | Timeouts are an essential liveness technique that help prevent your application from waiting a long time for a slow response to come back from a request. Deadlines extend the timeout pattern to cover entire workflows spanning multiple services, and ensure responsiveness for requests at the edge of your infrastructure while avoiding doing work that will not complete in a timely fashion. 308 | 309 | Hardhat supports both individual timeouts and global deadlines via its custom `Hardhat.Middleware.Timeout` middleware, but it is **not** included in the default middleware stack. 310 | 311 | When a timeout value is specified in the middleware options, the lesser of that value and any active deadline will be used for the timeout duration. If the configured timeout is shorter than the active deadline, the deadline propagated downstream will be set to the shorter timeout value. 312 | 313 | ```elixir 314 | defmodule TimeoutClient do 315 | use Hardhat 316 | 317 | # Requests will time out after 100ms 318 | plug Hardhat.Middleware.Timeout, timeout: 100 319 | end 320 | 321 | # Set a deadline of 50ms 322 | Deadline.set(50) 323 | 324 | # This will timeout after 50ms instead of the configured 100ms 325 | TimeoutClient.get("http://google.com") 326 | 327 | # Set a deadline of 500ms 328 | Deadline.set(500) 329 | 330 | # This will timeout after 100ms (not 500ms), propagating a deadline of 100ms 331 | TimeoutClient.get("http://elixir-lang.org") 332 | ``` 333 | 334 | When a timeout occurs, a `:timeout_exceeded` event will be added to the current `OpenTelemetry` span. 335 | 336 | > ### Using `Tesla.Middleware.Timeout` instead {: .warning} 337 | > Because implementing timeouts requires spawning a process that carries out the rest of the request, we recommend using `Hardhat`'s bundled timeout middleware. If you use the standard middleware bundled with `Tesla`, you must propagate `OpenTelemetry` context and `Deadline` information yourself. 338 | 339 | ### Deadline propagation 340 | 341 | The default middleware stack will propagate any `Deadline` you have set for the current process, regardless of whether you are using the `Hardhat.Middleware.Timeout` middleware in your client. The propagation consists of a request header (default `"deadline"`) whose value is the current deadline as an integer in milliseconds. To change the header name, override the [`deadline_propagation_opts`](`Hardhat.Defaults.deadline_propagation_opts/0`) callback: 342 | 343 | ```elixir 344 | defmodule CustomPropagationClient do 345 | use Hardhat 346 | 347 | def deadline_propagation_opts do 348 | [ 349 | header: "countdown-expires-in" 350 | ] 351 | end 352 | end 353 | ``` 354 | 355 | ## Testing 356 | 357 | Testing HTTP clients can be tricky, partly because they are software designed to interact with the outside world. Here are the primary strategies that one can take when testing `Hardhat` clients: 358 | 359 | * [`Mox`](https://hexdocs.pm/mox/Mox.html), which can generate a mock `Tesla.Adapter`. 360 | * [`Bypass`](https://hexdocs.pm/bypass/Bypass.html), which runs a `Plug` web server to handle requests from your client. 361 | * [`ExVCR`](https://hexdocs.pm/exvcr/readme.html), which replaces the adapter-level library with a double that records and replays responses. 362 | 363 | We do not recommend using `Tesla.Mock` for testing. Any of the three options above have superior behavior under complicated testing conditions, including spawning child processes via timeouts. 364 | 365 | ### `Mox` 366 | 367 | `Mox` allows us to define a custom `Tesla.Adapter` for use only in tests. First, we need to generate the mock adapter (put this in `test_helper.exs`): 368 | 369 | ```elixir 370 | Mox.defmock(MockAdapter, for: Tesla.Adapter) 371 | ``` 372 | 373 | Then configure your `Hardhat`-based client to use this adapter in `config/test.exs`: 374 | 375 | ```elixir 376 | import Config 377 | 378 | config :tesla, MyHardhatClient, adapter: MockAdapter 379 | ``` 380 | 381 | Then in your tests, set expectations on the adapter: 382 | 383 | ```elixir 384 | defmodule MyHardhatClient.Test do 385 | use ExUnit.Case, async: true 386 | import Mox 387 | 388 | # Checks your mock expectations on each test 389 | setup :verify_on_exit! 390 | 391 | test "it works" do 392 | expect(MockAdapter, :call, fn env, opts -> {:ok, %{env | status: 204}} end) 393 | 394 | assert {:ok, %Tesla.Env{status: 204}} = MyHardhatClient.get("https://foo.bar/") 395 | end 396 | end 397 | ``` 398 | 399 | This setup will work even if you are using `Hardhat.Middleware.Timeout` in your middleware, as child processes automatically inherit expectations and stubs defined by `Mox`. 400 | 401 | ### `Bypass` 402 | 403 | `Bypass` starts a web server in a new process that handles requests from your client. In order to use it effectively in tests, you will need to be able to set the hostname for each request (or for the current process), which might be challenging if you are already using `Tesla.Middleware.BaseUrl` in your client. One strategy using the process dictionary is shown below: 404 | 405 | ```elixir 406 | defmodule ClientWithBypassUrl do 407 | use Hardhat 408 | 409 | plug Tesla.Middleware.BaseUrl, base_url() 410 | 411 | def base_url do 412 | Process.get(:bypass_url) || 413 | Application.get_env(:my_app, __MODULE__, [])[:base_url] 414 | end 415 | end 416 | ``` 417 | 418 | And then in the test: 419 | 420 | ```elixir 421 | defmodule ClientWithBypassUrl.Test do 422 | use ExUnit.Case, async: true 423 | 424 | setup do 425 | bypass = Bypass.open() 426 | Process.put(:bypass_url, "http://localhost:#{bypass.port}") 427 | {:ok, %{bypass: bypass}} 428 | end 429 | 430 | test "works with bypass", %{bypass: bypass} do 431 | Bypass.expect(bypass, fn conn -> Plug.Conn.resp(conn, 204, "") end) 432 | 433 | assert {:ok, %Tesla.Env{status: 204}} = ClientWithBypassUrl.get("/test") 434 | end 435 | end 436 | ``` 437 | 438 | ### `ExVCR` 439 | 440 | [`exvcr`](https://hexdocs.pm/exvcr) intercepts calls into specific HTTP client libraries (like `Finch`) and returns pre-determined responses. Developers can execute tests in a recording mode, which will initialize the "cassettes" by executing real requests and recording the real responses into JSON files on disk. Once recorded, a call to `use_cassette` inside the test selects a particular session for replay. 441 | 442 | ```elixir 443 | defmodule PreRecordedClient.Test do 444 | use ExUnit.Case, async: true 445 | # Be sure to set Finch as the adapter in this call, or whatever you configured 446 | # your Hardhat client to use 447 | use ExVCR.Mock, adapter: ExVCR.Adapter.Finch 448 | 449 | test "returns a recorded response" do 450 | use_cassette "example" do 451 | assert {:ok, %Tesla.Env{status: 204}} = PreRecordedClient.get("/") 452 | end 453 | end 454 | end 455 | ``` 456 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :test do 4 | config :opentelemetry, 5 | traces_exporter: :none 6 | end 7 | -------------------------------------------------------------------------------- /lib/hardhat.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat do 2 | @external_resource "README.md" 3 | @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.at(1) 4 | 5 | defmacro __using__(opts \\ []) do 6 | quote do 7 | use Hardhat.Builder, unquote(opts) 8 | end 9 | end 10 | 11 | use Hardhat.Builder 12 | end 13 | -------------------------------------------------------------------------------- /lib/hardhat/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | @impl Application 6 | def start(_start_type, _start_args) do 7 | children = 8 | if Application.get_env(:hardhat, :start_default_client, false) do 9 | [Hardhat] 10 | else 11 | [] 12 | end 13 | 14 | Supervisor.start_link(children, strategy: :one_for_one, name: Hardhat.Supervisor) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hardhat/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Builder do 2 | @moduledoc false 3 | 4 | defmacro __using__(opts \\ []) do 5 | strategy = 6 | case Keyword.get(opts, :strategy, :fuse) do 7 | v when v in [:fuse, :regulator, :none] -> 8 | v 9 | 10 | invalid -> 11 | raise CompileError, 12 | description: "Invalid strategy #{inspect(invalid)}", 13 | file: __CALLER__.file, 14 | line: __CALLER__.line 15 | end 16 | 17 | opts = opts |> Keyword.delete(:strategy) |> Keyword.put_new(:docs, false) 18 | 19 | client_mod = __CALLER__.module 20 | regulator_name = Module.concat(client_mod, Regulator) 21 | 22 | install_regulator = 23 | if strategy == :regulator do 24 | quote location: :keep do 25 | unquote(client_mod).install_regulator() 26 | end 27 | end 28 | 29 | quote location: :keep do 30 | @before_compile Hardhat.Builder 31 | use Tesla.Builder, unquote(opts) 32 | 33 | @strategy unquote(strategy) 34 | 35 | adapter(Tesla.Adapter.Finch, name: __MODULE__.ConnectionPool) 36 | 37 | defmodule ClientSupervisor do 38 | @moduledoc false 39 | use Supervisor 40 | 41 | def start_link(init_arg) do 42 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 43 | end 44 | 45 | def init(opts) do 46 | unquote(install_regulator) 47 | 48 | children = [ 49 | {Finch, 50 | name: unquote(client_mod).ConnectionPool, 51 | pools: unquote(client_mod).pool_configuration(opts)} 52 | ] 53 | 54 | Supervisor.init(children, strategy: :one_for_one) 55 | end 56 | end 57 | 58 | @doc false 59 | def child_spec(opts) do 60 | Supervisor.child_spec(__MODULE__.ClientSupervisor, opts) 61 | end 62 | 63 | @doc false 64 | defdelegate pool_configuration(overrides), to: Hardhat.Defaults 65 | @doc false 66 | defdelegate should_melt(env), to: Hardhat.Defaults 67 | @doc false 68 | def deadline_propagation_opts() do 69 | [] 70 | end 71 | 72 | @doc false 73 | defdelegate should_retry(result), to: Hardhat.Defaults 74 | @doc false 75 | defdelegate should_regulate(result), to: Hardhat.Defaults 76 | 77 | @doc false 78 | def retry_opts() do 79 | [] 80 | end 81 | 82 | @doc false 83 | def fuse_opts() do 84 | [] 85 | end 86 | 87 | @doc false 88 | def regulator_opts() do 89 | [] 90 | end 91 | 92 | @doc false 93 | def opentelemetry_opts() do 94 | [] 95 | end 96 | 97 | @doc false 98 | def install_regulator() do 99 | Regulator.install( 100 | unquote(regulator_name), 101 | {Regulator.Limit.AIMD, Keyword.delete(regulator_opts(), :should_regulate)} 102 | ) 103 | end 104 | 105 | @doc false 106 | def uninstall_regulator() do 107 | Regulator.uninstall(unquote(regulator_name)) 108 | end 109 | 110 | defoverridable pool_configuration: 1, 111 | should_melt: 1, 112 | fuse_opts: 0, 113 | deadline_propagation_opts: 0, 114 | retry_opts: 0, 115 | should_retry: 1, 116 | regulator_opts: 0, 117 | should_regulate: 1, 118 | opentelemetry_opts: 0 119 | end 120 | end 121 | 122 | defmacro __before_compile__(env) do 123 | circuit_breaker = 124 | case Module.get_attribute(env.module, :strategy) do 125 | :fuse -> 126 | quote do 127 | plug( 128 | Tesla.Middleware.Fuse, 129 | Keyword.merge( 130 | Hardhat.Defaults.fuse_opts(__MODULE__), 131 | __MODULE__.fuse_opts() 132 | ) 133 | ) 134 | end 135 | 136 | :regulator -> 137 | quote do 138 | plug( 139 | Hardhat.Middleware.Regulator, 140 | Keyword.merge( 141 | Hardhat.Defaults.regulator_opts(__MODULE__), 142 | __MODULE__.regulator_opts() 143 | ) 144 | ) 145 | end 146 | 147 | :none -> 148 | quote(do: nil) 149 | end 150 | 151 | quote location: :keep do 152 | plug( 153 | Hardhat.Middleware.DeadlinePropagation, 154 | Keyword.merge( 155 | Hardhat.Defaults.deadline_propagation_opts(), 156 | __MODULE__.deadline_propagation_opts() 157 | ) 158 | ) 159 | 160 | plug( 161 | Tesla.Middleware.Retry, 162 | Keyword.merge( 163 | Hardhat.Defaults.retry_opts(__MODULE__), 164 | __MODULE__.retry_opts() 165 | ) 166 | ) 167 | 168 | unquote(circuit_breaker) 169 | plug(Tesla.Middleware.Telemetry) 170 | 171 | plug( 172 | Tesla.Middleware.OpenTelemetry, 173 | Keyword.merge( 174 | Hardhat.Defaults.opentelemetry_opts(), 175 | __MODULE__.opentelemetry_opts() 176 | ) 177 | ) 178 | 179 | plug(Hardhat.Middleware.PathParams) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/hardhat/defaults.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Defaults do 2 | @moduledoc """ 3 | Contains default implementations of functions that can be overridden in 4 | clients that `use Hardhat`. 5 | """ 6 | 7 | @doc """ 8 | The default configuration for the connection pool(s) that will be created when your 9 | client is added to the supervision tree. Overrides to the pool can be passed 10 | at startup time as an argument in the supervision tree (see `Supervisor.child_spec/2`). 11 | 12 | This creates a connection pool of size `10`. See `Finch.start_link/1` for more details. 13 | """ 14 | def pool_configuration(overrides \\ %{}) when is_list(overrides) or is_map(overrides) do 15 | Map.merge( 16 | %{ 17 | default: [size: 10] 18 | }, 19 | Map.new(overrides) 20 | ) 21 | end 22 | 23 | @doc """ 24 | Default implementation of the "melt test" for circuit breaking. This 25 | function will cause the circuit breaker to record an error when the 26 | result of a request is: 27 | 28 | * A TCP-level error, e.g. `{:error, :econnrefused}` 29 | * An HTTP status that indicates a server error or proxy-level error (>= 500) 30 | * An `429 Too Many Requests` HTTP status 31 | """ 32 | def should_melt({:error, _}) do 33 | true 34 | end 35 | 36 | def should_melt({:ok, %Tesla.Env{} = env}) do 37 | env.status >= 500 or env.status == 429 38 | end 39 | 40 | @doc """ 41 | Default implementation of the options to the `Tesla.Middleware.Fuse` that is 42 | included in the default middleware stack. 43 | 44 | Takes the client module name as an argument. 45 | 46 | These defaults include: 47 | - `:opts` - The fuse will blow after 50 errors with 1 second, and reset after 2 seconds 48 | - `:keep_original_error` - The original error will be returned when a fuse first blows 49 | - `:should_melt` - The `should_melt/1` function defined in the client module is used 50 | (by default this is `Hardhat.Defaults.should_melt/1`) 51 | - `:mode` - The fuse uses `:async_dirty` mode to check the failure rate, which may result 52 | in some delay in blowing the fuse under high concurrency, but it will not serialize 53 | checks to the fuse state through a single process 54 | 55 | See `Tesla.Middleware.Fuse` for more information on how to configure the middleware. 56 | """ 57 | def fuse_opts(mod) do 58 | [ 59 | opts: {{:standard, 50, 1_000}, {:reset, 2_000}}, 60 | keep_original_error: true, 61 | should_melt: &mod.should_melt/1, 62 | mode: :async_dirty 63 | ] 64 | end 65 | 66 | @doc """ 67 | Default implementation of the options to `Hardhat.Middleware.DeadlinePropagation` that 68 | is included in the default middleware stack. 69 | 70 | These defaults include: 71 | - `:header` - `"deadline"` is the name of the header added to the request 72 | """ 73 | def deadline_propagation_opts() do 74 | [header: "deadline"] 75 | end 76 | 77 | @doc """ 78 | Default implementation of the options to `Tesla.Middleware.Retry` that is 79 | included in the default middleware stack. 80 | 81 | These defaults include: 82 | - `:delay` - The base delay in milliseconds (positive integer, defaults to 50) 83 | - `:max_retries` - maximum number of retries (non-negative integer, defaults to 3) 84 | - `:max_delay` - maximum delay in milliseconds (positive integer, defaults to 300) 85 | - `:should_retry` - function to determine if request should be retried, defaults to `should_retry/1` 86 | - `:jitter_factor` - additive noise proportionality constant (float between 0 and 1, defaults to 0.2) 87 | """ 88 | def retry_opts(mod) do 89 | [ 90 | delay: 50, 91 | max_retries: 3, 92 | max_delay: 300, 93 | should_retry: &mod.should_retry/1, 94 | jitter_factor: 0.2 95 | ] 96 | end 97 | 98 | @doc """ 99 | Default implementation of the "retry test" for retries. This 100 | function will cause requests to be retried when the result of the 101 | request is: 102 | 103 | * A TCP-level error, e.g. `{:error, :econnrefused}` 104 | * An HTTP status that indicates a server error or proxy-level error (>= 500) 105 | * An `429 Too Many Requests` HTTP status 106 | 107 | In the case where the circuit breaker has been triggered, or the request method 108 | was `POST`, requests will not be retried. 109 | """ 110 | def should_retry({:error, :unavailable}), do: false 111 | 112 | def should_retry({:error, _}), do: true 113 | 114 | def should_retry({:ok, %Tesla.Env{} = env}) do 115 | env.method != :post and (env.status == 429 or env.status >= 500) 116 | end 117 | 118 | @doc """ 119 | Default options for the `Regulator` middleware, which can be used as an 120 | alternative circuit-breaking strategy to `:fuse`. 121 | 122 | The defaults include: 123 | * `:min_limit` - The minimum concurrency limit (defaults to 5) 124 | * `:initial_limit` - The initial concurrency limit when the regulator is installed (deafults to 20) 125 | * `:max_limit` - The maximum concurrency limit (defaults to 200) 126 | * `:step_increase` - The number of tokens to add when regulator is increasing the concurrency limit (defaults to 10). 127 | * `:backoff_ratio` - Floating point value for how quickly to reduce the concurrency limit (defaults to 0.9) 128 | * `:target_avg_latency` - This is the average latency in milliseconds for the system regulator is protecting. If the average latency drifts above this value Regulator considers it an error and backs off. Defaults to 5. 129 | * `:should_regulate` - Whether to consider the result of the request as failed, defaults to `should_regulate/1`. 130 | """ 131 | def regulator_opts(mod) do 132 | [ 133 | min_limit: 5, 134 | initial_limit: 20, 135 | max_limit: 200, 136 | backoff_ratio: 0.9, 137 | target_avg_latency: 5, 138 | step_increase: 10, 139 | should_regulate: &mod.should_regulate/1 140 | ] 141 | end 142 | 143 | @doc """ 144 | Default implementation of the "failure test" for dynamic regulation. This 145 | function will cause the `Regulator` to record an error when the 146 | result of a request is: 147 | 148 | * A TCP-level error, e.g. `{:error, :econnrefused}` 149 | * An HTTP status that indicates a server error or proxy-level error (>= 500) 150 | * An `429 Too Many Requests` HTTP status 151 | """ 152 | def should_regulate({:error, _}) do 153 | true 154 | end 155 | 156 | def should_regulate({:ok, %Tesla.Env{} = env}) do 157 | env.status >= 500 or env.status == 429 158 | end 159 | 160 | @doc """ 161 | Default options for the `Tesla.Middleware.OpenTelemetry` middleware. 162 | 163 | The options include: 164 | * `:span_name` - override span name. Can be a `String` for a static span name, 165 | or a function that takes the `Tesla.Env` and returns a `String`. The 166 | default span name is chosen by the middleware. 167 | * `:propagator` - configures trace headers propagators. Setting it to `:none` 168 | disables propagation. Any module that implements `:otel_propagator_text_map` 169 | can be used. Defaults to calling `:opentelemetry.get_text_map_injector/0` 170 | * `:mark_status_ok` - configures spans with a list of expected HTTP error codes to be 171 | marked as ok, not as an error-containing spans. The default is empty. 172 | """ 173 | def opentelemetry_opts do 174 | [] 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /lib/hardhat/middleware/deadline_propagation.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.DeadlinePropagation do 2 | @moduledoc """ 3 | Propagates `Deadline` information to the server being called via 4 | an HTTP header. _This middleware is part of the default stack_. 5 | 6 | Expects a `:header` option, which defaults to `"deadline"`. See `Hardhat.Defaults.deadline_propagation_opts/0`. 7 | """ 8 | 9 | @behaviour Tesla.Middleware 10 | 11 | @impl Tesla.Middleware 12 | def call(env, next, opts) do 13 | header = Keyword.fetch!(opts, :header) 14 | 15 | env = 16 | case Deadline.time_remaining() do 17 | :infinity -> env 18 | value -> Tesla.put_header(env, header, to_string(value)) 19 | end 20 | 21 | Tesla.run(env, next) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/hardhat/middleware/path_params.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.PathParams do 2 | @moduledoc """ 3 | Use templated URLs with separate params. Unlike `Tesla.Middleware.PathParams`, 4 | we ensure that all parameters are URL-safe. _This middleware is part of the 5 | default stack_. 6 | """ 7 | 8 | @behaviour Tesla.Middleware 9 | 10 | @impl Tesla.Middleware 11 | def call(env, next, _) do 12 | env = 13 | if path_params = env.opts[:path_params] do 14 | urlsafe = 15 | Enum.map(path_params, fn {key, value} -> 16 | {key, URI.encode(value, &URI.char_unreserved?/1)} 17 | end) 18 | 19 | Tesla.put_opt(env, :path_params, urlsafe) 20 | else 21 | env 22 | end 23 | 24 | Tesla.run(env, [{Tesla.Middleware.PathParams, :call, [[]]} | next]) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/hardhat/middleware/regulator.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.Regulator do 2 | @moduledoc """ 3 | Limits concurrent requests to the server based on a [loss based dynamic limit algorithm](`Regulator.Limit.AIMD`). 4 | Additively increases the concurrency limit when there are no errors and multiplicatively 5 | decrements the limit when there are errors. 6 | 7 | _This middleware is part of the default stack when giving the `strategy: :regulator` option to `use Hardhat`._ 8 | 9 | See also: 10 | - Configuration options: `Hardhat.Defaults.regulator_opts/1` 11 | - Determining what amounts to an error: `Hardhat.Defaults.should_regulate/1` 12 | """ 13 | @behaviour Tesla.Middleware 14 | 15 | @impl Tesla.Middleware 16 | def call(env = %Tesla.Env{}, next, opts) do 17 | should_regulate = Keyword.fetch!(opts, :should_regulate) 18 | regname = Module.concat(env.__module__, Regulator) 19 | 20 | with true <- is_pid(Process.whereis(regname)), 21 | {:ok, token} <- Regulator.ask(regname) do 22 | result = Tesla.run(env, next) 23 | 24 | if should_regulate.(result) do 25 | Regulator.error(token) 26 | else 27 | Regulator.ok(token) 28 | end 29 | 30 | result 31 | else 32 | false -> 33 | {:error, {:regulator_not_installed, regname}} 34 | 35 | :dropped -> 36 | {:error, :unavailable} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/hardhat/middleware/timeout.ex: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.Timeout do 2 | @moduledoc """ 3 | Abort HTTP requests after the given timeout or the current `Deadline`, 4 | and emit `OpenTelemetry` trace events on timeouts. 5 | 6 | Options: 7 | - `:timeout` - (required) timeout in milliseconds 8 | 9 | ## OpenTelemetry 10 | 11 | Implementing a timeout necessitates moving the request into a new process, 12 | and then waiting on that new process's completion (or aborting after the timeout). 13 | Since `OpenTelemetry` tracing context is stored in the process dictionary, that 14 | context must be explicitly propagated to the new process. This middleware uses 15 | `OpentelemetryProcessPropagator` for this purpose. 16 | 17 | In the event of a timeout result in this middleware, a new `timeout_exceeded` event 18 | will be added to the trace. The event will include these attributes: 19 | 20 | - `module` - the client module that includes this middleware 21 | - `timeout` - the duration that was exceeded 22 | 23 | ## Deadline 24 | 25 | When the caller has set a `Deadline` for the current process, that limit will be 26 | respected by this middleware. The effective timeout chosen will be the **lesser** of the time 27 | remaining on the current deadline and the duration given in the `:timeout` option. 28 | """ 29 | 30 | alias OpentelemetryProcessPropagator.Task 31 | 32 | @behaviour Tesla.Middleware 33 | 34 | @impl Tesla.Middleware 35 | def call(env = %Tesla.Env{}, next, opts) do 36 | opts = opts || [] 37 | configured_timeout = Keyword.fetch!(opts, :timeout) 38 | 39 | {timeout, is_deadline} = 40 | case Deadline.time_remaining() do 41 | :infinity -> {configured_timeout, false} 42 | value -> {min(value, configured_timeout), true} 43 | end 44 | 45 | task = 46 | if is_deadline do 47 | safe_async(fn -> 48 | Deadline.set(timeout) 49 | Tesla.run(env, next) 50 | end) 51 | else 52 | safe_async(fn -> Tesla.run(env, next) end) 53 | end 54 | 55 | try do 56 | Task.await(task, timeout) 57 | catch 58 | :exit, {:timeout, _} -> 59 | Task.shutdown(task, 0) 60 | 61 | OpenTelemetry.Tracer.add_event(:timeout_exceeded, 62 | module: env.__module__, 63 | timeout: timeout 64 | ) 65 | 66 | {:error, :timeout} 67 | else 68 | {:exception, error, stacktrace} -> 69 | reraise(error, stacktrace) 70 | 71 | {:throw, value} -> 72 | throw(value) 73 | 74 | {:exit, value} -> 75 | exit(value) 76 | 77 | {:ok, result} -> 78 | result 79 | end 80 | end 81 | 82 | defp safe_async(func) do 83 | Task.async(fn -> 84 | try do 85 | {:ok, func.()} 86 | rescue 87 | e in _ -> 88 | {:exception, e, __STACKTRACE__} 89 | catch 90 | type, value -> 91 | {type, value} 92 | end 93 | end) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :hardhat, 7 | version: "1.1.0", 8 | elixir: "~> 1.15", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: "An opinionated, production-ready HTTP client for Elixir services.", 12 | package: package(), 13 | docs: docs() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | mod: {Hardhat.Application, []}, 20 | env: [ 21 | start_default_client: false 22 | ], 23 | registered: [Hardhat.Supervisor, Hardhat, Hardhat.Sup] 24 | ] 25 | end 26 | 27 | defp deps do 28 | [ 29 | {:tesla, "~> 1.7"}, 30 | {:finch, "~> 0.18"}, 31 | {:telemetry, "~> 1.2"}, 32 | {:opentelemetry_tesla, "~> 2.4"}, 33 | {:opentelemetry, "~> 1.3", only: :test}, 34 | {:opentelemetry_process_propagator, "~> 0.3"}, 35 | {:fuse, "~> 2.5"}, 36 | {:regulator, "~> 0.6"}, 37 | {:deadline, "~> 0.7"}, 38 | {:ex_doc, "~> 0.34", only: :dev, runtime: false}, 39 | {:dialyxir, "~> 1.4", only: :dev, runtime: false}, 40 | {:bypass, "~> 2.1", only: :test} 41 | ] 42 | end 43 | 44 | defp package do 45 | [ 46 | licenses: ["MIT"], 47 | links: %{ 48 | "GitHub" => "https://github.com/seancribbs/hardhat" 49 | } 50 | ] 51 | end 52 | 53 | defp docs do 54 | [ 55 | main: "Hardhat", 56 | extras: ["CHANGELOG.md"] 57 | ] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, 3 | "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, 4 | "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, 7 | "deadline": {:hex, :deadline, "0.7.1", "c34bd6b88be2f6ac6cf425a8e638a6f104c0117a60dc0dea9dff097ec27dcf07", [:mix], [], "hexpm", "3ba0f8173dd119c417f89b24207bd68b10f4e8b7bf9f6f20501f8901a3554801"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, 12 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 13 | "fuse": {:hex, :fuse, "2.5.0", "71afa90be21da4e64f94abba9d36472faa2d799c67fedc3bd1752a88ea4c4753", [:rebar3], [], "hexpm", "7f52a1c84571731ad3c91d569e03131cc220ebaa7e2a11034405f0bac46a4fef"}, 14 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 18 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 19 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 20 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 22 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 23 | "norm": {:hex, :norm, "0.13.0", "2c562113f3205e3f195ee288d3bd1ab903743e7e9f3282562c56c61c4d95dec4", [:mix], [{:stream_data, "~> 0.5", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "447cc96dd2d0e19dcb37c84b5fc0d6842aad69386e846af048046f95561d46d7"}, 24 | "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"}, 25 | "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"}, 26 | "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, 27 | "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "0.2.0", "b67fe459c2938fcab341cb0951c44860c62347c005ace1b50f8402576f241435", [:mix, :rebar3], [], "hexpm", "d61fa1f5639ee8668d74b527e6806e0503efc55a42db7b5f39939d84c07d6895"}, 28 | "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.1", "4a73bfa29d7780ffe33db345465919cef875034854649c37ac789eb8e8f38b21", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ee43b14e6866123a3ee1344e3c0d3d7591f4537542c2a925fcdbf46249c9b50b"}, 29 | "opentelemetry_tesla": {:hex, :opentelemetry_tesla, "2.4.0", "028fc37338cf8dd7b91baaf3f480a184f5977f7437e323183ded774eb3541b9e", [:mix], [{:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "7a0d5f204e1db44615fc197f78de7b614357516e0a91bd78f9f95cddf3bf2be8"}, 30 | "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, 31 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, 32 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 33 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 34 | "recon": {:hex, :recon, "2.5.3", "739107b9050ea683c30e96de050bc59248fd27ec147696f79a8797ff9fa17153", [:mix, :rebar3], [], "hexpm", "6c6683f46fd4a1dfd98404b9f78dcabc7fcd8826613a89dcb984727a8c3099d7"}, 35 | "regulator": {:hex, :regulator, "0.6.0", "8adb4cb9779c0513a4ba692896e2de47a09d80fa4bcc84b3c59c096a5a2d8cdd", [:mix], [{:norm, "~> 0.12", [hex: :norm, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4baab6cf5f58d9500b601a8ee200d1f02a6d0e736351f43d65ec66bb9cceb278"}, 36 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 37 | "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, 38 | "tesla": {:hex, :tesla, "1.14.2", "fb1d4f0538bbb8a842c1d94028c886727e345f09f5239395eea19f1baa3314a0", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "ad6ac070f1052c96384693b07811e8b1974d9e98b66f29e0691f99926daf6127"}, 39 | } 40 | -------------------------------------------------------------------------------- /test/hardhat_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HardhatTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | end 7 | 8 | defmodule NoCircuitBreaker do 9 | use Hardhat, strategy: :none 10 | 11 | def fuse_opts() do 12 | [opts: {{:standard, 3, 100}, {:reset, 1000}}] 13 | end 14 | end 15 | 16 | setup do 17 | bypass = Bypass.open() 18 | pool = start_supervised!(TestClient) 19 | start_supervised!(NoCircuitBreaker) 20 | {:ok, %{bypass: bypass, pool: pool}} 21 | end 22 | 23 | test "default connection pool starts and makes requests", %{bypass: bypass, pool: pool} do 24 | assert Process.alive?(pool) 25 | 26 | Bypass.expect_once(bypass, fn conn -> 27 | Plug.Conn.resp(conn, 200, "Hello, world") 28 | end) 29 | 30 | assert {:ok, _conn} = TestClient.get("http://localhost:#{bypass.port}/") 31 | end 32 | 33 | test "does not limit requests when the circuit breaker strategy is :none", %{bypass: bypass} do 34 | status = 503 35 | 36 | Bypass.expect(bypass, fn conn -> 37 | Plug.Conn.resp(conn, status, "Failed") 38 | end) 39 | 40 | for _ <- 0..3 do 41 | assert {:ok, %Tesla.Env{status: ^status}} = 42 | NoCircuitBreaker.get("http://localhost:#{bypass.port}/") 43 | end 44 | 45 | assert {:ok, %Tesla.Env{status: ^status}} = 46 | NoCircuitBreaker.get("http://localhost:#{bypass.port}/") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/middleware/deadline_propagation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.DeadlinePropagationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | end 7 | 8 | defmodule TestClientWithTimeout do 9 | use Hardhat 10 | 11 | plug(Hardhat.Middleware.Timeout, timeout: 1000) 12 | end 13 | 14 | defmodule TestClientWithOptions do 15 | use Hardhat 16 | 17 | def deadline_propagation_opts() do 18 | [header: "countdown"] 19 | end 20 | end 21 | 22 | setup do 23 | bypass = Bypass.open() 24 | start_supervised!(TestClient) 25 | start_supervised!(TestClientWithTimeout) 26 | start_supervised!(TestClientWithOptions) 27 | 28 | {:ok, %{bypass: bypass}} 29 | end 30 | 31 | test "deadline header is not added when there is no deadline set", %{bypass: bypass} do 32 | parent = self() 33 | 34 | Bypass.expect_once(bypass, fn conn -> 35 | send(parent, {:deadline, Plug.Conn.get_req_header(conn, "deadline")}) 36 | Plug.Conn.resp(conn, 200, "hello world") 37 | end) 38 | 39 | assert nil == Deadline.get() 40 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 41 | assert_receive {:deadline, []}, 500 42 | end 43 | 44 | test "deadline header is added when there is a deadline set", %{bypass: bypass} do 45 | parent = self() 46 | 47 | Bypass.expect_once(bypass, fn conn -> 48 | send(parent, {:deadline, Plug.Conn.get_req_header(conn, "deadline")}) 49 | Plug.Conn.resp(conn, 200, "hello world") 50 | end) 51 | 52 | Deadline.set(50) 53 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 54 | assert_receive {:deadline, [deadline]}, 500 55 | assert String.to_integer(deadline) <= 50 56 | end 57 | 58 | test "deadline header is propagated through timeouts", %{bypass: bypass} do 59 | parent = self() 60 | 61 | Bypass.expect_once(bypass, fn conn -> 62 | send(parent, {:deadline, Plug.Conn.get_req_header(conn, "deadline")}) 63 | Plug.Conn.resp(conn, 200, "hello world") 64 | end) 65 | 66 | Deadline.set(500) 67 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 68 | assert_receive {:deadline, [deadline]}, 100 69 | assert String.to_integer(deadline) <= 500 70 | end 71 | 72 | test "deadline header name is configurable", %{bypass: bypass} do 73 | parent = self() 74 | 75 | Bypass.expect_once(bypass, fn conn -> 76 | send(parent, {:deadline, Plug.Conn.get_req_header(conn, "countdown")}) 77 | Plug.Conn.resp(conn, 200, "hello world") 78 | end) 79 | 80 | Deadline.set(50) 81 | assert {:ok, _} = TestClientWithOptions.get("http://localhost:#{bypass.port}/") 82 | assert_receive {:deadline, [deadline]}, 500 83 | assert String.to_integer(deadline) <= 50 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/middleware/fuse_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.FuseTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | 7 | @fuse_thresholds {{:standard, 3, 100}, {:reset, 1000}} 8 | 9 | def fuse_opts do 10 | [opts: @fuse_thresholds] 11 | end 12 | 13 | # NOTE: retries can interact badly with fuse, so let's not retry in test 14 | def retry_opts do 15 | [ 16 | max_retries: 0 17 | ] 18 | end 19 | end 20 | 21 | setup do 22 | bypass = Bypass.open() 23 | pool = start_supervised!(TestClient) 24 | :fuse.reset(TestClient) 25 | {:ok, %{bypass: bypass, pool: pool}} 26 | end 27 | 28 | describe "fuse options" do 29 | test "get injected into the middleware stack for Tesla.Middleware.Fuse" do 30 | assert {Tesla.Middleware.Fuse, :call, [opts]} = 31 | List.keyfind!(TestClient.__middleware__(), Tesla.Middleware.Fuse, 0) 32 | 33 | assert {{:standard, 3, 100}, {:reset, 1000}} = Keyword.fetch!(opts, :opts) 34 | end 35 | end 36 | 37 | describe "default should_melt" do 38 | test "TCP-level errors blow the fuse", %{bypass: bypass} do 39 | Bypass.down(bypass) 40 | 41 | for _ <- 0..3 do 42 | assert {:error, :econnrefused} = TestClient.get("http://localhost:#{bypass.port}/") 43 | end 44 | 45 | assert {:error, :unavailable} = TestClient.get("http://localhost:#{bypass.port}/") 46 | end 47 | 48 | test "HTTP 429 blows the fuse", %{bypass: bypass} do 49 | assert_should_melt_status(bypass, 429) 50 | end 51 | 52 | test "HTTP 500 blows the fuse", %{bypass: bypass} do 53 | assert_should_melt_status(bypass, 500) 54 | end 55 | 56 | test "HTTP 501 blows the fuse", %{bypass: bypass} do 57 | assert_should_melt_status(bypass, 501) 58 | end 59 | 60 | test "HTTP 502 blows the fuse", %{bypass: bypass} do 61 | assert_should_melt_status(bypass, 502) 62 | end 63 | 64 | test "HTTP 503 blows the fuse", %{bypass: bypass} do 65 | assert_should_melt_status(bypass, 503) 66 | end 67 | 68 | defp assert_should_melt_status(bypass, status) do 69 | Bypass.expect(bypass, fn conn -> 70 | Plug.Conn.resp(conn, status, "Failed") 71 | end) 72 | 73 | for _ <- 0..3 do 74 | assert {:ok, %Tesla.Env{status: ^status}} = 75 | TestClient.get("http://localhost:#{bypass.port}/") 76 | end 77 | 78 | assert {:error, :unavailable} = TestClient.get("http://localhost:#{bypass.port}/") 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/middleware/opentelemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.OpentelemetryTest do 2 | use ExUnit.Case, async: false 3 | require Record 4 | 5 | defmodule TestClient do 6 | use Hardhat 7 | end 8 | 9 | defmodule NoPropagationClient do 10 | use Hardhat 11 | 12 | def opentelemetry_opts do 13 | [propagator: :none] 14 | end 15 | end 16 | 17 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do 18 | Record.defrecord(name, spec) 19 | end 20 | 21 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry_api/include/opentelemetry.hrl") do 22 | Record.defrecord(name, spec) 23 | end 24 | 25 | setup do 26 | bypass = Bypass.open() 27 | pool = start_supervised!(TestClient) 28 | start_supervised!(NoPropagationClient) 29 | :application.stop(:opentelemetry) 30 | :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) 31 | :application.set_env(:opentelemetry, :traces_exporter, {:otel_exporter_pid, self()}) 32 | 33 | :application.set_env(:opentelemetry, :processors, [ 34 | {:otel_batch_processor, %{scheduled_delay_ms: 1}} 35 | ]) 36 | 37 | :application.start(:opentelemetry) 38 | 39 | {:ok, %{bypass: bypass, pool: pool}} 40 | end 41 | 42 | test "records spans for simple requests", %{bypass: bypass} do 43 | Bypass.expect_once(bypass, fn conn -> 44 | Plug.Conn.resp(conn, 200, "Hello, world") 45 | end) 46 | 47 | assert {:ok, _conn} = TestClient.get("http://localhost:#{bypass.port}/") 48 | 49 | :otel_tracer_provider.force_flush() 50 | 51 | assert_receive {:span, span(name: "HTTP GET")}, 1000 52 | end 53 | 54 | test "records spans for parameterized requests", %{bypass: bypass} do 55 | Bypass.expect_once(bypass, fn conn -> 56 | Plug.Conn.resp(conn, 200, "Hello, world") 57 | end) 58 | 59 | assert {:ok, _conn} = 60 | TestClient.get("http://localhost:#{bypass.port}/user/:id", 61 | opts: [path_params: [id: "5"]] 62 | ) 63 | 64 | assert_receive {:span, span(name: "/user/:id", attributes: _)}, 1000 65 | end 66 | 67 | test "does not propagate trace when disabled", %{bypass: bypass} do 68 | parent = self() 69 | 70 | Bypass.expect_once(bypass, fn conn -> 71 | send(parent, {:traceheader, Plug.Conn.get_req_header(conn, "traceparent")}) 72 | Plug.Conn.resp(conn, 200, "Hello, world") 73 | end) 74 | 75 | assert {:ok, _conn} = 76 | NoPropagationClient.get("http://localhost:#{bypass.port}/") 77 | 78 | assert_receive {:traceheader, []}, 1000 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/middleware/path_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.PathParamsTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | end 7 | 8 | setup do 9 | bypass = Bypass.open() 10 | pool = start_supervised!(TestClient) 11 | {:ok, %{bypass: bypass, pool: pool}} 12 | end 13 | 14 | test "encodes non-URL-safe characters in path params", %{bypass: bypass} do 15 | Bypass.expect_once(bypass, fn conn -> 16 | Plug.Conn.resp(conn, 200, "Hello, world") 17 | end) 18 | 19 | assert {:ok, env} = 20 | TestClient.get("http://localhost:#{bypass.port}/user/:id", 21 | opts: [path_params: [id: "%^&*foo"]] 22 | ) 23 | 24 | refute env.url == "http://localhost:#{bypass.port}/user/%^&*foo" 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/middleware/regulator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.RegulatorTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule TestClient do 5 | use Hardhat, strategy: :regulator 6 | end 7 | 8 | setup do 9 | bypass = Bypass.open() 10 | TestClient.uninstall_regulator() 11 | pool = start_supervised!(TestClient) 12 | {:ok, %{bypass: bypass, pool: pool}} 13 | end 14 | 15 | describe "failure detector strategy" do 16 | test "uses Hardhat.Middleware.Regulator instead of Fuse" do 17 | assert {Hardhat.Middleware.Regulator, :call, [opts]} = 18 | List.keyfind!(TestClient.__middleware__(), Hardhat.Middleware.Regulator, 0) 19 | 20 | assert (&TestClient.should_regulate/1) == Keyword.fetch!(opts, :should_regulate) 21 | assert 0.9 == Keyword.fetch!(opts, :backoff_ratio) 22 | end 23 | end 24 | 25 | test "returns not-installed error when regulator is missing and does not automatically install it", 26 | %{bypass: bypass} do 27 | TestClient.uninstall_regulator() 28 | 29 | assert {:error, {:regulator_not_installed, TestClient.Regulator}} = 30 | TestClient.get("http://localhost:#{bypass.port}/") 31 | 32 | assert is_nil(Process.whereis(TestClient.Regulator)) 33 | end 34 | 35 | def send_to_test(event, measurements, metadata, pid) do 36 | send(pid, {event, measurements, metadata}) 37 | end 38 | 39 | test "invokes regulator for requests", %{bypass: bypass} do 40 | :telemetry.attach_many( 41 | "invokes regulator", 42 | [ 43 | [:regulator, :ask, :start], 44 | [:regulator, :ask, :stop], 45 | [:regulator, :ask, :exception], 46 | [:regulator, :limit] 47 | ], 48 | &__MODULE__.send_to_test/4, 49 | self() 50 | ) 51 | 52 | Bypass.stub(bypass, "GET", "/", fn conn -> 53 | Plug.Conn.resp(conn, 200, "Hello, world") 54 | end) 55 | 56 | assert {:ok, _env} = TestClient.get("http://localhost:#{bypass.port}/") 57 | 58 | assert_receive {[:regulator, :ask, :start], %{system_time: _, inflight: _}, 59 | %{regulator: TestClient.Regulator}} 60 | 61 | assert_receive {[:regulator, :ask, :stop], %{duration: _}, 62 | %{regulator: TestClient.Regulator, result: :ok}} 63 | 64 | # NOTE: We never call Regulator.ask/2 with a function, we are always taking the token to handle 65 | # ourselves, so we will never get :exception events 66 | refute_receive {[:regulator, :ask, :exception], _, %{regulator: TestClient.Regulator}} 67 | 68 | # NOTE: We sent only one request without any concurrency, so we should not trigger a limit change 69 | refute_receive {[:regulator, :limit], %{limit: _}, %{regulator: TestClient.Regulator}} 70 | 71 | :telemetry.detach("invokes regulator") 72 | end 73 | 74 | test "records errors based on should_regulate/1", %{bypass: bypass} do 75 | :telemetry.attach_many( 76 | "regulator errors", 77 | [ 78 | [:regulator, :ask, :start], 79 | [:regulator, :ask, :stop], 80 | [:regulator, :ask, :exception], 81 | [:regulator, :limit] 82 | ], 83 | &__MODULE__.send_to_test/4, 84 | self() 85 | ) 86 | 87 | Bypass.stub(bypass, "GET", "/", fn conn -> 88 | Plug.Conn.resp(conn, 503, "Bad upstream") 89 | end) 90 | 91 | assert {:ok, _env} = TestClient.get("http://localhost:#{bypass.port}/") 92 | 93 | assert_receive {[:regulator, :ask, :start], %{system_time: _, inflight: _}, 94 | %{regulator: TestClient.Regulator}} 95 | 96 | assert_receive {[:regulator, :ask, :stop], %{duration: _}, 97 | %{regulator: TestClient.Regulator, result: :error}} 98 | 99 | # NOTE: We never call Regulator.ask/2 with a function, we are always taking the token to handle 100 | # ourselves, so we will never get :exception events 101 | refute_receive {[:regulator, :ask, :exception], _, %{regulator: TestClient.Regulator}} 102 | 103 | # NOTE: We sent only one request without any concurrency, so we should not trigger a limit change 104 | refute_receive {[:regulator, :limit], %{limit: _}, %{regulator: TestClient.Regulator}} 105 | 106 | :telemetry.detach("regulator errors") 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/middleware/retry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.RetryTest do 2 | use ExUnit.Case, async: false 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | end 7 | 8 | setup do 9 | bypass = Bypass.open() 10 | pool = start_supervised!(TestClient) 11 | :fuse.circuit_enable(TestClient) 12 | :fuse.reset(TestClient) 13 | {:ok, %{bypass: bypass, pool: pool}} 14 | end 15 | 16 | describe "retry options" do 17 | test "get injected into the middleware stack for Tesla.Middleware.Retry" do 18 | assert {Tesla.Middleware.Retry, :call, [opts]} = 19 | List.keyfind!(TestClient.__middleware__(), Tesla.Middleware.Retry, 0) 20 | 21 | assert 50 = Keyword.fetch!(opts, :delay) 22 | end 23 | end 24 | 25 | describe "default should_retry" do 26 | test "does not retry when the fuse is blown", %{bypass: bypass} do 27 | parent = self() 28 | 29 | Bypass.stub(bypass, "GET", "/", fn conn -> 30 | send(parent, :request) 31 | Plug.Conn.resp(conn, 200, "Hello, world") 32 | end) 33 | 34 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 35 | assert_receive :request 36 | 37 | :fuse.circuit_disable(TestClient) 38 | 39 | assert {:error, :unavailable} = TestClient.get("http://localhost:#{bypass.port}/") 40 | 41 | refute_receive :request 42 | end 43 | 44 | test "does not retry on POST requests", %{bypass: bypass} do 45 | parent = self() 46 | 47 | Bypass.stub(bypass, "POST", "/", fn conn -> 48 | send(parent, :request) 49 | Plug.Conn.resp(conn, 500, "Oops") 50 | end) 51 | 52 | assert {:ok, %Tesla.Env{status: 500}} = 53 | TestClient.post("http://localhost:#{bypass.port}/", "boom") 54 | 55 | assert_receive :request 56 | 57 | refute_receive :request, 1000, "request was retried!" 58 | end 59 | 60 | test "retries when there is a TCP-level error", %{bypass: bypass} do 61 | parent = self() 62 | 63 | adapter = fn _ -> 64 | send(parent, :request) 65 | {:error, :econnrefused} 66 | end 67 | 68 | client = Tesla.client([], adapter) 69 | 70 | assert {:error, :econnrefused} = TestClient.get(client, "http://localhost:#{bypass.port}/") 71 | 72 | for _ <- 0..3 do 73 | assert_receive :request 74 | end 75 | end 76 | 77 | test "retries HTTP 429", %{bypass: bypass} do 78 | assert_retries_status_code(bypass, 429) 79 | end 80 | 81 | test "retries HTTP 500", %{bypass: bypass} do 82 | assert_retries_status_code(bypass, 500) 83 | end 84 | 85 | test "retries HTTP 501", %{bypass: bypass} do 86 | assert_retries_status_code(bypass, 501) 87 | end 88 | 89 | test "retries HTTP 502", %{bypass: bypass} do 90 | assert_retries_status_code(bypass, 502) 91 | end 92 | 93 | test "retries HTTP 503", %{bypass: bypass} do 94 | assert_retries_status_code(bypass, 503) 95 | end 96 | 97 | defp assert_retries_status_code(bypass, status) do 98 | parent = self() 99 | 100 | Bypass.stub(bypass, "GET", "/", fn conn -> 101 | send(parent, :request) 102 | Plug.Conn.resp(conn, status, "Hello, world") 103 | end) 104 | 105 | assert {:ok, env} = TestClient.get("http://localhost:#{bypass.port}/") 106 | assert env.status == status 107 | 108 | for _ <- 0..3 do 109 | assert_receive :request 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/middleware/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.TelemetryTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule TestClient do 5 | use Hardhat 6 | end 7 | 8 | setup do 9 | bypass = Bypass.open() 10 | pool = start_supervised!(TestClient) 11 | {:ok, %{bypass: bypass, pool: pool}} 12 | end 13 | 14 | def test_handler(event, measures, metadata, test_pid) do 15 | send(test_pid, {event, measures, metadata}) 16 | end 17 | 18 | test "emits telemetry hooks for requests", %{bypass: bypass} do 19 | Bypass.expect_once(bypass, fn conn -> 20 | Plug.Conn.resp(conn, 200, "Hello, world") 21 | end) 22 | 23 | :telemetry.attach( 24 | "telemetry test", 25 | [:tesla, :request, :stop], 26 | &__MODULE__.test_handler/4, 27 | self() 28 | ) 29 | 30 | assert {:ok, _conn} = 31 | TestClient.get("http://localhost:#{bypass.port}/user/:id", 32 | opts: [path_params: [id: "5"]] 33 | ) 34 | 35 | assert_receive {[:tesla, :request, :stop], %{duration: _}, %{env: _}}, 1000 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/middleware/timeout_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hardhat.Middleware.TimeoutTest do 2 | use ExUnit.Case, async: false 3 | require Record 4 | require OpenTelemetry.Tracer 5 | 6 | defmodule TestClient do 7 | use Hardhat 8 | 9 | plug(Hardhat.Middleware.Timeout, timeout: 100) 10 | end 11 | 12 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/otel_span.hrl") do 13 | Record.defrecord(name, spec) 14 | end 15 | 16 | for {name, spec} <- Record.extract_all(from_lib: "opentelemetry_api/include/opentelemetry.hrl") do 17 | Record.defrecord(name, spec) 18 | end 19 | 20 | setup do 21 | bypass = Bypass.open() 22 | pool = start_supervised!(TestClient) 23 | :application.stop(:opentelemetry) 24 | :application.set_env(:opentelemetry, :tracer, :otel_tracer_default) 25 | :application.set_env(:opentelemetry, :traces_exporter, {:otel_exporter_pid, self()}) 26 | 27 | :application.set_env(:opentelemetry, :processors, [ 28 | {:otel_batch_processor, %{scheduled_delay_ms: 1}} 29 | ]) 30 | 31 | :application.start(:opentelemetry) 32 | 33 | {:ok, %{bypass: bypass, pool: pool}} 34 | end 35 | 36 | test "returns timeout error after the specified period", %{bypass: bypass} do 37 | Bypass.expect_once(bypass, fn conn -> 38 | Process.sleep(1_000) 39 | Plug.Conn.resp(conn, 200, "Hello, world") 40 | end) 41 | 42 | Process.flag(:trap_exit, true) 43 | 44 | pid = 45 | spawn_link(fn -> 46 | # Deadlines will not affect this request because it unset 47 | assert nil == Deadline.get() 48 | assert {:error, :timeout} = TestClient.get("http://localhost:#{bypass.port}/") 49 | end) 50 | 51 | assert_receive {:EXIT, ^pid, :normal}, 500 52 | Bypass.pass(bypass) 53 | end 54 | 55 | test "propagates OpenTelemetry tracing context into the timeout", %{bypass: bypass} do 56 | Bypass.expect_once(bypass, fn conn -> 57 | Plug.Conn.resp(conn, 200, "Hello, world") 58 | end) 59 | 60 | OpenTelemetry.Tracer.with_span "request" do 61 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 62 | end 63 | 64 | assert_receive {:span, span(name: "request", span_id: span_id)} 65 | assert_receive {:span, span(name: "HTTP GET", parent_span_id: ^span_id)} 66 | end 67 | 68 | test "adds a span event to the current OpenTelemetry span when timeout is exceeded", %{ 69 | bypass: bypass 70 | } do 71 | Bypass.expect_once(bypass, fn conn -> 72 | Process.sleep(1_000) 73 | Plug.Conn.resp(conn, 200, "Hello, world") 74 | end) 75 | 76 | OpenTelemetry.Tracer.with_span "request" do 77 | assert {:error, :timeout} = TestClient.get("http://localhost:#{bypass.port}/") 78 | end 79 | 80 | assert_receive {:span, 81 | span( 82 | name: "request", 83 | span_id: span_id, 84 | events: {:events, _, _, _, _, [event]} 85 | )} 86 | 87 | assert event(name: :timeout_exceeded, attributes: {:attributes, _, _, _, attrs}) = event 88 | assert %{module: TestClient, timeout: 100} = attrs 89 | 90 | refute_receive {:span, span(name: "HTTP GET", parent_span_id: ^span_id)} 91 | Bypass.pass(bypass) 92 | end 93 | 94 | test "uses the smaller of the set deadline or the configured timeout", %{bypass: bypass} do 95 | # Normal timeout is used when deadline is not set 96 | assert nil == Deadline.get() 97 | 98 | Bypass.expect_once(bypass, fn conn -> 99 | Process.sleep(40) 100 | Plug.Conn.resp(conn, 200, "Hello, world") 101 | end) 102 | 103 | assert {:ok, _} = TestClient.get("http://localhost:#{bypass.port}/") 104 | 105 | # Set a deadline that is smaller than the configured timeout 106 | Bypass.expect_once(bypass, fn conn -> 107 | Process.sleep(75) 108 | Plug.Conn.resp(conn, 200, "Hello, world") 109 | end) 110 | 111 | Deadline.set(25) 112 | assert {:error, :timeout} = TestClient.get("http://localhost:#{bypass.port}/") 113 | 114 | # Set a deadline that is larger than the configured timeout 115 | Bypass.expect_once(bypass, fn conn -> 116 | Process.sleep(120) 117 | Plug.Conn.resp(conn, 200, "Hello, world") 118 | end) 119 | 120 | Deadline.set(200) 121 | assert {:error, :timeout} = TestClient.get("http://localhost:#{bypass.port}/") 122 | Bypass.pass(bypass) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------