├── .credo.exs ├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── Finch_logo_all-White.png └── Finch_logo_onWhite.png ├── lib ├── finch.ex └── finch │ ├── error.ex │ ├── http1 │ ├── conn.ex │ ├── pool.ex │ └── pool_metrics.ex │ ├── http2 │ ├── pool.ex │ ├── pool_metrics.ex │ └── request_stream.ex │ ├── pool.ex │ ├── pool_manager.ex │ ├── request.ex │ ├── response.ex │ ├── ssl.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock └── test ├── finch ├── http1 │ ├── integration_proxy_test.exs │ ├── integration_test.exs │ ├── pool_metrics_test.exs │ ├── pool_test.exs │ └── telemetry_test.exs └── http2 │ ├── integration_test.exs │ ├── pool_metrics_test.exs │ ├── pool_test.exs │ └── telemetry_test.exs ├── finch_request_test.exs ├── finch_test.exs ├── fixtures ├── selfsigned.pem └── selfsigned_key.pem ├── support ├── finch_case.ex ├── http1_server.ex ├── http2_server.ex ├── https1_server.ex ├── mock_http2_server.ex ├── mock_socket_server.ex └── test_usage.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "test/"], 25 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 26 | }, 27 | # 28 | # Load and configure plugins here: 29 | # 30 | plugins: [], 31 | # 32 | # If you create your own checks, you must specify the source files for 33 | # them here, so they can be loaded by Credo before running the analysis. 34 | # 35 | requires: [], 36 | # 37 | # If you want to enforce a style guide and need a more traditional linting 38 | # experience, you can change `strict` to `true` below: 39 | # 40 | strict: false, 41 | # 42 | # If you want to use uncolored output by default, you can change `color` 43 | # to `false` below: 44 | # 45 | color: true, 46 | # 47 | # You can customize the parameters of any check by adding a second element 48 | # to the tuple. 49 | # 50 | # To disable a check put `false` as second element: 51 | # 52 | # {Credo.Check.Design.DuplicatedCode, false} 53 | # 54 | checks: [ 55 | # 56 | ## Consistency Checks 57 | # 58 | {Credo.Check.Consistency.ExceptionNames, []}, 59 | {Credo.Check.Consistency.LineEndings, []}, 60 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 61 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 62 | {Credo.Check.Consistency.SpaceAroundOperators, false}, 63 | {Credo.Check.Consistency.SpaceInParentheses, false}, 64 | {Credo.Check.Consistency.TabsOrSpaces, []}, 65 | {Credo.Check.Consistency.UnusedVariableNames, false}, 66 | 67 | # 68 | ## Design Checks 69 | # 70 | # You can customize the priority of any check 71 | # Priority values are: `low, normal, high, higher` 72 | # 73 | {Credo.Check.Design.AliasUsage, 74 | [priority: :low, if_nested_deeper_than: 4, if_called_more_often_than: 2]}, 75 | {Credo.Check.Design.DuplicatedCode, false}, 76 | {Credo.Check.Design.TagTODO, false}, 77 | {Credo.Check.Design.TagFIXME, false}, 78 | 79 | # 80 | ## Readability Checks 81 | # 82 | {Credo.Check.Readability.AliasAs, false}, 83 | {Credo.Check.Readability.AliasOrder, false}, 84 | {Credo.Check.Readability.FunctionNames, []}, 85 | {Credo.Check.Readability.LargeNumbers, []}, 86 | {Credo.Check.Readability.MaxLineLength, false}, 87 | {Credo.Check.Readability.ModuleAttributeNames, []}, 88 | {Credo.Check.Readability.ModuleDoc, []}, 89 | {Credo.Check.Readability.ModuleNames, []}, 90 | {Credo.Check.Readability.MultiAlias, false}, 91 | {Credo.Check.Readability.ParenthesesInCondition, []}, 92 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 93 | {Credo.Check.Readability.PredicateFunctionNames, false}, 94 | {Credo.Check.Readability.PreferImplicitTry, []}, 95 | {Credo.Check.Readability.RedundantBlankLines, [max_blank_lines: 1]}, 96 | {Credo.Check.Readability.Semicolons, []}, 97 | {Credo.Check.Readability.SinglePipe, false}, 98 | {Credo.Check.Readability.SpaceAfterCommas, false}, 99 | {Credo.Check.Readability.Specs, false}, 100 | {Credo.Check.Readability.StringSigils, []}, 101 | {Credo.Check.Readability.TrailingBlankLine, []}, 102 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 103 | # TODO: enable by default in Credo 1.1 104 | {Credo.Check.Readability.UnnecessaryAliasExpansion, false}, 105 | {Credo.Check.Readability.VariableNames, []}, 106 | 107 | # 108 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 109 | # 110 | 111 | # 112 | ## Refactoring Opportunities 113 | # 114 | {Credo.Check.Refactor.ABCSize, false}, 115 | {Credo.Check.Refactor.AppendSingleItem, false}, 116 | {Credo.Check.Refactor.CaseTrivialMatches, false}, 117 | {Credo.Check.Refactor.CondStatements, false}, 118 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 18]}, 119 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 120 | {Credo.Check.Refactor.FunctionArity, []}, 121 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 122 | {Credo.Check.Refactor.MapInto, false}, 123 | {Credo.Check.Refactor.MatchInCondition, false}, 124 | {Credo.Check.Refactor.ModuleDependencies, false}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, false}, 128 | {Credo.Check.Refactor.PipeChainStart, false}, 129 | {Credo.Check.Refactor.UnlessWithElse, []}, 130 | {Credo.Check.Refactor.VariableRebinding, false}, 131 | {Credo.Check.Refactor.WithClauses, []}, 132 | 133 | # 134 | ## Warnings 135 | # 136 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 137 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 138 | {Credo.Check.Warning.IExPry, []}, 139 | {Credo.Check.Warning.IoInspect, []}, 140 | {Credo.Check.Warning.LazyLogging, false}, 141 | {Credo.Check.Warning.MapGetUnsafePass, false}, 142 | {Credo.Check.Warning.OperationOnSameValues, []}, 143 | {Credo.Check.Warning.OperationWithConstantResult, false}, 144 | {Credo.Check.Warning.RaiseInsideRescue, []}, 145 | {Credo.Check.Warning.UnsafeToAtom, false}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []}, 154 | ] 155 | } 156 | ] 157 | } 158 | 159 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | mix_test: 9 | runs-on: ubuntu-20.04 10 | env: 11 | MIX_ENV: test 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: "1.13" 18 | otp: "22" 19 | - pair: 20 | elixir: "1.18" 21 | otp: "27" 22 | lint: lint 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/cache@v4 26 | with: 27 | path: deps 28 | key: deps-${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-${{ hashFiles('**/mix.lock') }} 29 | restore-keys: deps-${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}- 30 | - uses: actions/cache@v4 31 | with: 32 | path: _build 33 | key: build-${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}-${{ hashFiles('**/mix.lock') }} 34 | restore-keys: build-${{ runner.os }}-${{ matrix.pair.otp }}-${{ matrix.pair.elixir }}- 35 | - uses: erlef/setup-beam@v1 36 | with: 37 | otp-version: ${{matrix.pair.otp}} 38 | elixir-version: ${{matrix.pair.elixir}} 39 | 40 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 41 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 42 | - name: Cache Dialyzer's PLT 43 | uses: actions/cache@v4 44 | if: ${{ matrix.lint }} 45 | id: cache-plt 46 | with: 47 | path: _build/test 48 | key: | 49 | ${{ runner.os }}-plt-otp${{ matrix.erlang }}-elixir${{ matrix.elixir }} 50 | 51 | - name: Install Dependencies 52 | run: mix deps.get 53 | 54 | # Create PLTs if no cache was found 55 | - name: Create PLTs 56 | if: ${{ matrix.lint && steps.cache-plt.outputs.cache-hit != 'true' }} 57 | run: mix dialyzer --plt 58 | 59 | - run: mix format --check-formatted 60 | if: ${{ matrix.lint }} 61 | 62 | - run: mix deps.unlock --check-unused 63 | if: ${{ matrix.lint }} 64 | 65 | - run: mix deps.compile 66 | 67 | - run: mix compile --warnings-as-errors 68 | if: ${{ matrix.lint }} 69 | 70 | - name: Run Credo 71 | run: mix credo 72 | if: ${{ matrix.lint }} 73 | 74 | - name: Run Tests 75 | run: mix test 76 | if: ${{ ! matrix.lint }} 77 | 78 | - name: Run Tests 79 | run: mix test --warnings-as-errors 80 | if: ${{ matrix.lint }} 81 | 82 | - name: Run Dialyzer 83 | run: mix dialyzer 84 | if: ${{ matrix.lint }} 85 | -------------------------------------------------------------------------------- /.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 | finch-*.tar 24 | 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.19.0 (2024-09-04) 4 | 5 | ### Enhancements 6 | 7 | - Update @mint_tls_opts in pool_manager.ex #266 8 | - Document there is no backpressure on HTTP2 #283 9 | - Fix test: compare file size instead of map #284 10 | - Finch.request/3: Use improper list and avoid Enum.reverse #286 11 | - Require Mint 1.6 #287 12 | - Remove castore dependency #274 13 | - Fix typos and improve language in docs and comments #285 14 | - fix logo size in README #275 15 | 16 | ### Bug Fixes 17 | 18 | - Tweak Finch supervisor children startup order #289, fixes #277 19 | - implement handle_cancelled/2 pool callback #268, fixes #257 20 | - type Finch.request_opt() was missing the :request_timeout option #278 21 | 22 | ## v0.18.0 (2024-02-09) 23 | 24 | ### Enhancements 25 | 26 | - Add Finch name to telemetry events #252 27 | 28 | ### Bug Fixes 29 | 30 | - Fix several minor dialyzer errors and run dialyzer in CI #259, #261 31 | 32 | ## v0.17.0 (2024-01-07) 33 | 34 | ### Enhancements 35 | 36 | - Add support for async requests #228, #231 37 | - Add stream example to docs #230 38 | - Fix calls to deprecated Logger.warn/2 #232 39 | - Fix typos #233 40 | - Docs: do not use streams with async_request #238 41 | - Add Finch.stream_while/5 #239 42 | - Set MIX_ENV=test on CI #241 43 | - Update HTTP/2 pool log level to warning for retried action #240 44 | - Split trailers from headers #242 45 | - Introduce :request_timeout option #244 46 | - Support ALPN over HTTP1 pools #250 47 | - Deprecate :protocol in favour of :protocols #251 48 | - Implement pool telemetry #248 49 | 50 | ## v0.16.0 (2023-04-13) 51 | 52 | ### Enhancements 53 | 54 | - add `Finch.request!/3` #219 55 | - allow usage with nimble_pool 1.0 #220 56 | 57 | ## v0.15.0 (2023-03-16) 58 | 59 | ### Enhancements 60 | 61 | - allow usage with nimble_options 1.0 #218 62 | - allow usage with castore 1.0 #210 63 | 64 | ## v0.14.0 (2022-11-30) 65 | 66 | ### Enhancements 67 | 68 | - Improve error message for pool timeouts #126 69 | - Relax nimble_options version to allow usage with 0.5.0 #204 70 | 71 | ## v0.13.0 (2022-07-26) 72 | 73 | ### Enhancements 74 | 75 | - Define `Finch.child_spec/1` which will automatically use the `Finch` `:name` as the `:id`, allowing users to start multiple instances under the same Supervisor without any additional configuration #202 76 | - Include the changelog in the generated HexDocs #201 77 | - Fix typo in `Finch.Telemetry` docs #198 78 | 79 | ## v0.12.0 (2022-05-03) 80 | 81 | ### Enhancements 82 | 83 | - Add support for private request metadata #180 84 | - Hide docs for deprecated `Finch.request/6` #195 85 | - Add support for Mint.UnsafeProxy connections #184 86 | 87 | ### Bug Fixes 88 | 89 | - In v0.11.0 headers and status codes were added to Telemetry events in a way that made invalid assumptions 90 | regarding the shape of the response accumulator, this has been resolved in #196 91 | 92 | ### Breaking Changes 93 | 94 | - Telemetry updates #176 95 | - Rename the telemetry event `:request` to `:send` and `:response` to `:recv`. 96 | - Introduce a new `:request` field which contains the full `Finch.Request.t()` in place of the `:scheme`, `:host`, `:port`, `:path`, `:method` fields wherever possible. The new `:request` field can be found on the `:request`, `:queue`, `:send`, and `:recv` events. 97 | - Rename the meta data field `:error` to `:reason` for all `:exception` events to follow the standard introduced in [telemetry](https://github.com/beam-telemetry/telemetry/blob/3f069cfd2193396bee221d0709287c1bdaa4fabf/src/telemetry.erl#L335) 98 | - Introduce a new `[:finch, :request, :start | :stop | :exception]` telemetry event that emits 99 | whenever `Finch.request/3` or `Finch.stream/5` are called. 100 | 101 | ## v0.11.0 (2022-03-28) 102 | 103 | - Add `:pool_max_idle_time` option to enable termination of idle HTTP/1 pools. 104 | - Add `:conn_max_idle_time` and deprecate `:max_idle_time` to make the distinction from 105 | `:pool_max_idle_time` more obvious. 106 | - Add headers and status code to Telemetry events. 107 | 108 | ## v0.10.2 (2022-01-12) 109 | 110 | - Complete the typespec for Finch.Request.t() 111 | - Fix the typespec for Finch.build/5 112 | - Update deps 113 | 114 | ## v0.10.1 (2021-12-27) 115 | 116 | - Fix handling of iodata in HTTP/2 request streams. 117 | 118 | ## v0.10.0 (2021-12-12) 119 | 120 | - Add ability to stream the request body for HTTP/2 requests. 121 | - Check and respect window sizes during HTTP/2 requests. 122 | 123 | ## v0.9.1 (2021-10-17) 124 | 125 | - Upgrade NimbleOptions dep to 0.4.0. 126 | 127 | ## v0.9.0 (2021-10-17) 128 | 129 | - Add support for unix sockets. 130 | 131 | ## v0.8.3 (2021-10-15) 132 | 133 | - Return Error struct when HTTP2 connection is closed and a timeout occurs. 134 | - Do not leak messages/connections when cancelling streaming requests. 135 | 136 | ## v0.8.2 (2021-09-09) 137 | 138 | - Demonitor http/2 connections when the request is done. 139 | 140 | ## v0.8.1 (2021-07-27) 141 | 142 | - Update mix.exs to allow compatibility with Telemetry v1.0 143 | - Avoid appending "?" to request_path when query string is an empty string 144 | 145 | ## v0.8.0 (2021-06-23) 146 | 147 | - HTTP2 connections will now always return Exceptions. 148 | 149 | ## v0.7.0 (2021-05-10) 150 | 151 | - Add support for SSLKEYLOGFILE. 152 | - Drop HTTPS options for default HTTP pools to avoid `:badarg` errors. 153 | 154 | ## v0.6.3 (2021-02-22) 155 | 156 | - Return more verbose errors when finch is configured with bad URLs. 157 | 158 | ## v0.6.2 (2021-02-19) 159 | 160 | - Fix incorrect type spec for stream/5 161 | - Add default transport options for keepalive, timeouts, and nodelay. 162 | 163 | ## v0.6.1 (2021-02-17) 164 | 165 | - Update Mint to 1.2.1, which properly handles HTTP/1.0 style responses that close 166 | the connection at the same time as sending the response. 167 | - Update NimblePool to 0.2.4 which includes a bugfix that prevents extra connections 168 | being opened. 169 | - Fix the typespec for Finch.stream/5. 170 | - Fix assertion that was not actually being called in a test case. 171 | 172 | ## v0.6.0 (2020-12-15) 173 | 174 | - Add ability to stream the request body for HTTP/1.x requests. 175 | 176 | ## v0.5.2 (2020-11-10) 177 | 178 | - Fix deprecation in nimble_options. 179 | 180 | ## v0.5.1 (2020-10-27) 181 | 182 | - Fix crash in http2 pools when a message is received in disconnected state. 183 | 184 | ## v0.5.0 (2020-10-26) 185 | 186 | - Add `:max_idle_time` option for http1 pools 187 | - Optimize http2 connection closing. 188 | - Use new lazy pools in NimblePool 189 | - Additional `idle_time` measurements for all http1 connection telemetry 190 | 191 | ## v0.4.0 (2020-10-2) 192 | 193 | - Update all dependencies. This includes bug fixes for Mint. 194 | 195 | ## v0.3.2 (2020-09-18) 196 | 197 | - Add metadata to connection start telemetry in http/2 pools 198 | 199 | ## v0.3.1 (2020-08-29) 200 | 201 | - Add HTTP method to telemetry events 202 | - BUGFIX - Include query parameters in HTTP/2 requests 203 | 204 | ## v0.3.0 (2020-06-24) 205 | 206 | - HTTP/2 support 207 | - Streaming support for both http/1.1 and http/2 pools 208 | - New api for building and making requests 209 | - typespec fixes 210 | 211 | ## v0.2.0 (2020-05-06) 212 | 213 | - Response body now defaults to an empty string instead of nil 214 | 215 | ## v0.1.1 (2020-05-04) 216 | 217 | - Accepts a URI struct in request/3/4/5/6, Todd Resudek 218 | - Fix `http_method()` typespec, Ryan Johnson 219 | 220 | ## v0.1.0 (2020-04-25) 221 | 222 | - Initial Release 223 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christopher Jon Keathley & Nico Daniel Piderman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Finch 2 | Finch 3 | 4 | [![CI](https://github.com/sneako/finch/actions/workflows/elixir.yml/badge.svg)](https://github.com/sneako/finch/actions/workflows/elixir.yml) 5 | [![Hex pm](https://img.shields.io/hexpm/v/finch.svg?style=flat)](https://hex.pm/packages/finch) 6 | [![Hexdocs.pm](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/finch/) 7 | 8 | 9 | 10 | An HTTP client with a focus on performance, built on top of 11 | [Mint](https://github.com/elixir-mint/mint) and [NimblePool](https://github.com/dashbitco/nimble_pool). 12 | 13 | We attempt to achieve this goal by providing efficient connection pooling strategies and avoiding copying of memory wherever possible. 14 | 15 | Most developers will most likely prefer to use the fabulous HTTP client [Req](https://github.com/wojtekmach/req) which takes advantage of Finch's pooling and provides an extremely friendly and pleasant to use API. 16 | 17 | ## Usage 18 | 19 | In order to use Finch, you must start it and provide a `:name`. Often in your 20 | supervision tree: 21 | 22 | ```elixir 23 | children = [ 24 | {Finch, name: MyFinch} 25 | ] 26 | ``` 27 | 28 | Or, in rare cases, dynamically: 29 | 30 | ```elixir 31 | Finch.start_link(name: MyFinch) 32 | ``` 33 | 34 | Once you have started your instance of Finch, you are ready to start making requests: 35 | 36 | ```elixir 37 | Finch.build(:get, "https://hex.pm") |> Finch.request(MyFinch) 38 | ``` 39 | 40 | When using HTTP/1, Finch will parse the passed in URL into a `{scheme, host, port}` 41 | tuple, and maintain one or more connection pools for each `{scheme, host, port}` you 42 | interact with. 43 | 44 | You can also configure a pool size and count to be used for specific URLs that are 45 | known before starting Finch. The passed URLs will be parsed into `{scheme, host, port}`, 46 | and the corresponding pools will be started. See `Finch.start_link/1` for configuration 47 | options. 48 | 49 | ```elixir 50 | children = [ 51 | {Finch, 52 | name: MyConfiguredFinch, 53 | pools: %{ 54 | :default => [size: 10, count: 2], 55 | "https://hex.pm" => [size: 32, count: 8] 56 | }} 57 | ] 58 | ``` 59 | 60 | Pools will be started for each configured `{scheme, host, port}` when Finch is started. 61 | For any unconfigured `{scheme, host, port}`, the pool will be started the first time 62 | it is requested using the `:default` configuration. This means given the pool 63 | configuration above each origin/`{scheme, host, port}` will launch 2 (`:count`) new pool 64 | processes. So, if you encountered 10 separate combinations, that'd be 20 pool processes. 65 | 66 | Note pools are not automatically terminated by default, if you need to 67 | terminate them after some idle time, use the `pool_max_idle_time` option (available only for HTTP1 pools). 68 | 69 | ## Telemetry 70 | 71 | Finch uses Telemetry to provide instrumentation. See the `Finch.Telemetry` 72 | module for details on specific events. 73 | 74 | ## Logging TLS Secrets 75 | 76 | Finch supports logging TLS secrets to a file. These can be later used in a tool such as 77 | Wireshark to decrypt HTTPS sessions. To use this feature you must specify the file to 78 | which the secrets should be written. If you are using TLSv1.3 you must also add 79 | `keep_secrets: true` to your pool `:transport_opts`. For example: 80 | 81 | ```elixir 82 | {Finch, 83 | name: MyFinch, 84 | pools: %{ 85 | default: [conn_opts: [transport_opts: [keep_secrets: true]]] 86 | }} 87 | ``` 88 | 89 | There are two different ways to specify this file: 90 | 91 | 1. The `:ssl_key_log_file` connection option in your pool configuration. For example: 92 | 93 | ```elixir 94 | {Finch, 95 | name: MyFinch, 96 | pools: %{ 97 | default: [ 98 | conn_opts: [ 99 | ssl_key_log_file: "/writable/path/to/the/sslkey.log" 100 | ] 101 | ] 102 | }} 103 | ``` 104 | 105 | 2. Alternatively, you could also set the `SSLKEYLOGFILE` environment variable. 106 | 107 | 108 | 109 | ## Installation 110 | 111 | The package can be installed by adding `finch` to your list of dependencies in `mix.exs`: 112 | 113 | ```elixir 114 | def deps do 115 | [ 116 | {:finch, "~> 0.19"} 117 | ] 118 | end 119 | ``` 120 | 121 | The docs can be found at [https://hexdocs.pm/finch](https://hexdocs.pm/finch). 122 | -------------------------------------------------------------------------------- /assets/Finch_logo_all-White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneako/finch/3cf1406ff88043fae155958c6c032eef882fddfb/assets/Finch_logo_all-White.png -------------------------------------------------------------------------------- /assets/Finch_logo_onWhite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sneako/finch/3cf1406ff88043fae155958c6c032eef882fddfb/assets/Finch_logo_onWhite.png -------------------------------------------------------------------------------- /lib/finch.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch do 2 | @external_resource "README.md" 3 | @moduledoc "README.md" 4 | |> File.read!() 5 | |> String.split("") 6 | |> Enum.fetch!(1) 7 | 8 | alias Finch.{PoolManager, Request, Response} 9 | require Finch.Pool 10 | 11 | use Supervisor 12 | 13 | @default_pool_size 50 14 | @default_pool_count 1 15 | 16 | @default_connect_timeout 5_000 17 | 18 | @pool_config_schema [ 19 | protocol: [ 20 | type: {:in, [:http2, :http1]}, 21 | deprecated: "Use `:protocols` instead." 22 | ], 23 | protocols: [ 24 | type: {:list, {:in, [:http1, :http2]}}, 25 | doc: """ 26 | The type of connections to support. 27 | 28 | If using `:http1` only, an HTTP1 pool without multiplexing is used. \ 29 | If using `:http2` only, an HTTP2 pool with multiplexing is used. \ 30 | If both are listed, then both HTTP1/HTTP2 connections are \ 31 | supported (via ALPN), but there is no multiplexing. 32 | """, 33 | default: [:http1] 34 | ], 35 | size: [ 36 | type: :pos_integer, 37 | doc: """ 38 | Number of connections to maintain in each pool. Used only by HTTP1 pools \ 39 | since HTTP2 is able to multiplex requests through a single connection. In \ 40 | other words, for HTTP2, the size is always 1 and the `:count` should be \ 41 | configured in order to increase capacity. 42 | """, 43 | default: @default_pool_size 44 | ], 45 | count: [ 46 | type: :pos_integer, 47 | doc: """ 48 | Number of pools to start. HTTP1 pools are able to re-use connections in the \ 49 | same pool and establish new ones only when necessary. However, if there is a \ 50 | high pool count and few requests are made, these requests will be scattered \ 51 | across pools, reducing connection reuse. It is recommended to increase the pool \ 52 | count for HTTP1 only if you are experiencing high checkout times. 53 | """, 54 | default: @default_pool_count 55 | ], 56 | max_idle_time: [ 57 | type: :timeout, 58 | doc: """ 59 | The maximum number of milliseconds an HTTP1 connection is allowed to be idle \ 60 | before being closed during a checkout attempt. 61 | """, 62 | deprecated: "Use :conn_max_idle_time instead." 63 | ], 64 | conn_opts: [ 65 | type: :keyword_list, 66 | doc: """ 67 | These options are passed to `Mint.HTTP.connect/4` whenever a new connection is established. \ 68 | `:mode` is not configurable as Finch must control this setting. Typically these options are \ 69 | used to configure proxying, https settings, or connect timeouts. 70 | """, 71 | default: [] 72 | ], 73 | pool_max_idle_time: [ 74 | type: :timeout, 75 | doc: """ 76 | The maximum number of milliseconds that a pool can be idle before being terminated, used only by HTTP1 pools. \ 77 | This options is forwarded to NimblePool and it starts and idle verification cycle that may impact \ 78 | performance if misused. For instance setting a very low timeout may lead to pool restarts. \ 79 | For more information see NimblePool's `handle_ping/2` documentation. 80 | """, 81 | default: :infinity 82 | ], 83 | conn_max_idle_time: [ 84 | type: :timeout, 85 | doc: """ 86 | The maximum number of milliseconds an HTTP1 connection is allowed to be idle \ 87 | before being closed during a checkout attempt. 88 | """, 89 | default: :infinity 90 | ], 91 | start_pool_metrics?: [ 92 | type: :boolean, 93 | doc: "When true, pool metrics will be collected and available through `get_pool_status/2`", 94 | default: false 95 | ] 96 | ] 97 | 98 | @typedoc """ 99 | The `:name` provided to Finch in `start_link/1`. 100 | """ 101 | @type name() :: atom() 102 | 103 | @type scheme() :: :http | :https 104 | 105 | @type scheme_host_port() :: {scheme(), host :: String.t(), port :: :inet.port_number()} 106 | 107 | @type request_opt() :: 108 | {:pool_timeout, timeout()} 109 | | {:receive_timeout, timeout()} 110 | | {:request_timeout, timeout()} 111 | 112 | @typedoc """ 113 | Options used by request functions. 114 | """ 115 | @type request_opts() :: [request_opt()] 116 | 117 | @typedoc """ 118 | The reference used to identify a request sent using `async_request/3`. 119 | """ 120 | @opaque request_ref() :: Finch.Pool.request_ref() 121 | 122 | @typedoc """ 123 | The stream function given to `stream/5`. 124 | """ 125 | @type stream(acc) :: 126 | ({:status, integer} 127 | | {:headers, Mint.Types.headers()} 128 | | {:data, binary} 129 | | {:trailers, Mint.Types.headers()}, 130 | acc -> 131 | acc) 132 | 133 | @typedoc """ 134 | The stream function given to `stream_while/5`. 135 | """ 136 | @type stream_while(acc) :: 137 | ({:status, integer} 138 | | {:headers, Mint.Types.headers()} 139 | | {:data, binary} 140 | | {:trailers, Mint.Types.headers()}, 141 | acc -> 142 | {:cont, acc} | {:halt, acc}) 143 | 144 | @doc """ 145 | Start an instance of Finch. 146 | 147 | ## Options 148 | 149 | * `:name` - The name of your Finch instance. This field is required. 150 | 151 | * `:pools` - A map specifying the configuration for your pools. The keys should be URLs 152 | provided as binaries, a tuple `{scheme, {:local, unix_socket}}` where `unix_socket` is the path for 153 | the socket, or the atom `:default` to provide a catch-all configuration to be used for any 154 | unspecified URLs - meaning that new pools for unspecified URLs will be started using the `:default` 155 | configuration. See "Pool Configuration Options" below for details on the possible map 156 | values. Default value is `%{default: [size: #{@default_pool_size}, count: #{@default_pool_count}]}`. 157 | 158 | ### Pool Configuration Options 159 | 160 | #{NimbleOptions.docs(@pool_config_schema)} 161 | """ 162 | def start_link(opts) do 163 | name = finch_name!(opts) 164 | pools = Keyword.get(opts, :pools, []) |> pool_options!() 165 | {default_pool_config, pools} = Map.pop(pools, :default) 166 | 167 | config = %{ 168 | registry_name: name, 169 | manager_name: manager_name(name), 170 | supervisor_name: pool_supervisor_name(name), 171 | default_pool_config: default_pool_config, 172 | pools: pools 173 | } 174 | 175 | Supervisor.start_link(__MODULE__, config, name: supervisor_name(name)) 176 | end 177 | 178 | def child_spec(opts) do 179 | %{ 180 | id: finch_name!(opts), 181 | start: {__MODULE__, :start_link, [opts]} 182 | } 183 | end 184 | 185 | @impl true 186 | def init(config) do 187 | children = [ 188 | {Registry, [keys: :duplicate, name: config.registry_name, meta: [config: config]]}, 189 | {DynamicSupervisor, name: config.supervisor_name, strategy: :one_for_one}, 190 | {PoolManager, config} 191 | ] 192 | 193 | Supervisor.init(children, strategy: :one_for_all) 194 | end 195 | 196 | defp finch_name!(opts) do 197 | Keyword.get(opts, :name) || raise(ArgumentError, "must supply a name") 198 | end 199 | 200 | defp pool_options!(pools) do 201 | {:ok, default} = NimbleOptions.validate([], @pool_config_schema) 202 | 203 | Enum.reduce(pools, %{default: valid_opts_to_map(default)}, fn {destination, opts}, acc -> 204 | with {:ok, valid_destination} <- cast_destination(destination), 205 | {:ok, valid_pool_opts} <- cast_pool_opts(opts) do 206 | Map.put(acc, valid_destination, valid_pool_opts) 207 | else 208 | {:error, reason} -> 209 | raise reason 210 | end 211 | end) 212 | end 213 | 214 | defp cast_destination(destination) do 215 | case destination do 216 | :default -> 217 | {:ok, destination} 218 | 219 | {scheme, {:local, path}} when is_atom(scheme) and is_binary(path) -> 220 | {:ok, {scheme, {:local, path}, 0}} 221 | 222 | url when is_binary(url) -> 223 | cast_binary_destination(url) 224 | 225 | _ -> 226 | {:error, %ArgumentError{message: "invalid destination: #{inspect(destination)}"}} 227 | end 228 | end 229 | 230 | defp cast_binary_destination(url) when is_binary(url) do 231 | {scheme, host, port, _path, _query} = Finch.Request.parse_url(url) 232 | {:ok, {scheme, host, port}} 233 | end 234 | 235 | defp cast_pool_opts(opts) do 236 | with {:ok, valid} <- NimbleOptions.validate(opts, @pool_config_schema) do 237 | {:ok, valid_opts_to_map(valid)} 238 | end 239 | end 240 | 241 | defp valid_opts_to_map(valid) do 242 | # We need to enable keepalive and set the nodelay flag to true by default. 243 | transport_opts = 244 | valid 245 | |> get_in([:conn_opts, :transport_opts]) 246 | |> List.wrap() 247 | |> Keyword.put_new(:timeout, @default_connect_timeout) 248 | |> Keyword.put_new(:nodelay, true) 249 | |> Keyword.put(:keepalive, true) 250 | 251 | conn_opts = valid[:conn_opts] |> List.wrap() 252 | 253 | ssl_key_log_file = 254 | Keyword.get(conn_opts, :ssl_key_log_file) || System.get_env("SSLKEYLOGFILE") 255 | 256 | ssl_key_log_file_device = ssl_key_log_file && File.open!(ssl_key_log_file, [:append]) 257 | 258 | conn_opts = 259 | conn_opts 260 | |> Keyword.put(:ssl_key_log_file_device, ssl_key_log_file_device) 261 | |> Keyword.put(:transport_opts, transport_opts) 262 | |> Keyword.put(:protocols, valid[:protocols]) 263 | 264 | # TODO: Remove :protocol on v0.18 265 | mod = 266 | case valid[:protocol] do 267 | :http1 -> 268 | Finch.HTTP1.Pool 269 | 270 | :http2 -> 271 | Finch.HTTP2.Pool 272 | 273 | nil -> 274 | if :http1 in valid[:protocols] do 275 | Finch.HTTP1.Pool 276 | else 277 | Finch.HTTP2.Pool 278 | end 279 | end 280 | 281 | %{ 282 | mod: mod, 283 | size: valid[:size], 284 | count: valid[:count], 285 | conn_opts: conn_opts, 286 | conn_max_idle_time: to_native(valid[:max_idle_time] || valid[:conn_max_idle_time]), 287 | pool_max_idle_time: valid[:pool_max_idle_time], 288 | start_pool_metrics?: valid[:start_pool_metrics?] 289 | } 290 | end 291 | 292 | defp to_native(:infinity), do: :infinity 293 | defp to_native(time), do: System.convert_time_unit(time, :millisecond, :native) 294 | 295 | defp supervisor_name(name), do: :"#{name}.Supervisor" 296 | defp manager_name(name), do: :"#{name}.PoolManager" 297 | defp pool_supervisor_name(name), do: :"#{name}.PoolSupervisor" 298 | 299 | defmacrop request_span(request, name, do: block) do 300 | quote do 301 | start_meta = %{request: unquote(request), name: unquote(name)} 302 | 303 | Finch.Telemetry.span(:request, start_meta, fn -> 304 | result = unquote(block) 305 | end_meta = Map.put(start_meta, :result, result) 306 | {result, end_meta} 307 | end) 308 | end 309 | end 310 | 311 | @doc """ 312 | Builds an HTTP request to be sent with `request/3` or `stream/4`. 313 | 314 | It is possible to send the request body in a streaming fashion. In order to do so, the 315 | `body` parameter needs to take form of a tuple `{:stream, body_stream}`, where `body_stream` 316 | is a `Stream`. 317 | """ 318 | @spec build(Request.method(), Request.url(), Request.headers(), Request.body(), Keyword.t()) :: 319 | Request.t() 320 | defdelegate build(method, url, headers \\ [], body \\ nil, opts \\ []), to: Request 321 | 322 | @doc """ 323 | Streams an HTTP request and returns the accumulator. 324 | 325 | A function of arity 2 is expected as argument. The first argument 326 | is a tuple, as listed below, and the second argument is the 327 | accumulator. The function must return a potentially updated 328 | accumulator. 329 | 330 | See also `stream_while/5`. 331 | 332 | > ### HTTP2 streaming and back-pressure {: .warning} 333 | > 334 | > At the moment, streaming over HTTP2 connections do not provide 335 | > any back-pressure mechanism: this means the response will be 336 | > sent to the client as quickly as possible. Therefore, you must 337 | > not use streaming over HTTP2 for non-terminating responses or 338 | > when streaming large responses which you do not intend to keep 339 | > in memory. 340 | 341 | ## Stream commands 342 | 343 | * `{:status, status}` - the http response status 344 | * `{:headers, headers}` - the http response headers 345 | * `{:data, data}` - a streaming section of the http response body 346 | * `{:trailers, trailers}` - the http response trailers 347 | 348 | ## Options 349 | 350 | Shares options with `request/3`. 351 | 352 | ## Examples 353 | 354 | path = "/tmp/archive.zip" 355 | file = File.open!(path, [:write, :exclusive]) 356 | url = "https://example.com/archive.zip" 357 | request = Finch.build(:get, url) 358 | 359 | Finch.stream(request, MyFinch, nil, fn 360 | {:status, status}, _acc -> 361 | IO.inspect(status) 362 | 363 | {:headers, headers}, _acc -> 364 | IO.inspect(headers) 365 | 366 | {:data, data}, _acc -> 367 | IO.binwrite(file, data) 368 | end) 369 | 370 | File.close(file) 371 | """ 372 | @spec stream(Request.t(), name(), acc, stream(acc), request_opts()) :: 373 | {:ok, acc} | {:error, Exception.t(), acc} 374 | when acc: term() 375 | def stream(%Request{} = req, name, acc, fun, opts \\ []) when is_function(fun, 2) do 376 | fun = fn entry, acc -> 377 | {:cont, fun.(entry, acc)} 378 | end 379 | 380 | stream_while(req, name, acc, fun, opts) 381 | end 382 | 383 | @doc """ 384 | Streams an HTTP request until it finishes or `fun` returns `{:halt, acc}`. 385 | 386 | A function of arity 2 is expected as argument. The first argument 387 | is a tuple, as listed below, and the second argument is the 388 | accumulator. 389 | 390 | The function must return: 391 | 392 | * `{:cont, acc}` to continue streaming 393 | * `{:halt, acc}` to halt streaming 394 | 395 | See also `stream/5`. 396 | 397 | > ### HTTP2 streaming and back-pressure {: .warning} 398 | > 399 | > At the moment, streaming over HTTP2 connections do not provide 400 | > any back-pressure mechanism: this means the response will be 401 | > sent to the client as quickly as possible. Therefore, you must 402 | > not use streaming over HTTP2 for non-terminating responses or 403 | > when streaming large responses which you do not intend to keep 404 | > in memory. 405 | 406 | ## Stream commands 407 | 408 | * `{:status, status}` - the http response status 409 | * `{:headers, headers}` - the http response headers 410 | * `{:data, data}` - a streaming section of the http response body 411 | * `{:trailers, trailers}` - the http response trailers 412 | 413 | ## Options 414 | 415 | Shares options with `request/3`. 416 | 417 | ## Examples 418 | 419 | path = "/tmp/archive.zip" 420 | file = File.open!(path, [:write, :exclusive]) 421 | url = "https://example.com/archive.zip" 422 | request = Finch.build(:get, url) 423 | 424 | Finch.stream_while(request, MyFinch, nil, fn 425 | {:status, status}, acc -> 426 | IO.inspect(status) 427 | {:cont, acc} 428 | 429 | {:headers, headers}, acc -> 430 | IO.inspect(headers) 431 | {:cont, acc} 432 | 433 | {:data, data}, acc -> 434 | IO.binwrite(file, data) 435 | {:cont, acc} 436 | end) 437 | 438 | File.close(file) 439 | """ 440 | @spec stream_while(Request.t(), name(), acc, stream_while(acc), request_opts()) :: 441 | {:ok, acc} | {:error, Exception.t(), acc} 442 | when acc: term() 443 | def stream_while(%Request{} = req, name, acc, fun, opts \\ []) when is_function(fun, 2) do 444 | request_span req, name do 445 | __stream__(req, name, acc, fun, opts) 446 | end 447 | end 448 | 449 | defp __stream__(%Request{} = req, name, acc, fun, opts) do 450 | {pool, pool_mod} = get_pool(req, name) 451 | pool_mod.request(pool, req, acc, fun, name, opts) 452 | end 453 | 454 | @doc """ 455 | Sends an HTTP request and returns a `Finch.Response` struct. 456 | 457 | ## Options 458 | 459 | * `:pool_timeout` - This timeout is applied when we check out a connection from the pool. 460 | Default value is `5_000`. 461 | 462 | * `:receive_timeout` - The maximum time to wait for each chunk to be received before returning an error. 463 | Default value is `15_000`. 464 | 465 | * `:request_timeout` - The amount of time to wait for a complete response before returning an error. 466 | This timeout only applies to HTTP/1, and its current implementation is a best effort timeout, 467 | it does not guarantee the call will return precisely when the time has elapsed. 468 | Default value is `:infinity`. 469 | 470 | """ 471 | @spec request(Request.t(), name(), request_opts()) :: 472 | {:ok, Response.t()} 473 | | {:error, Exception.t()} 474 | def request(req, name, opts \\ []) 475 | 476 | def request(%Request{} = req, name, opts) do 477 | request_span req, name do 478 | acc = {nil, [], [], []} 479 | 480 | fun = fn 481 | {:status, value}, {_, headers, body, trailers} -> 482 | {:cont, {value, headers, body, trailers}} 483 | 484 | {:headers, value}, {status, headers, body, trailers} -> 485 | {:cont, {status, headers ++ value, body, trailers}} 486 | 487 | {:data, value}, {status, headers, body, trailers} -> 488 | {:cont, {status, headers, [body | value], trailers}} 489 | 490 | {:trailers, value}, {status, headers, body, trailers} -> 491 | {:cont, {status, headers, body, trailers ++ value}} 492 | end 493 | 494 | case __stream__(req, name, acc, fun, opts) do 495 | {:ok, {status, headers, body, trailers}} -> 496 | {:ok, 497 | %Response{ 498 | status: status, 499 | headers: headers, 500 | body: IO.iodata_to_binary(body), 501 | trailers: trailers 502 | }} 503 | 504 | {:error, error, _acc} -> 505 | {:error, error} 506 | end 507 | end 508 | end 509 | 510 | # Catch-all for backwards compatibility below 511 | def request(name, method, url) do 512 | request(name, method, url, []) 513 | end 514 | 515 | @doc false 516 | def request(name, method, url, headers, body \\ nil, opts \\ []) do 517 | IO.warn("Finch.request/6 is deprecated, use Finch.build/5 + Finch.request/3 instead") 518 | 519 | build(method, url, headers, body) 520 | |> request(name, opts) 521 | end 522 | 523 | @doc """ 524 | Sends an HTTP request and returns a `Finch.Response` struct 525 | or raises an exception in case of failure. 526 | 527 | See `request/3` for more detailed information. 528 | """ 529 | @spec request!(Request.t(), name(), request_opts()) :: 530 | Response.t() 531 | def request!(%Request{} = req, name, opts \\ []) do 532 | case request(req, name, opts) do 533 | {:ok, resp} -> resp 534 | {:error, exception} -> raise exception 535 | end 536 | end 537 | 538 | @doc """ 539 | Sends an HTTP request asynchronously, returning a request reference. 540 | 541 | If the request is sent using HTTP1, an extra process is spawned to 542 | consume messages from the underlying socket. The messages are sent 543 | to the current process as soon as they arrive, as a firehose. If 544 | you wish to maximize request rate or have more control over how 545 | messages are streamed, a strategy using `request/3` or `stream/5` 546 | should be used instead. 547 | 548 | ## Receiving the response 549 | 550 | Response information is sent to the calling process as it is received 551 | in `{ref, response}` tuples. 552 | 553 | If the calling process exits before the request has completed, the 554 | request will be canceled. 555 | 556 | Responses include: 557 | 558 | * `{:status, status}` - HTTP response status 559 | * `{:headers, headers}` - HTTP response headers 560 | * `{:data, data}` - section of the HTTP response body 561 | * `{:error, exception}` - an error occurred during the request 562 | * `:done` - request has completed successfully 563 | 564 | On a successful request, a single `:status` message will be followed 565 | by a single `:headers` message, after which more than one `:data` 566 | messages may be sent. If trailing headers are present, a final 567 | `:headers` message may be sent. Any `:done` or `:error` message 568 | indicates that the request has succeeded or failed and no further 569 | messages are expected. 570 | 571 | ## Example 572 | 573 | iex> req = Finch.build(:get, "https://httpbin.org/stream/5") 574 | iex> ref = Finch.async_request(req, MyFinch) 575 | iex> flush() 576 | {ref, {:status, 200}} 577 | {ref, {:headers, [...]}} 578 | {ref, {:data, "..."}} 579 | {ref, :done} 580 | 581 | ## Options 582 | 583 | Shares options with `request/3`. 584 | """ 585 | @spec async_request(Request.t(), name(), request_opts()) :: request_ref() 586 | def async_request(%Request{} = req, name, opts \\ []) do 587 | {pool, pool_mod} = get_pool(req, name) 588 | pool_mod.async_request(pool, req, name, opts) 589 | end 590 | 591 | @doc """ 592 | Cancels a request sent with `async_request/3`. 593 | """ 594 | @spec cancel_async_request(request_ref()) :: :ok 595 | def cancel_async_request(request_ref) when Finch.Pool.is_request_ref(request_ref) do 596 | {pool_mod, _cancel_ref} = request_ref 597 | pool_mod.cancel_async_request(request_ref) 598 | end 599 | 600 | defp get_pool(%Request{scheme: scheme, unix_socket: unix_socket}, name) 601 | when is_binary(unix_socket) do 602 | PoolManager.get_pool(name, {scheme, {:local, unix_socket}, 0}) 603 | end 604 | 605 | defp get_pool(%Request{scheme: scheme, host: host, port: port}, name) do 606 | PoolManager.get_pool(name, {scheme, host, port}) 607 | end 608 | 609 | @doc """ 610 | Get pool metrics list. 611 | 612 | The number of items present on the metrics list depends on the `:count` option 613 | each metric will have a `pool_index` going from 1 to `:count`. 614 | 615 | The metrics struct depends on the pool scheme defined on the `:protocols` option 616 | `Finch.HTTP1.PoolMetrics` for `:http1` and `Finch.HTTP2.PoolMetrics` for `:http2`. 617 | 618 | See the `Finch.HTTP1.PoolMetrics` and `Finch.HTTP2.PoolMetrics` for more details. 619 | 620 | `{:error, :not_found}` may return on 2 scenarios: 621 | - There is no pool registered for the given pair finch instance and url 622 | - The pool is configured with `start_pool_metrics?` option false (default) 623 | 624 | ## Example 625 | 626 | iex> Finch.get_pool_status(MyFinch, "https://httpbin.org") 627 | {:ok, [ 628 | %Finch.HTTP1.PoolMetrics{ 629 | pool_index: 1, 630 | pool_size: 50, 631 | available_connections: 43, 632 | in_use_connections: 7 633 | }, 634 | %Finch.HTTP1.PoolMetrics{ 635 | pool_index: 2, 636 | pool_size: 50, 637 | available_connections: 37, 638 | in_use_connections: 13 639 | }] 640 | } 641 | """ 642 | @spec get_pool_status(name(), url :: String.t() | scheme_host_port()) :: 643 | {:ok, list(Finch.HTTP1.PoolMetrics.t())} 644 | | {:ok, list(Finch.HTTP2.PoolMetrics.t())} 645 | | {:error, :not_found} 646 | def get_pool_status(finch_name, url) when is_binary(url) do 647 | {s, h, p, _, _} = Request.parse_url(url) 648 | get_pool_status(finch_name, {s, h, p}) 649 | end 650 | 651 | def get_pool_status(finch_name, shp) when is_tuple(shp) do 652 | case PoolManager.get_pool(finch_name, shp, auto_start?: false) do 653 | {_pool, pool_mod} -> 654 | pool_mod.get_pool_status(finch_name, shp) 655 | 656 | :not_found -> 657 | {:error, :not_found} 658 | end 659 | end 660 | 661 | @doc """ 662 | Stops the pool of processes associated with the given scheme, host, port (aka SHP). 663 | 664 | This function can be invoked to manually stop the pool to the given SHP when you know it's not 665 | going to be used anymore. 666 | 667 | Note that this function is not safe with respect to concurrent requests. Invoking it while 668 | another request to the same SHP is taking place might result in the failure of that request. It 669 | is the responsibility of the client to ensure that no request to the same SHP is taking place 670 | while this function is being invoked. 671 | """ 672 | @spec stop_pool(name(), url :: String.t() | scheme_host_port()) :: :ok | {:error, :not_found} 673 | def stop_pool(finch_name, url) when is_binary(url) do 674 | {s, h, p, _, _} = Request.parse_url(url) 675 | stop_pool(finch_name, {s, h, p}) 676 | end 677 | 678 | def stop_pool(finch_name, shp) when is_tuple(shp) do 679 | case PoolManager.all_pool_instances(finch_name, shp) do 680 | [] -> 681 | {:error, :not_found} 682 | 683 | children -> 684 | Enum.each( 685 | children, 686 | fn {pid, _module} -> 687 | DynamicSupervisor.terminate_child(pool_supervisor_name(finch_name), pid) 688 | end 689 | ) 690 | end 691 | end 692 | end 693 | -------------------------------------------------------------------------------- /lib/finch/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.Error do 2 | @moduledoc """ 3 | An HTTP error. 4 | 5 | This exception struct is used to represent errors of all sorts for the HTTP/2 protocol. 6 | """ 7 | 8 | @type t() :: %__MODULE__{reason: atom()} 9 | 10 | defexception [:reason] 11 | 12 | @impl true 13 | def exception(reason) when is_atom(reason) do 14 | %__MODULE__{reason: reason} 15 | end 16 | 17 | @impl true 18 | def message(%__MODULE__{reason: reason}) do 19 | "#{reason}" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/finch/http1/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.Conn do 2 | @moduledoc false 3 | 4 | alias Finch.SSL 5 | alias Finch.Telemetry 6 | 7 | def new(scheme, host, port, opts, parent) do 8 | %{ 9 | scheme: scheme, 10 | host: host, 11 | port: port, 12 | opts: opts.conn_opts, 13 | parent: parent, 14 | last_checkin: System.monotonic_time(), 15 | max_idle_time: opts.conn_max_idle_time, 16 | mint: nil 17 | } 18 | end 19 | 20 | def connect(%{mint: mint} = conn, name) when not is_nil(mint) do 21 | meta = %{ 22 | scheme: conn.scheme, 23 | host: conn.host, 24 | port: conn.port, 25 | name: name 26 | } 27 | 28 | Telemetry.event(:reused_connection, %{}, meta) 29 | {:ok, conn} 30 | end 31 | 32 | def connect(%{mint: nil} = conn, name) do 33 | meta = %{ 34 | scheme: conn.scheme, 35 | host: conn.host, 36 | port: conn.port, 37 | name: name 38 | } 39 | 40 | start_time = Telemetry.start(:connect, meta) 41 | 42 | # By default we force HTTP1, but we allow someone to set 43 | # custom protocols in case they don't know if a connection 44 | # is HTTP1/HTTP2, but they are fine as treating HTTP2 45 | # connections has HTTP2. 46 | 47 | conn_opts = 48 | conn.opts 49 | |> Keyword.put(:mode, :passive) 50 | |> Keyword.put_new(:protocols, [:http1]) 51 | 52 | case Mint.HTTP.connect(conn.scheme, conn.host, conn.port, conn_opts) do 53 | {:ok, mint} -> 54 | Telemetry.stop(:connect, start_time, meta) 55 | SSL.maybe_log_secrets(conn.scheme, conn_opts, mint) 56 | {:ok, %{conn | mint: mint}} 57 | 58 | {:error, error} -> 59 | meta = Map.put(meta, :error, error) 60 | Telemetry.stop(:connect, start_time, meta) 61 | {:error, conn, error} 62 | end 63 | end 64 | 65 | def transfer(conn, pid) do 66 | case Mint.HTTP.controlling_process(conn.mint, pid) do 67 | # Mint.HTTP.controlling_process causes a side-effect, but it doesn't actually 68 | # change the conn, so we can ignore the value returned above. 69 | {:ok, _} -> {:ok, conn} 70 | {:error, error} -> {:error, conn, error} 71 | end 72 | end 73 | 74 | def open?(%{mint: nil}), do: false 75 | def open?(%{mint: mint}), do: Mint.HTTP.open?(mint) 76 | 77 | def idle_time(conn, unit \\ :native) do 78 | idle_time = System.monotonic_time() - conn.last_checkin 79 | 80 | System.convert_time_unit(idle_time, :native, unit) 81 | end 82 | 83 | def reusable?(%{max_idle_time: :infinity}, _idle_time), do: true 84 | def reusable?(%{max_idle_time: max_idle_time}, idle_time), do: idle_time <= max_idle_time 85 | 86 | def set_mode(conn, mode) when mode in [:active, :passive] do 87 | case Mint.HTTP.set_mode(conn.mint, mode) do 88 | {:ok, mint} -> {:ok, %{conn | mint: mint}} 89 | _ -> {:error, "Connection is dead"} 90 | end 91 | end 92 | 93 | def discard(%{mint: nil}, _), do: :unknown 94 | 95 | def discard(conn, message) do 96 | case Mint.HTTP.stream(conn.mint, message) do 97 | {:ok, mint, _responses} -> {:ok, %{conn | mint: mint}} 98 | {:error, _, reason, _} -> {:error, reason} 99 | :unknown -> :unknown 100 | end 101 | end 102 | 103 | def request(%{mint: nil} = conn, _, _, _, _, _, _, _), do: {:error, conn, "Could not connect"} 104 | 105 | def request(conn, req, acc, fun, name, receive_timeout, request_timeout, idle_time) do 106 | full_path = Finch.Request.request_path(req) 107 | 108 | metadata = %{request: req, name: name} 109 | 110 | extra_measurements = %{idle_time: idle_time} 111 | 112 | start_time = Telemetry.start(:send, metadata, extra_measurements) 113 | 114 | try do 115 | case Mint.HTTP.request( 116 | conn.mint, 117 | req.method, 118 | full_path, 119 | req.headers, 120 | stream_or_body(req.body) 121 | ) do 122 | {:ok, mint, ref} -> 123 | case maybe_stream_request_body(mint, ref, req.body) do 124 | {:ok, mint} -> 125 | Telemetry.stop(:send, start_time, metadata, extra_measurements) 126 | start_time = Telemetry.start(:recv, metadata, extra_measurements) 127 | resp_metadata = %{status: nil, headers: [], trailers: []} 128 | timeouts = %{receive_timeout: receive_timeout, request_timeout: request_timeout} 129 | 130 | response = 131 | receive_response( 132 | [], 133 | acc, 134 | fun, 135 | mint, 136 | ref, 137 | timeouts, 138 | :headers, 139 | resp_metadata 140 | ) 141 | 142 | handle_response(response, conn, metadata, start_time, extra_measurements) 143 | 144 | {:error, mint, error} -> 145 | handle_request_error( 146 | conn, 147 | mint, 148 | error, 149 | acc, 150 | metadata, 151 | start_time, 152 | extra_measurements 153 | ) 154 | end 155 | 156 | {:error, mint, error} -> 157 | handle_request_error(conn, mint, error, acc, metadata, start_time, extra_measurements) 158 | end 159 | catch 160 | kind, error -> 161 | close(conn) 162 | Telemetry.exception(:recv, start_time, kind, error, __STACKTRACE__, metadata) 163 | :erlang.raise(kind, error, __STACKTRACE__) 164 | end 165 | end 166 | 167 | defp stream_or_body({:stream, _}), do: :stream 168 | defp stream_or_body(body), do: body 169 | 170 | defp handle_request_error(conn, mint, error, acc, metadata, start_time, extra_measurements) do 171 | metadata = Map.put(metadata, :error, error) 172 | Telemetry.stop(:send, start_time, metadata, extra_measurements) 173 | {:error, %{conn | mint: mint}, error, acc} 174 | end 175 | 176 | defp maybe_stream_request_body(mint, ref, {:stream, stream}) do 177 | with {:ok, mint} <- stream_request_body(mint, ref, stream) do 178 | Mint.HTTP.stream_request_body(mint, ref, :eof) 179 | end 180 | end 181 | 182 | defp maybe_stream_request_body(mint, _, _), do: {:ok, mint} 183 | 184 | defp stream_request_body(mint, ref, stream) do 185 | Enum.reduce_while(stream, {:ok, mint}, fn 186 | chunk, {:ok, mint} -> {:cont, Mint.HTTP.stream_request_body(mint, ref, chunk)} 187 | _chunk, error -> {:halt, error} 188 | end) 189 | end 190 | 191 | def close(%{mint: nil} = conn), do: conn 192 | 193 | def close(conn) do 194 | {:ok, mint} = Mint.HTTP.close(conn.mint) 195 | %{conn | mint: mint} 196 | end 197 | 198 | defp handle_response(response, conn, metadata, start_time, extra_measurements) do 199 | case response do 200 | {:ok, mint, acc, resp_metadata} -> 201 | metadata = Map.merge(metadata, resp_metadata) 202 | Telemetry.stop(:recv, start_time, metadata, extra_measurements) 203 | {:ok, %{conn | mint: mint}, acc} 204 | 205 | {:error, mint, error, acc, resp_metadata} -> 206 | metadata = Map.merge(metadata, Map.put(resp_metadata, :error, error)) 207 | Telemetry.stop(:recv, start_time, metadata, extra_measurements) 208 | {:error, %{conn | mint: mint}, error, acc} 209 | end 210 | end 211 | 212 | defp receive_response( 213 | entries, 214 | acc, 215 | fun, 216 | mint, 217 | ref, 218 | timeouts, 219 | fields, 220 | resp_metadata 221 | ) 222 | 223 | defp receive_response( 224 | [{:done, ref} | _], 225 | acc, 226 | _fun, 227 | mint, 228 | ref, 229 | _timeouts, 230 | _fields, 231 | resp_metadata 232 | ) do 233 | {:ok, mint, acc, resp_metadata} 234 | end 235 | 236 | defp receive_response( 237 | _, 238 | acc, 239 | _fun, 240 | mint, 241 | _ref, 242 | timeouts, 243 | _fields, 244 | resp_metadata 245 | ) 246 | when timeouts.request_timeout < 0 do 247 | {:ok, mint} = Mint.HTTP1.close(mint) 248 | {:error, mint, %Mint.TransportError{reason: :timeout}, acc, resp_metadata} 249 | end 250 | 251 | defp receive_response( 252 | [], 253 | acc, 254 | fun, 255 | mint, 256 | ref, 257 | timeouts, 258 | fields, 259 | resp_metadata 260 | ) do 261 | start_time = System.monotonic_time(:millisecond) 262 | 263 | case Mint.HTTP.recv(mint, 0, timeouts.receive_timeout) do 264 | {:ok, mint, entries} -> 265 | timeouts = 266 | if is_integer(timeouts.request_timeout) do 267 | elapsed_time = System.monotonic_time(:millisecond) - start_time 268 | update_in(timeouts.request_timeout, &(&1 - elapsed_time)) 269 | else 270 | timeouts 271 | end 272 | 273 | receive_response( 274 | entries, 275 | acc, 276 | fun, 277 | mint, 278 | ref, 279 | timeouts, 280 | fields, 281 | resp_metadata 282 | ) 283 | 284 | {:error, mint, error, _responses} -> 285 | {:error, mint, error, acc, resp_metadata} 286 | end 287 | end 288 | 289 | defp receive_response( 290 | [entry | entries], 291 | acc, 292 | fun, 293 | mint, 294 | ref, 295 | timeouts, 296 | fields, 297 | resp_metadata 298 | ) do 299 | case entry do 300 | {:status, ^ref, value} -> 301 | case fun.({:status, value}, acc) do 302 | {:cont, acc} -> 303 | receive_response( 304 | entries, 305 | acc, 306 | fun, 307 | mint, 308 | ref, 309 | timeouts, 310 | fields, 311 | %{resp_metadata | status: value} 312 | ) 313 | 314 | {:halt, acc} -> 315 | {:ok, mint} = Mint.HTTP1.close(mint) 316 | {:ok, mint, acc, resp_metadata} 317 | 318 | other -> 319 | raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}" 320 | end 321 | 322 | {:headers, ^ref, value} -> 323 | resp_metadata = update_in(resp_metadata, [fields], &(&1 ++ value)) 324 | 325 | case fun.({fields, value}, acc) do 326 | {:cont, acc} -> 327 | receive_response( 328 | entries, 329 | acc, 330 | fun, 331 | mint, 332 | ref, 333 | timeouts, 334 | fields, 335 | resp_metadata 336 | ) 337 | 338 | {:halt, acc} -> 339 | {:ok, mint} = Mint.HTTP1.close(mint) 340 | {:ok, mint, acc, resp_metadata} 341 | 342 | other -> 343 | raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}" 344 | end 345 | 346 | {:data, ^ref, value} -> 347 | case fun.({:data, value}, acc) do 348 | {:cont, acc} -> 349 | receive_response( 350 | entries, 351 | acc, 352 | fun, 353 | mint, 354 | ref, 355 | timeouts, 356 | :trailers, 357 | resp_metadata 358 | ) 359 | 360 | {:halt, acc} -> 361 | {:ok, mint} = Mint.HTTP1.close(mint) 362 | {:ok, mint, acc, resp_metadata} 363 | 364 | other -> 365 | raise ArgumentError, "expected {:cont, acc} or {:halt, acc}, got: #{inspect(other)}" 366 | end 367 | 368 | {:error, ^ref, error} -> 369 | {:error, mint, error, acc, resp_metadata} 370 | end 371 | end 372 | end 373 | -------------------------------------------------------------------------------- /lib/finch/http1/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.Pool do 2 | @moduledoc false 3 | @behaviour NimblePool 4 | @behaviour Finch.Pool 5 | 6 | defmodule State do 7 | @moduledoc false 8 | defstruct [ 9 | :registry, 10 | :shp, 11 | :pool_idx, 12 | :metric_ref, 13 | :opts 14 | ] 15 | end 16 | 17 | alias Finch.HTTP1.Conn 18 | alias Finch.Telemetry 19 | alias Finch.HTTP1.PoolMetrics 20 | 21 | def child_spec(opts) do 22 | { 23 | _shp, 24 | _registry_name, 25 | _pool_size, 26 | _conn_opts, 27 | pool_max_idle_time, 28 | _start_pool_metrics?, 29 | _pool_idx 30 | } = opts 31 | 32 | %{ 33 | id: __MODULE__, 34 | start: {__MODULE__, :start_link, [opts]}, 35 | restart: restart_option(pool_max_idle_time) 36 | } 37 | end 38 | 39 | def start_link( 40 | {shp, registry_name, pool_size, conn_opts, pool_max_idle_time, start_pool_metrics?, 41 | pool_idx} 42 | ) do 43 | NimblePool.start_link( 44 | worker: 45 | {__MODULE__, {registry_name, shp, pool_idx, pool_size, start_pool_metrics?, conn_opts}}, 46 | pool_size: pool_size, 47 | lazy: true, 48 | worker_idle_timeout: pool_idle_timeout(pool_max_idle_time) 49 | ) 50 | end 51 | 52 | @impl Finch.Pool 53 | def request(pool, req, acc, fun, name, opts) do 54 | pool_timeout = Keyword.get(opts, :pool_timeout, 5_000) 55 | receive_timeout = Keyword.get(opts, :receive_timeout, 15_000) 56 | request_timeout = Keyword.get(opts, :request_timeout, :infinity) 57 | 58 | metadata = %{request: req, pool: pool, name: name} 59 | 60 | start_time = Telemetry.start(:queue, metadata) 61 | 62 | try do 63 | NimblePool.checkout!( 64 | pool, 65 | :checkout, 66 | fn from, {state, conn, idle_time} -> 67 | Telemetry.stop(:queue, start_time, metadata, %{idle_time: idle_time}) 68 | 69 | case Conn.connect(conn, name) do 70 | {:ok, conn} -> 71 | Conn.request(conn, req, acc, fun, name, receive_timeout, request_timeout, idle_time) 72 | |> case do 73 | {:ok, conn, acc} -> 74 | {{:ok, acc}, transfer_if_open(conn, state, from)} 75 | 76 | {:error, conn, error, acc} -> 77 | {{:error, error, acc}, transfer_if_open(conn, state, from)} 78 | end 79 | 80 | {:error, conn, error} -> 81 | {{:error, error, acc}, transfer_if_open(conn, state, from)} 82 | end 83 | end, 84 | pool_timeout 85 | ) 86 | catch 87 | :exit, data -> 88 | Telemetry.exception(:queue, start_time, :exit, data, __STACKTRACE__, metadata) 89 | 90 | # Provide helpful error messages for known errors 91 | case data do 92 | {:timeout, {NimblePool, :checkout, _affected_pids}} -> 93 | reraise( 94 | """ 95 | Finch was unable to provide a connection within the timeout due to excess queuing \ 96 | for connections. Consider adjusting the pool size, count, timeout or reducing the \ 97 | rate of requests if it is possible that the downstream service is unable to keep up \ 98 | with the current rate. 99 | """, 100 | __STACKTRACE__ 101 | ) 102 | 103 | _ -> 104 | exit(data) 105 | end 106 | end 107 | end 108 | 109 | @impl Finch.Pool 110 | def async_request(pool, req, name, opts) do 111 | owner = self() 112 | 113 | pid = 114 | spawn_link(fn -> 115 | monitor = Process.monitor(owner) 116 | request_ref = {__MODULE__, self()} 117 | 118 | case request( 119 | pool, 120 | req, 121 | {owner, monitor, request_ref}, 122 | &send_async_response/2, 123 | name, 124 | opts 125 | ) do 126 | {:ok, _} -> send(owner, {request_ref, :done}) 127 | {:error, error, _acc} -> send(owner, {request_ref, {:error, error}}) 128 | end 129 | end) 130 | 131 | {__MODULE__, pid} 132 | end 133 | 134 | defp send_async_response(response, {owner, monitor, request_ref}) do 135 | if process_down?(monitor) do 136 | exit(:shutdown) 137 | end 138 | 139 | send(owner, {request_ref, response}) 140 | {:cont, {owner, monitor, request_ref}} 141 | end 142 | 143 | defp process_down?(monitor) do 144 | receive do 145 | {:DOWN, ^monitor, _, _, _} -> true 146 | after 147 | 0 -> false 148 | end 149 | end 150 | 151 | @impl Finch.Pool 152 | def cancel_async_request({_, pid} = _request_ref) do 153 | Process.unlink(pid) 154 | Process.exit(pid, :shutdown) 155 | :ok 156 | end 157 | 158 | @impl Finch.Pool 159 | def get_pool_status(finch_name, shp) do 160 | case Finch.PoolManager.get_pool_count(finch_name, shp) do 161 | nil -> 162 | {:error, :not_found} 163 | 164 | count -> 165 | 1..count 166 | |> Enum.map(&PoolMetrics.get_pool_status(finch_name, shp, &1)) 167 | |> Enum.filter(&match?({:ok, _}, &1)) 168 | |> Enum.map(&elem(&1, 1)) 169 | |> case do 170 | [] -> {:error, :not_found} 171 | result -> {:ok, result} 172 | end 173 | end 174 | end 175 | 176 | @impl NimblePool 177 | def init_pool({registry, shp, pool_idx, pool_size, start_pool_metrics?, opts}) do 178 | {:ok, metric_ref} = 179 | if start_pool_metrics?, 180 | do: PoolMetrics.init(registry, shp, pool_idx, pool_size), 181 | else: {:ok, nil} 182 | 183 | # Register our pool with our module name as the key. This allows the caller 184 | # to determine the correct pool module to use to make the request 185 | {:ok, _} = Registry.register(registry, shp, __MODULE__) 186 | 187 | state = %__MODULE__.State{ 188 | registry: registry, 189 | shp: shp, 190 | pool_idx: pool_idx, 191 | metric_ref: metric_ref, 192 | opts: opts 193 | } 194 | 195 | {:ok, state} 196 | end 197 | 198 | @impl NimblePool 199 | def init_worker(%__MODULE__.State{shp: {scheme, host, port}, opts: opts} = pool_state) do 200 | {:ok, Conn.new(scheme, host, port, opts, self()), pool_state} 201 | end 202 | 203 | @impl NimblePool 204 | def handle_checkout(:checkout, _, %{mint: nil} = conn, %__MODULE__.State{} = pool_state) do 205 | idle_time = System.monotonic_time() - conn.last_checkin 206 | PoolMetrics.maybe_add(pool_state.metric_ref, in_use_connections: 1) 207 | {:ok, {:fresh, conn, idle_time}, conn, pool_state} 208 | end 209 | 210 | def handle_checkout(:checkout, _from, conn, %__MODULE__.State{} = pool_state) do 211 | idle_time = System.monotonic_time() - conn.last_checkin 212 | 213 | %__MODULE__.State{ 214 | shp: {scheme, host, port}, 215 | metric_ref: metric_ref 216 | } = pool_state 217 | 218 | with true <- Conn.reusable?(conn, idle_time), 219 | {:ok, conn} <- Conn.set_mode(conn, :passive) do 220 | PoolMetrics.maybe_add(metric_ref, in_use_connections: 1) 221 | {:ok, {:reuse, conn, idle_time}, conn, pool_state} 222 | else 223 | false -> 224 | meta = %{ 225 | scheme: scheme, 226 | host: host, 227 | port: port 228 | } 229 | 230 | # Deprecated, remember to delete when we remove the :max_idle_time pool config option! 231 | Telemetry.event(:max_idle_time_exceeded, %{idle_time: idle_time}, meta) 232 | 233 | Telemetry.event(:conn_max_idle_time_exceeded, %{idle_time: idle_time}, meta) 234 | 235 | {:remove, :closed, pool_state} 236 | 237 | _ -> 238 | {:remove, :closed, pool_state} 239 | end 240 | end 241 | 242 | @impl NimblePool 243 | def handle_checkin(checkin, _from, _old_conn, %__MODULE__.State{} = pool_state) do 244 | %__MODULE__.State{metric_ref: metric_ref} = pool_state 245 | PoolMetrics.maybe_add(metric_ref, in_use_connections: -1) 246 | 247 | with {:ok, conn} <- checkin, 248 | {:ok, conn} <- Conn.set_mode(conn, :active) do 249 | {:ok, %{conn | last_checkin: System.monotonic_time()}, pool_state} 250 | else 251 | _ -> 252 | {:remove, :closed, pool_state} 253 | end 254 | end 255 | 256 | @impl NimblePool 257 | def handle_update(new_conn, _old_conn, %__MODULE__.State{} = pool_state) do 258 | {:ok, new_conn, pool_state} 259 | end 260 | 261 | @impl NimblePool 262 | def handle_info(message, conn) do 263 | case Conn.discard(conn, message) do 264 | {:ok, conn} -> {:ok, conn} 265 | :unknown -> {:ok, conn} 266 | {:error, _error} -> {:remove, :closed} 267 | end 268 | end 269 | 270 | @impl NimblePool 271 | def handle_ping(_conn, %__MODULE__.State{} = pool_state) do 272 | %__MODULE__.State{shp: {scheme, host, port}} = pool_state 273 | 274 | meta = %{ 275 | scheme: scheme, 276 | host: host, 277 | port: port 278 | } 279 | 280 | Telemetry.event(:pool_max_idle_time_exceeded, %{}, meta) 281 | 282 | {:stop, :idle_timeout} 283 | end 284 | 285 | @impl NimblePool 286 | # On terminate, effectively close it. 287 | # This will succeed even if it was already closed or if we don't own it. 288 | def terminate_worker(_reason, conn, %__MODULE__.State{} = pool_state) do 289 | Conn.close(conn) 290 | {:ok, pool_state} 291 | end 292 | 293 | @impl NimblePool 294 | def handle_cancelled(:checked_out, %__MODULE__.State{} = pool_state) do 295 | %__MODULE__.State{metric_ref: metric_ref} = pool_state 296 | PoolMetrics.maybe_add(metric_ref, in_use_connections: -1) 297 | :ok 298 | end 299 | 300 | def handle_cancelled(:queued, _pool_state), do: :ok 301 | 302 | defp transfer_if_open(conn, state, {pid, _} = from) do 303 | if Conn.open?(conn) do 304 | if state == :fresh do 305 | NimblePool.update(from, conn) 306 | 307 | case Conn.transfer(conn, pid) do 308 | {:ok, conn} -> {:ok, conn} 309 | {:error, _, _} -> :closed 310 | end 311 | else 312 | {:ok, conn} 313 | end 314 | else 315 | :closed 316 | end 317 | end 318 | 319 | defp restart_option(:infinity), do: :permanent 320 | defp restart_option(_pool_max_idle_time), do: :transient 321 | 322 | defp pool_idle_timeout(:infinity), do: nil 323 | defp pool_idle_timeout(pool_max_idle_time), do: pool_max_idle_time 324 | end 325 | -------------------------------------------------------------------------------- /lib/finch/http1/pool_metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.PoolMetrics do 2 | @moduledoc """ 3 | HTTP1 Pool metrics. 4 | 5 | Available metrics: 6 | 7 | * `:pool_index` - Index of the pool 8 | * `:pool_size` - Total number of connections of the pool 9 | * `:available_connections` - Number of available connections 10 | * `:in_use_connections` - Number of connections currently in use 11 | 12 | Caveats: 13 | 14 | * A given number X of `available_connections` does not mean that currently 15 | exists X connections to the server sitting on the pool. Because Finch uses 16 | a lazy strategy for workers initialization, every pool starts with it's 17 | size as available connections even if they are not started yet. In practice 18 | this means that `available_connections` may be connections sitting on the pool 19 | or available space on the pool for a new one if required. 20 | 21 | """ 22 | @type t :: %__MODULE__{} 23 | 24 | defstruct [ 25 | :pool_index, 26 | :pool_size, 27 | :available_connections, 28 | :in_use_connections 29 | ] 30 | 31 | @atomic_idx [ 32 | pool_idx: 1, 33 | pool_size: 2, 34 | in_use_connections: 3 35 | ] 36 | 37 | def init(registry, shp, pool_idx, pool_size) do 38 | ref = :atomics.new(length(@atomic_idx), []) 39 | :atomics.add(ref, @atomic_idx[:pool_idx], pool_idx) 40 | :atomics.add(ref, @atomic_idx[:pool_size], pool_size) 41 | 42 | :persistent_term.put({__MODULE__, registry, shp, pool_idx}, ref) 43 | {:ok, ref} 44 | end 45 | 46 | def maybe_add(nil, _metrics_list), do: :ok 47 | 48 | def maybe_add(ref, metrics_list) do 49 | Enum.each(metrics_list, fn {metric_name, val} -> 50 | :atomics.add(ref, @atomic_idx[metric_name], val) 51 | end) 52 | end 53 | 54 | def get_pool_status(name, shp, pool_idx) do 55 | {__MODULE__, name, shp, pool_idx} 56 | |> :persistent_term.get(nil) 57 | |> get_pool_status() 58 | end 59 | 60 | def get_pool_status(nil), do: {:error, :not_found} 61 | 62 | def get_pool_status(ref) do 63 | %{ 64 | pool_idx: pool_idx, 65 | pool_size: pool_size, 66 | in_use_connections: in_use_connections 67 | } = 68 | @atomic_idx 69 | |> Enum.map(fn {k, idx} -> {k, :atomics.get(ref, idx)} end) 70 | |> Map.new() 71 | 72 | result = %__MODULE__{ 73 | pool_index: pool_idx, 74 | pool_size: pool_size, 75 | available_connections: pool_size - in_use_connections, 76 | in_use_connections: in_use_connections 77 | } 78 | 79 | {:ok, result} 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/finch/http2/pool_metrics.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.PoolMetrics do 2 | @moduledoc """ 3 | HTTP2 Pool metrics. 4 | 5 | Available metrics: 6 | 7 | * `:pool_index` - Index of the pool 8 | * `:in_flight_requests` - Number of requests currently on the connection 9 | 10 | Caveats: 11 | 12 | * HTTP2 pools have only one connection and leverage the multiplex nature 13 | of the protocol. That's why we only keep the in flight requests, representing 14 | the number of streams currently running on the connection. 15 | """ 16 | @type t :: %__MODULE__{} 17 | 18 | defstruct [ 19 | :pool_index, 20 | :in_flight_requests 21 | ] 22 | 23 | @atomic_idx [ 24 | pool_idx: 1, 25 | in_flight_requests: 2 26 | ] 27 | 28 | def init(finch_name, shp, pool_idx) do 29 | ref = :atomics.new(length(@atomic_idx), []) 30 | :atomics.put(ref, @atomic_idx[:pool_idx], pool_idx) 31 | 32 | :persistent_term.put({__MODULE__, finch_name, shp, pool_idx}, ref) 33 | {:ok, ref} 34 | end 35 | 36 | def maybe_add(nil, _metrics_list), do: :ok 37 | 38 | def maybe_add(ref, metrics_list) do 39 | Enum.each(metrics_list, fn {metric_name, val} -> 40 | :atomics.add(ref, @atomic_idx[metric_name], val) 41 | end) 42 | end 43 | 44 | def get_pool_status(name, shp, pool_idx) do 45 | {__MODULE__, name, shp, pool_idx} 46 | |> :persistent_term.get(nil) 47 | |> get_pool_status() 48 | end 49 | 50 | def get_pool_status(nil), do: {:error, :not_found} 51 | 52 | def get_pool_status(ref) do 53 | %{ 54 | pool_idx: pool_idx, 55 | in_flight_requests: in_flight_requests 56 | } = 57 | @atomic_idx 58 | |> Enum.map(fn {k, idx} -> {k, :atomics.get(ref, idx)} end) 59 | |> Map.new() 60 | 61 | result = %__MODULE__{ 62 | pool_index: pool_idx, 63 | in_flight_requests: in_flight_requests 64 | } 65 | 66 | {:ok, result} 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/finch/http2/request_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.RequestStream do 2 | @moduledoc false 3 | 4 | defstruct [:body, :status, :buffer, :continuation] 5 | 6 | def new(body) do 7 | enumerable = 8 | case body do 9 | {:stream, stream} -> Stream.map(stream, &with_byte_size/1) 10 | nil -> [with_byte_size("")] 11 | io_data -> [with_byte_size(io_data)] 12 | end 13 | 14 | reducer = &reduce_with_suspend/2 15 | 16 | %__MODULE__{ 17 | body: body, 18 | status: if(body == nil, do: :done, else: :streaming), 19 | buffer: <<>>, 20 | continuation: &Enumerable.reduce(enumerable, &1, reducer) 21 | } 22 | end 23 | 24 | defp with_byte_size(binary) when is_binary(binary), do: {binary, byte_size(binary)} 25 | defp with_byte_size(io_data), do: io_data |> IO.iodata_to_binary() |> with_byte_size() 26 | 27 | defp reduce_with_suspend( 28 | {message, message_size}, 29 | {message_buffer, message_buffer_size, window} 30 | ) 31 | when message_size + message_buffer_size > window do 32 | {:suspend, 33 | {[{message, message_size} | message_buffer], message_size + message_buffer_size, window}} 34 | end 35 | 36 | defp reduce_with_suspend( 37 | {message, message_size}, 38 | {message_buffer, message_buffer_size, window} 39 | ) do 40 | {:cont, {[message | message_buffer], message_size + message_buffer_size, window}} 41 | end 42 | 43 | # gets the next chunk of data that will fit into the given window size 44 | def next_chunk(request, window) 45 | 46 | # when the buffer is empty, continue reducing the stream 47 | def next_chunk(%__MODULE__{buffer: <<>>} = request, window) do 48 | continue_reduce(request, {[], 0, window}) 49 | end 50 | 51 | def next_chunk(%__MODULE__{buffer: buffer} = request, window) do 52 | case buffer do 53 | <> -> 54 | # when the buffer contains more bytes than a window, send as much of the 55 | # buffer as we can 56 | {put_in(request.buffer, rest), bytes_to_send} 57 | 58 | _ -> 59 | # when the buffer can fit in the windows, continue reducing using the buffer 60 | # as the accumulator 61 | continue_reduce(request, {[buffer], byte_size(buffer), window}) 62 | end 63 | end 64 | 65 | defp continue_reduce(request, acc) do 66 | case request.continuation.({:cont, acc}) do 67 | {finished, {messages, _size, _window}} when finished in [:done, :halted] -> 68 | {put_in(request.status, :done), Enum.reverse(messages)} 69 | 70 | {:suspended, 71 | {[{overload_message, overload_message_size} | messages_that_fit], total_size, window_size}, 72 | next_continuation} -> 73 | fittable_size = window_size - (total_size - overload_message_size) 74 | 75 | <> = 76 | overload_message 77 | 78 | request = %{request | continuation: next_continuation, buffer: overload_binary} 79 | 80 | {request, Enum.reverse([fittable_binary | messages_that_fit])} 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/finch/pool.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.Pool do 2 | @moduledoc false 3 | # Defines a behaviour that both http1 and http2 pools need to implement. 4 | 5 | @type request_ref :: {pool_mod :: module(), cancel_ref :: term()} 6 | 7 | @callback request( 8 | pid(), 9 | Finch.Request.t(), 10 | acc, 11 | Finch.stream(acc), 12 | Finch.name(), 13 | list() 14 | ) :: {:ok, acc} | {:error, term(), acc} 15 | when acc: term() 16 | 17 | @callback async_request( 18 | pid(), 19 | Finch.Request.t(), 20 | Finch.name(), 21 | list() 22 | ) :: request_ref() 23 | 24 | @callback cancel_async_request(request_ref()) :: :ok 25 | 26 | @callback get_pool_status( 27 | finch_name :: atom(), 28 | {schema :: atom(), host :: String.t(), port :: integer()} 29 | ) :: {:ok, list(map)} | {:error, :not_found} 30 | 31 | defguard is_request_ref(ref) when tuple_size(ref) == 2 and is_atom(elem(ref, 0)) 32 | end 33 | -------------------------------------------------------------------------------- /lib/finch/pool_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.PoolManager do 2 | @moduledoc false 3 | use GenServer 4 | 5 | @mint_tls_opts [ 6 | :cacertfile, 7 | :ciphers, 8 | :depth, 9 | :eccs, 10 | :hibernate_after, 11 | :partial_chain, 12 | :reuse_sessions, 13 | :secure_renegotiate, 14 | :server_name_indication, 15 | :signature_algs, 16 | :signature_algs_cert, 17 | :supported_groups, 18 | :verify, 19 | :verify_fun, 20 | :versions 21 | ] 22 | 23 | @default_conn_hostname "localhost" 24 | 25 | def start_link(config) do 26 | GenServer.start_link(__MODULE__, config, name: config.manager_name) 27 | end 28 | 29 | @impl true 30 | def init(config) do 31 | Enum.each(config.pools, fn {shp, _} -> 32 | do_start_pools(shp, config) 33 | end) 34 | 35 | {:ok, config} 36 | end 37 | 38 | def get_pool(registry_name, {_scheme, _host, _port} = key, opts \\ []) do 39 | case lookup_pool(registry_name, key) do 40 | {pid, _} = pool when is_pid(pid) -> 41 | pool 42 | 43 | :none -> 44 | if Keyword.get(opts, :auto_start?, true), 45 | do: start_pools(registry_name, key), 46 | else: :not_found 47 | end 48 | end 49 | 50 | def lookup_pool(registry, key) do 51 | case all_pool_instances(registry, key) do 52 | [] -> 53 | :none 54 | 55 | [pool] -> 56 | pool 57 | 58 | pools -> 59 | # TODO implement alternative strategies 60 | Enum.random(pools) 61 | end 62 | end 63 | 64 | def all_pool_instances(registry, key), do: Registry.lookup(registry, key) 65 | 66 | def start_pools(registry_name, shp) do 67 | {:ok, config} = Registry.meta(registry_name, :config) 68 | GenServer.call(config.manager_name, {:start_pools, shp}) 69 | end 70 | 71 | @impl true 72 | def handle_call({:start_pools, shp}, _from, state) do 73 | reply = 74 | case lookup_pool(state.registry_name, shp) do 75 | :none -> do_start_pools(shp, state) 76 | pool -> pool 77 | end 78 | 79 | {:reply, reply, state} 80 | end 81 | 82 | defp do_start_pools(shp, config) do 83 | pool_config = pool_config(config, shp) 84 | 85 | if pool_config.start_pool_metrics? do 86 | put_pool_count(config, shp, pool_config.count) 87 | end 88 | 89 | Enum.map(1..pool_config.count, fn pool_idx -> 90 | pool_args = pool_args(shp, config, pool_config, pool_idx) 91 | # Choose pool type here... 92 | {:ok, pid} = 93 | DynamicSupervisor.start_child(config.supervisor_name, {pool_config.mod, pool_args}) 94 | 95 | {pid, pool_config.mod} 96 | end) 97 | |> hd() 98 | end 99 | 100 | defp put_pool_count(%{registry_name: name}, shp, val), 101 | do: :persistent_term.put({__MODULE__, :pool_count, name, shp}, val) 102 | 103 | def get_pool_count(finch_name, shp), 104 | do: :persistent_term.get({__MODULE__, :pool_count, finch_name, shp}, nil) 105 | 106 | defp pool_config(%{pools: config, default_pool_config: default}, shp) do 107 | config 108 | |> Map.get(shp, default) 109 | |> maybe_drop_tls_options(shp) 110 | |> maybe_add_hostname(shp) 111 | end 112 | 113 | # Drop TLS options from :conn_opts for default pools with :http scheme, 114 | # otherwise you will get :badarg error from :gen_tcp 115 | defp maybe_drop_tls_options(config, {:http, _, _} = _shp) when is_map(config) do 116 | with conn_opts when is_list(conn_opts) <- config[:conn_opts], 117 | trns_opts when is_list(trns_opts) <- conn_opts[:transport_opts] do 118 | trns_opts = Keyword.drop(trns_opts, @mint_tls_opts) 119 | conn_opts = Keyword.put(conn_opts, :transport_opts, trns_opts) 120 | Map.put(config, :conn_opts, conn_opts) 121 | else 122 | _ -> config 123 | end 124 | end 125 | 126 | defp maybe_drop_tls_options(config, _), do: config 127 | 128 | # Hostname is required when the address is not a URL (binary) so we need to specify 129 | # a default value in case the configuration does not specify one. 130 | defp maybe_add_hostname(config, {_scheme, {:local, _path}, _port} = _shp) when is_map(config) do 131 | conn_opts = 132 | config |> Map.get(:conn_opts, []) |> Keyword.put_new(:hostname, @default_conn_hostname) 133 | 134 | Map.put(config, :conn_opts, conn_opts) 135 | end 136 | 137 | defp maybe_add_hostname(config, _), do: config 138 | 139 | defp pool_args(shp, config, %{mod: Finch.HTTP1.Pool} = pool_config, pool_idx), 140 | do: { 141 | shp, 142 | config.registry_name, 143 | pool_config.size, 144 | pool_config, 145 | pool_config.pool_max_idle_time, 146 | pool_config.start_pool_metrics?, 147 | pool_idx 148 | } 149 | 150 | defp pool_args(shp, config, %{mod: Finch.HTTP2.Pool} = pool_config, pool_idx), 151 | do: { 152 | shp, 153 | config.registry_name, 154 | pool_config, 155 | pool_config.start_pool_metrics?, 156 | pool_idx 157 | } 158 | end 159 | -------------------------------------------------------------------------------- /lib/finch/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.Request do 2 | @moduledoc """ 3 | A request struct. 4 | """ 5 | 6 | @enforce_keys [:scheme, :host, :port, :method, :path, :headers, :body, :query] 7 | defstruct [ 8 | :scheme, 9 | :host, 10 | :port, 11 | :method, 12 | :path, 13 | :headers, 14 | :body, 15 | :query, 16 | :unix_socket, 17 | private: %{} 18 | ] 19 | 20 | @atom_methods [ 21 | :get, 22 | :post, 23 | :put, 24 | :patch, 25 | :delete, 26 | :head, 27 | :options 28 | ] 29 | @methods [ 30 | "GET", 31 | "POST", 32 | "PUT", 33 | "PATCH", 34 | "DELETE", 35 | "HEAD", 36 | "OPTIONS" 37 | ] 38 | @atom_to_method Enum.zip(@atom_methods, @methods) |> Enum.into(%{}) 39 | 40 | @typedoc """ 41 | An HTTP request method represented as an `atom()` or a `String.t()`. 42 | 43 | The following atom methods are supported: `#{Enum.map_join(@atom_methods, "`, `", &inspect/1)}`. 44 | You can use any arbitrary method by providing it as a `String.t()`. 45 | """ 46 | @type method() :: :get | :post | :head | :patch | :delete | :options | :put | String.t() 47 | 48 | @typedoc """ 49 | A Uniform Resource Locator, the address of a resource on the Web. 50 | """ 51 | @type url() :: String.t() | URI.t() 52 | 53 | @typedoc """ 54 | Request headers. 55 | """ 56 | @type headers() :: Mint.Types.headers() 57 | 58 | @typedoc """ 59 | Optional request body. 60 | """ 61 | @type body() :: iodata() | {:stream, Enumerable.t()} | nil 62 | 63 | @type private_metadata() :: %{optional(atom()) => term()} 64 | 65 | @type t :: %__MODULE__{ 66 | scheme: Mint.Types.scheme(), 67 | host: String.t() | nil, 68 | port: :inet.port_number(), 69 | method: String.t(), 70 | path: String.t(), 71 | headers: headers(), 72 | body: body(), 73 | query: String.t() | nil, 74 | unix_socket: String.t() | nil, 75 | private: private_metadata() 76 | } 77 | 78 | @doc """ 79 | Sets a new **private** key and value in the request metadata. This storage is meant to be used by libraries 80 | and frameworks to inject information about the request that needs to be retrieved later on, for example, 81 | from handlers that consume `Finch.Telemetry` events. 82 | """ 83 | @spec put_private(t(), key :: atom(), value :: term()) :: t() 84 | def put_private(%__MODULE__{private: private} = request, key, value) when is_atom(key) do 85 | %{request | private: Map.put(private, key, value)} 86 | end 87 | 88 | def put_private(%__MODULE__{}, key, _) do 89 | raise ArgumentError, """ 90 | got unsupported private metadata key #{inspect(key)} 91 | only atoms are allowed as keys of the `:private` field. 92 | """ 93 | end 94 | 95 | @doc false 96 | def request_path(%{path: path, query: nil}), do: path 97 | def request_path(%{path: path, query: ""}), do: path 98 | def request_path(%{path: path, query: query}), do: "#{path}?#{query}" 99 | 100 | @doc false 101 | def build(method, url, headers, body, opts) do 102 | unix_socket = Keyword.get(opts, :unix_socket) 103 | {scheme, host, port, path, query} = parse_url(url) 104 | 105 | %Finch.Request{ 106 | scheme: scheme, 107 | host: host, 108 | port: port, 109 | method: build_method(method), 110 | path: path, 111 | headers: headers, 112 | body: body, 113 | query: query, 114 | unix_socket: unix_socket 115 | } 116 | end 117 | 118 | @doc false 119 | def parse_url(url) when is_binary(url) do 120 | url |> URI.parse() |> parse_url() 121 | end 122 | 123 | def parse_url(%URI{} = parsed_uri) do 124 | normalized_path = parsed_uri.path || "/" 125 | 126 | scheme = 127 | case parsed_uri.scheme do 128 | "https" -> 129 | :https 130 | 131 | "http" -> 132 | :http 133 | 134 | nil -> 135 | raise ArgumentError, "scheme is required for url: #{URI.to_string(parsed_uri)}" 136 | 137 | scheme -> 138 | raise ArgumentError, 139 | "invalid scheme \"#{scheme}\" for url: #{URI.to_string(parsed_uri)}" 140 | end 141 | 142 | {scheme, parsed_uri.host, parsed_uri.port, normalized_path, parsed_uri.query} 143 | end 144 | 145 | defp build_method(method) when is_binary(method), do: method 146 | defp build_method(method) when method in @atom_methods, do: @atom_to_method[method] 147 | 148 | defp build_method(method) do 149 | supported = Enum.map_join(@atom_methods, ", ", &inspect/1) 150 | 151 | raise ArgumentError, """ 152 | got unsupported atom method #{inspect(method)}. 153 | Only the following methods can be provided as atoms: #{supported}. 154 | Otherwise you must pass a binary. 155 | """ 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /lib/finch/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.Response do 2 | @moduledoc """ 3 | A response to a request. 4 | """ 5 | 6 | alias __MODULE__ 7 | 8 | defstruct [ 9 | :status, 10 | body: "", 11 | headers: [], 12 | trailers: [] 13 | ] 14 | 15 | @type t :: %Response{ 16 | status: Mint.Types.status(), 17 | body: binary(), 18 | headers: Mint.Types.headers(), 19 | trailers: Mint.Types.headers() 20 | } 21 | end 22 | -------------------------------------------------------------------------------- /lib/finch/ssl.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.SSL do 2 | @moduledoc false 3 | 4 | alias Mint.HTTP 5 | 6 | def maybe_log_secrets(:https, conn_opts, mint) do 7 | ssl_key_log_file_device = Keyword.get(conn_opts, :ssl_key_log_file_device) 8 | 9 | if ssl_key_log_file_device != nil do 10 | socket = HTTP.get_socket(mint) 11 | # Note: not every ssl library version returns information for :keylog. By using `with` here, 12 | # anything other than the expected return value is silently ignored. 13 | with {:ok, [{:keylog, keylog_items}]} <- :ssl.connection_information(socket, [:keylog]) do 14 | for keylog_item <- keylog_items do 15 | :ok = IO.puts(ssl_key_log_file_device, keylog_item) 16 | end 17 | end 18 | else 19 | :ok 20 | end 21 | end 22 | 23 | def maybe_log_secrets(_scheme, _conn_opts, _mint) do 24 | :ok 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/finch/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration. 4 | 5 | Unless specified, all times are in `:native` units. 6 | 7 | Finch executes the following events: 8 | 9 | ### Request Start 10 | 11 | `[:finch, :request, :start]` - Executed when `Finch.request/3` or `Finch.stream/5` is called. 12 | 13 | #### Measurements 14 | 15 | * `:system_time` - The system time. 16 | 17 | #### Metadata 18 | 19 | * `:name` - The name of the Finch instance. 20 | * `:request` - The request (`Finch.Request`). 21 | 22 | ### Request Stop 23 | 24 | `[:finch, :request, :stop]` - Executed after `Finch.request/3` or `Finch.stream/5` ended. 25 | 26 | #### Measurements 27 | 28 | * `:duration` - Time taken from the request start event. 29 | 30 | #### Metadata 31 | 32 | * `:name` - The name of the Finch instance. 33 | * `:request` - The request (`Finch.Request`). 34 | * `:result` - The result of the operation. In case of `Finch.stream/5` this is 35 | `{:ok, acc} | {:error, Exception.t()}`, where `acc` is the accumulator result of the 36 | reducer passed in `Finch.stream/5`. In case of `Finch.request/3` this is 37 | `{:ok, Finch.Response.t()} | {:error, Exception.t()}`. 38 | 39 | ### Request Exception 40 | 41 | `[:finch, :request, :exception]` - Executed when an exception occurs while executing 42 | `Finch.request/3` or `Finch.stream/5`. 43 | 44 | #### Measurements 45 | 46 | * `:duration` - The time it took since the start before raising the exception. 47 | 48 | #### Metadata 49 | 50 | * `:name` - The name of the Finch instance. 51 | * `:request` - The request (`Finch.Request`). 52 | * `:kind` - The type of exception. 53 | * `:reason` - Error description or error data. 54 | * `:stacktrace` - The stacktrace. 55 | 56 | ### Queue Start 57 | 58 | `[:finch, :queue, :start]` - Executed before checking out an HTTP1 connection from the pool. 59 | 60 | #### Measurements 61 | 62 | * `:system_time` - The system time. 63 | 64 | #### Metadata 65 | 66 | * `:name` - The name of the Finch instance. 67 | * `:pool` - The pool's PID. 68 | * `:request` - The request (`Finch.Request`). 69 | 70 | ### Queue Stop 71 | 72 | `[:finch, :queue, :stop]` - Executed after an HTTP1 connection is retrieved from the pool. 73 | 74 | #### Measurements 75 | 76 | * `:duration` - Time taken to check out a pool connection. 77 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 78 | 79 | #### Metadata 80 | 81 | * `:name` - The name of the Finch instance. 82 | * `:pool` - The pool's PID. 83 | * `:request` - The request (`Finch.Request`). 84 | 85 | ### Queue Exception 86 | 87 | `[:finch, :queue, :exception]` - Executed if checking out an HTTP1 connection throws an exception. 88 | 89 | #### Measurements 90 | 91 | * `:duration` - The time it took since queue start event before raising an exception. 92 | 93 | #### Metadata 94 | 95 | * `:name` - The name of the Finch instance. 96 | * `:request` - The request (`Finch.Request`). 97 | * `:kind` - The type of exception. 98 | * `:reason` - Error description or error data. 99 | * `:stacktrace` - The stacktrace. 100 | 101 | ### Connect Start 102 | 103 | `[:finch, :connect, :start]` - Executed before opening a new connection. 104 | If a connection is being re-used this event will *not* be executed. 105 | 106 | #### Measurements 107 | 108 | * `:system_time` - The system time. 109 | 110 | #### Metadata 111 | 112 | * `:name` - The name of the Finch instance. 113 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 114 | * `:host` - The host address. 115 | * `:port` - The port to connect on. 116 | 117 | ### Connect Stop 118 | 119 | `[:finch, :connect, :stop]` - Executed after a connection is opened. 120 | 121 | #### Measurements 122 | 123 | * `:duration` - Time taken to connect to the host. 124 | 125 | #### Metadata 126 | 127 | * `:name` - The name of the Finch instance. 128 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 129 | * `:host` - The host address. 130 | * `:port` - The port to connect on. 131 | * `:error` - This value is optional. It includes any errors that occurred while opening the connection. 132 | 133 | ### Send Start 134 | 135 | `[:finch, :send, :start]` - Executed before sending a request. 136 | 137 | #### Measurements 138 | 139 | * `:name` - The name of the Finch instance. 140 | * `:system_time` - The system time. 141 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 142 | 143 | #### Metadata 144 | 145 | * `:request` - The request (`Finch.Request`). 146 | 147 | ### Send Stop 148 | 149 | `[:finch, :send, :stop]` - Executed after a request is finished. 150 | 151 | #### Measurements 152 | 153 | * `:name` - The name of the Finch instance. 154 | * `:duration` - Time taken to make the request. 155 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 156 | 157 | #### Metadata 158 | 159 | * `:request` - The request (`Finch.Request`). 160 | * `:error` - This value is optional. It includes any errors that occurred while making the request. 161 | 162 | ### Receive Start 163 | 164 | `[:finch, :recv, :start]` - Executed before receiving the response. 165 | 166 | #### Measurements 167 | 168 | * `:system_time` - The system time. 169 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 170 | 171 | #### Metadata 172 | 173 | * `:name` - The name of the Finch instance. 174 | * `:request` - The request (`Finch.Request`). 175 | 176 | ### Receive Stop 177 | 178 | `[:finch, :recv, :stop]` - Executed after a response has been fully received. 179 | 180 | #### Measurements 181 | 182 | * `:duration` - Duration to receive the response. 183 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 184 | 185 | #### Metadata 186 | 187 | * `:name` - The name of the Finch instance. 188 | * `:request` - The request (`Finch.Request`). 189 | * `:status` - The response status (`Mint.Types.status()`). 190 | * `:headers` - The response headers (`Mint.Types.headers()`). 191 | * `:error` - This value is optional. It includes any errors that occurred while receiving the response. 192 | 193 | ### Receive Exception 194 | 195 | `[:finch, :recv, :exception]` - Executed if an exception is thrown before the response has 196 | been fully received. 197 | 198 | #### Measurements 199 | 200 | * `:duration` - The time it took before raising an exception 201 | 202 | #### Metadata 203 | 204 | * `:name` - The name of the Finch instance. 205 | * `:request` - The request (`Finch.Request`). 206 | * `:kind` - The type of exception. 207 | * `:reason` - Error description or error data. 208 | * `:stacktrace` - The stacktrace. 209 | 210 | ### Reused Connection 211 | 212 | `[:finch, :reused_connection]` - Executed if an existing HTTP1 connection is reused. There are no measurements provided with this event. 213 | 214 | #### Metadata 215 | 216 | * `:name` - The name of the Finch instance. 217 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 218 | * `:host` - The host address. 219 | * `:port` - The port to connect on. 220 | 221 | ### Conn Max Idle Time Exceeded 222 | 223 | `[:finch, :conn_max_idle_time_exceeded]` - Executed if an HTTP1 connection was discarded because the `conn_max_idle_time` had been reached. 224 | 225 | #### Measurements 226 | 227 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 228 | 229 | #### Metadata 230 | 231 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 232 | * `:host` - The host address. 233 | * `:port` - The port to connect on. 234 | 235 | ### Pool Max Idle Time Exceeded 236 | 237 | `[:finch, :pool_max_idle_time_exceeded]` - Executed if an HTTP1 pool was terminated because the `pool_max_idle_time` has been reached. There are no measurements provided with this event. 238 | 239 | #### Metadata 240 | 241 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 242 | * `:host` - The host address. 243 | * `:port` - The port to connect on. 244 | 245 | ### Max Idle Time Exceeded (Deprecated) 246 | 247 | `[:finch, :max_idle_time_exceeded]` - Executed if an HTTP1 connection was discarded because the `max_idle_time` had been reached. 248 | 249 | *Deprecated:* use `:conn_max_idle_time_exceeded` event instead. 250 | 251 | #### Measurements 252 | 253 | * `:idle_time` - Elapsed time since the connection was last checked in or initialized. 254 | 255 | #### Metadata 256 | 257 | * `:scheme` - The scheme used in the connection. either `http` or `https`. 258 | * `:host` - The host address. 259 | * `:port` - The port to connect on. 260 | """ 261 | 262 | @doc false 263 | # emits a `start` telemetry event and returns the the start time 264 | def start(event, meta \\ %{}, extra_measurements \\ %{}) do 265 | start_time = System.monotonic_time() 266 | 267 | :telemetry.execute( 268 | [:finch, event, :start], 269 | Map.merge(extra_measurements, %{system_time: System.system_time()}), 270 | meta 271 | ) 272 | 273 | start_time 274 | end 275 | 276 | @doc false 277 | # Emits a stop event. 278 | def stop(event, start_time, meta \\ %{}, extra_measurements \\ %{}) do 279 | end_time = System.monotonic_time() 280 | measurements = Map.merge(extra_measurements, %{duration: end_time - start_time}) 281 | 282 | :telemetry.execute( 283 | [:finch, event, :stop], 284 | measurements, 285 | meta 286 | ) 287 | end 288 | 289 | @doc false 290 | def exception(event, start_time, kind, reason, stack, meta \\ %{}, extra_measurements \\ %{}) do 291 | end_time = System.monotonic_time() 292 | measurements = Map.merge(extra_measurements, %{duration: end_time - start_time}) 293 | 294 | meta = 295 | meta 296 | |> Map.put(:kind, kind) 297 | |> Map.put(:reason, reason) 298 | |> Map.put(:stacktrace, stack) 299 | 300 | :telemetry.execute([:finch, event, :exception], measurements, meta) 301 | end 302 | 303 | @doc false 304 | # Used for reporting generic events 305 | def event(event, measurements, meta) do 306 | :telemetry.execute([:finch, event], measurements, meta) 307 | end 308 | 309 | @doc false 310 | # Used to easily create :start, :stop, :exception events. 311 | def span(event, start_metadata, fun) do 312 | :telemetry.span( 313 | [:finch, event], 314 | start_metadata, 315 | fun 316 | ) 317 | end 318 | end 319 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.MixProject do 2 | use Mix.Project 3 | 4 | @name "Finch" 5 | @version "0.19.0" 6 | @repo_url "https://github.com/sneako/finch" 7 | 8 | def project do 9 | [ 10 | app: :finch, 11 | version: @version, 12 | elixir: "~> 1.13", 13 | description: "An HTTP client focused on performance.", 14 | package: package(), 15 | docs: docs(), 16 | elixirc_paths: elixirc_paths(Mix.env()), 17 | start_permanent: Mix.env() == :prod, 18 | name: @name, 19 | source_url: @repo_url, 20 | deps: deps() 21 | ] 22 | end 23 | 24 | defp elixirc_paths(:test), do: ["lib", "test/support"] 25 | defp elixirc_paths(:dev), do: ["lib", "test/support/test_usage.ex"] 26 | defp elixirc_paths(_), do: ["lib"] 27 | 28 | def application do 29 | [ 30 | extra_applications: [:logger] 31 | ] 32 | end 33 | 34 | defp deps do 35 | [ 36 | {:mint, "~> 1.6.2 or ~> 1.7"}, 37 | {:nimble_pool, "~> 1.1"}, 38 | {:nimble_options, "~> 0.4 or ~> 1.0"}, 39 | {:telemetry, "~> 0.4 or ~> 1.0"}, 40 | {:mime, "~> 1.0 or ~> 2.0"}, 41 | {:ex_doc, "~> 0.28", only: :dev, runtime: false}, 42 | {:credo, "~> 1.3", only: [:dev, :test]}, 43 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 44 | {:bypass, "~> 2.0", only: :test}, 45 | {:cowboy, "~> 2.7", only: [:dev, :test]}, 46 | {:plug_cowboy, "~> 2.0", only: [:dev, :test]}, 47 | {:x509, "~> 0.8", only: [:dev, :test]}, 48 | {:mimic, "~> 1.7", only: :test} 49 | ] 50 | end 51 | 52 | defp package do 53 | [ 54 | licenses: ["MIT"], 55 | links: %{ 56 | "GitHub" => @repo_url, 57 | "Changelog" => "https://hexdocs.pm/finch/changelog.html" 58 | } 59 | ] 60 | end 61 | 62 | defp docs do 63 | [ 64 | logo: "assets/Finch_logo_all-White.png", 65 | source_ref: "v#{@version}", 66 | source_url: @repo_url, 67 | main: @name, 68 | extras: [ 69 | "CHANGELOG.md" 70 | ] 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "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"}, 4 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 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.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 7 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 10 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 11 | "ex_doc": {:hex, :ex_doc, "0.31.0", "06eb1dfd787445d9cab9a45088405593dd3bb7fe99e097eaa71f37ba80c7a676", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5350cafa6b7f77bdd107aa2199fe277acf29d739aba5aee7e865fc680c62a110"}, 12 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 13 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [: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", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, 18 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 19 | "mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"}, 20 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [: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", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 21 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 23 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 24 | "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [: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", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, 25 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [: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", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 26 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 27 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 28 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 29 | "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/finch/http1/integration_proxy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.IntegrationProxyTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Finch.HTTP1Server 5 | 6 | setup_all do 7 | {:ok, listen_socket} = :ssl.listen(0, mode: :binary) 8 | {:ok, {_address, port}} = :ssl.sockname(listen_socket) 9 | :ssl.close(listen_socket) 10 | 11 | # Not quite a proper forward proxy server, but good enough 12 | {:ok, _} = HTTP1Server.start(port) 13 | 14 | {:ok, proxy_port: port} 15 | end 16 | 17 | test "requests HTTP through a proxy", %{proxy_port: proxy_port} do 18 | start_finch(proxy_port) 19 | 20 | assert {:ok, _} = Finch.build(:get, "http://example.com") |> Finch.request(ProxyFinch) 21 | end 22 | 23 | defp start_finch(proxy_port) do 24 | start_supervised!( 25 | {Finch, 26 | name: ProxyFinch, 27 | pools: %{ 28 | default: [ 29 | protocols: [:http1], 30 | conn_opts: [ 31 | proxy: {:http, "localhost", proxy_port, []} 32 | ] 33 | ] 34 | }} 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/finch/http1/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.IntegrationTest do 2 | use ExUnit.Case, async: false 3 | import ExUnit.CaptureLog 4 | 5 | require Logger 6 | 7 | alias Finch.HTTPS1Server 8 | alias Finch.TestHelper 9 | 10 | setup_all do 11 | {:ok, listen_socket} = :ssl.listen(0, mode: :binary) 12 | {:ok, {_address, port}} = :ssl.sockname(listen_socket) 13 | :ssl.close(listen_socket) 14 | 15 | {:ok, _} = HTTPS1Server.start(port) 16 | 17 | {:ok, url: "https://localhost:#{port}"} 18 | end 19 | 20 | @tag :capture_log 21 | test "fail to negotiate h2 protocol", %{url: url} do 22 | start_supervised!( 23 | {Finch, 24 | name: H2Finch, 25 | pools: %{ 26 | default: [ 27 | protocols: [:http2], 28 | conn_opts: [ 29 | transport_opts: [ 30 | verify: :verify_none 31 | ] 32 | ] 33 | ] 34 | }} 35 | ) 36 | 37 | assert capture_log(fn -> 38 | {:error, _} = Finch.build(:get, url) |> Finch.request(H2Finch) 39 | end) =~ "No application protocol" 40 | end 41 | 42 | @tag :capture_log 43 | @tag skip: TestHelper.ssl_version() < [10, 2] 44 | test "writes TLS secrets to SSLKEYLOGFILE file", %{url: url} do 45 | tmp_dir = System.tmp_dir() 46 | log_file = Path.join(tmp_dir, "ssl-key-file.log") 47 | :ok = System.put_env("SSLKEYLOGFILE", log_file) 48 | 49 | start_finch([:"tlsv1.2", :"tlsv1.3"]) 50 | 51 | try do 52 | assert {:ok, _} = Finch.build(:get, url) |> Finch.request(H1Finch) 53 | assert File.stat!(log_file).size > 0 54 | after 55 | File.rm!(log_file) 56 | System.delete_env("SSLKEYLOGFILE") 57 | end 58 | end 59 | 60 | @tag :capture_log 61 | @tag skip: TestHelper.ssl_version() < [10, 2] 62 | test "writes TLS secrets to SSLKEYLOGFILE file using TLS 1.3" do 63 | tmp_dir = System.tmp_dir() 64 | log_file = Path.join(tmp_dir, "ssl-key-file.log") 65 | :ok = System.put_env("SSLKEYLOGFILE", log_file) 66 | 67 | start_finch([:"tlsv1.3"]) 68 | 69 | try do 70 | {:ok, _} = Finch.build(:get, "https://rabbitmq.com") |> Finch.request(H1Finch) 71 | assert File.stat!(log_file).size > 0 72 | after 73 | File.rm!(log_file) 74 | System.delete_env("SSLKEYLOGFILE") 75 | end 76 | end 77 | 78 | @tag :capture_log 79 | @tag skip: TestHelper.ssl_version() < [10, 2] 80 | test "cancel streaming response", %{url: url} do 81 | start_finch([:"tlsv1.2", :"tlsv1.3"]) 82 | 83 | assert catch_throw( 84 | Finch.stream(Finch.build(:get, url), H1Finch, :ok, fn {:status, _}, :ok -> 85 | throw(:error) 86 | end) 87 | ) == :error 88 | end 89 | 90 | test "trailers" do 91 | handler = fn transport, socket -> 92 | data = """ 93 | HTTP/1.1 200 OK 94 | transfer-encoding: chunked 95 | trailer: x-foo, x-bar 96 | 97 | 6\r 98 | chunk1\r 99 | 6\r 100 | chunk2\r 101 | 0\r 102 | x-foo: foo\r 103 | x-bar: bar\r 104 | \r 105 | """ 106 | 107 | :ok = transport.send(socket, data) 108 | end 109 | 110 | {:ok, socket} = Finch.MockSocketServer.start(socket: {nil, []}, handler: handler) 111 | {:ok, port} = :inet.port(socket) 112 | url = "http://localhost:#{port}" 113 | 114 | start_supervised!( 115 | {Finch, 116 | name: H1Finch, 117 | pools: %{ 118 | default: [ 119 | protocols: [:http1] 120 | ] 121 | }} 122 | ) 123 | 124 | {:ok, resp} = Finch.build(:get, url) |> Finch.request(H1Finch) 125 | assert resp.status == 200 126 | assert resp.headers == [{"transfer-encoding", "chunked"}, {"trailer", "x-foo, x-bar"}] 127 | assert resp.body == "chunk1chunk2" 128 | assert resp.trailers == [{"x-foo", "foo"}, {"x-bar", "bar"}] 129 | end 130 | 131 | defp start_finch(tls_versions) do 132 | start_supervised!( 133 | {Finch, 134 | name: H1Finch, 135 | pools: %{ 136 | default: [ 137 | protocols: [:http1], 138 | conn_opts: [ 139 | transport_opts: [ 140 | reuse_sessions: false, 141 | verify: :verify_none, 142 | keep_secrets: true, 143 | versions: tls_versions, 144 | ciphers: get_ciphers_for_tls_versions(tls_versions) 145 | ] 146 | ] 147 | ] 148 | }} 149 | ) 150 | end 151 | 152 | def get_ciphers_for_tls_versions(tls_versions) do 153 | if TestHelper.ssl_version() >= [8, 2, 4] do 154 | # Note: :ssl.filter_cipher_suites/2 is available 155 | tls_versions 156 | |> List.foldl([], fn v, acc -> 157 | [:ssl.filter_cipher_suites(:ssl.cipher_suites(:all, v), []) | acc] 158 | end) 159 | |> List.flatten() 160 | else 161 | :ssl.cipher_suites(:all, :"tlsv1.2") 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /test/finch/http1/pool_metrics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.PoolMetricsTest do 2 | use FinchCase, async: true 3 | use Mimic 4 | 5 | alias Finch.HTTP1.PoolMetrics 6 | alias Finch.PoolManager 7 | 8 | test "should not start if opt is false", %{bypass: bypass, finch_name: finch_name} do 9 | start_supervised!( 10 | {Finch, 11 | name: finch_name, pools: %{default: [protocols: [:http1], start_pool_metrics?: false]}} 12 | ) 13 | 14 | shp = shp_from_bypass(bypass) 15 | 16 | parent = self() 17 | 18 | Bypass.expect(bypass, "GET", "/", fn conn -> 19 | ["number", number] = String.split(conn.query_string, "=") 20 | send(parent, {:ping_bypass, number}) 21 | Plug.Conn.send_resp(conn, 200, "OK") 22 | end) 23 | 24 | refs = 25 | Enum.map(1..1, fn i -> 26 | Finch.build(:get, endpoint(bypass, "?number=#{i}")) 27 | |> Finch.async_request(finch_name) 28 | end) 29 | 30 | assert_receive {:ping_bypass, "1"}, 500 31 | 32 | Enum.each(refs, fn req_ref -> 33 | assert_receive {^req_ref, {:status, 200}}, 2000 34 | end) 35 | 36 | wait_connection_checkin() 37 | assert nil == PoolManager.get_pool_count(finch_name, shp) 38 | assert {:error, :not_found} = Finch.get_pool_status(finch_name, shp) 39 | end 40 | 41 | test "get pool status", %{bypass: bypass, finch_name: finch_name} do 42 | start_supervised!( 43 | {Finch, 44 | name: finch_name, pools: %{default: [protocols: [:http1], start_pool_metrics?: true]}} 45 | ) 46 | 47 | shp = shp_from_bypass(bypass) 48 | 49 | parent = self() 50 | 51 | Bypass.expect(bypass, "GET", "/", fn conn -> 52 | ["number", number] = String.split(conn.query_string, "=") 53 | send(parent, {:ping_bypass, number}) 54 | 55 | Process.sleep(:timer.seconds(1)) 56 | Plug.Conn.send_resp(conn, 200, "OK") 57 | end) 58 | 59 | refs = 60 | Enum.map(1..20, fn i -> 61 | Finch.build(:get, endpoint(bypass, "?number=#{i}")) 62 | |> Finch.async_request(finch_name) 63 | end) 64 | 65 | assert_receive {:ping_bypass, "20"}, 500 66 | 67 | assert {:ok, 68 | [ 69 | %PoolMetrics{ 70 | pool_index: 1, 71 | pool_size: 50, 72 | available_connections: 30, 73 | in_use_connections: 20 74 | } 75 | ]} = Finch.get_pool_status(finch_name, shp) 76 | 77 | Enum.each(refs, fn req_ref -> 78 | assert_receive {^req_ref, {:status, 200}}, 2000 79 | end) 80 | 81 | wait_connection_checkin() 82 | 83 | assert {:ok, 84 | [ 85 | %PoolMetrics{ 86 | pool_index: 1, 87 | pool_size: 50, 88 | available_connections: 50, 89 | in_use_connections: 0 90 | } 91 | ]} = Finch.get_pool_status(finch_name, shp) 92 | end 93 | 94 | test "get multi pool status", %{bypass: bypass, finch_name: finch_name} do 95 | start_supervised!( 96 | {Finch, 97 | name: finch_name, 98 | pools: %{default: [protocols: [:http1], start_pool_metrics?: true, count: 2]}} 99 | ) 100 | 101 | shp = shp_from_bypass(bypass) 102 | 103 | parent = self() 104 | 105 | Bypass.expect(bypass, "GET", "/", fn conn -> 106 | ["number", number] = String.split(conn.query_string, "=") 107 | send(parent, {:ping_bypass, number}) 108 | 109 | Process.sleep(:timer.seconds(1)) 110 | Plug.Conn.send_resp(conn, 200, "OK") 111 | end) 112 | 113 | refs = 114 | Enum.map(1..20, fn i -> 115 | Finch.build(:get, endpoint(bypass, "?number=#{i}")) 116 | |> Finch.async_request(finch_name) 117 | end) 118 | 119 | assert_receive {:ping_bypass, "20"}, 500 120 | 121 | assert {:ok, 122 | [ 123 | %PoolMetrics{ 124 | pool_index: 1, 125 | available_connections: p1_available_conns, 126 | in_use_connections: p1_in_use_conns 127 | }, 128 | %PoolMetrics{ 129 | pool_index: 2, 130 | available_connections: p2_available_conns, 131 | in_use_connections: p2_in_use_conns 132 | } 133 | ]} = Finch.get_pool_status(finch_name, shp) 134 | 135 | assert p1_available_conns + p2_available_conns == 80 136 | assert p1_in_use_conns + p2_in_use_conns == 20 137 | 138 | Enum.each(refs, fn req_ref -> 139 | assert_receive {^req_ref, {:status, 200}}, 2000 140 | end) 141 | 142 | wait_connection_checkin() 143 | 144 | assert {:ok, 145 | [ 146 | %PoolMetrics{ 147 | pool_index: 1, 148 | available_connections: p1_available_conns, 149 | in_use_connections: p1_in_use_conns 150 | }, 151 | %PoolMetrics{ 152 | pool_index: 2, 153 | available_connections: p2_available_conns, 154 | in_use_connections: p2_in_use_conns 155 | } 156 | ]} = Finch.get_pool_status(finch_name, shp) 157 | 158 | assert p1_available_conns + p2_available_conns == 100 159 | assert p1_in_use_conns + p2_in_use_conns == 0 160 | end 161 | 162 | test "get pool status with not reusable connections", %{bypass: bypass, finch_name: finch_name} do 163 | start_supervised!( 164 | {Finch, 165 | name: finch_name, 166 | pools: %{ 167 | default: [ 168 | protocols: [:http1], 169 | start_pool_metrics?: true, 170 | conn_max_idle_time: 1, 171 | size: 10 172 | ] 173 | }} 174 | ) 175 | 176 | shp = shp_from_bypass(bypass) 177 | 178 | parent = self() 179 | 180 | Bypass.expect(bypass, "GET", "/", fn conn -> 181 | ["number", number] = String.split(conn.query_string, "=") 182 | send(parent, {:ping_bypass, number}) 183 | Process.sleep(:timer.seconds(1)) 184 | Plug.Conn.send_resp(conn, 200, "OK") 185 | end) 186 | 187 | refs = 188 | Enum.map(1..8, fn i -> 189 | ref = 190 | Finch.build(:get, endpoint(bypass, "?number=#{i}")) 191 | |> Finch.async_request(finch_name) 192 | 193 | {ref, i} 194 | end) 195 | 196 | assert_receive {:ping_bypass, "8"}, 500 197 | 198 | assert {:ok, 199 | [ 200 | %PoolMetrics{ 201 | pool_index: 1, 202 | pool_size: 10, 203 | available_connections: 2, 204 | in_use_connections: 8 205 | } 206 | ]} = Finch.get_pool_status(finch_name, shp) 207 | 208 | Enum.each(refs, fn {req_ref, _number} -> assert_receive({^req_ref, {:status, 200}}, 2000) end) 209 | 210 | wait_connection_checkin() 211 | 212 | assert {:ok, 213 | [ 214 | %PoolMetrics{ 215 | pool_index: 1, 216 | pool_size: 10, 217 | available_connections: 10, 218 | in_use_connections: 0 219 | } 220 | ]} = Finch.get_pool_status(finch_name, shp) 221 | 222 | refs = 223 | Enum.map(1..8, fn i -> 224 | ref = 225 | Finch.build(:get, endpoint(bypass, "?number=#{i}")) 226 | |> Finch.async_request(finch_name) 227 | 228 | {ref, i} 229 | end) 230 | 231 | assert_receive {:ping_bypass, "8"}, 500 232 | 233 | assert {:ok, 234 | [ 235 | %PoolMetrics{ 236 | pool_index: 1, 237 | pool_size: 10, 238 | available_connections: 2, 239 | in_use_connections: 8 240 | } 241 | ]} = Finch.get_pool_status(finch_name, shp) 242 | 243 | Enum.each(refs, fn {req_ref, _number} -> assert_receive({^req_ref, {:status, 200}}, 2000) end) 244 | 245 | wait_connection_checkin() 246 | 247 | assert {:ok, 248 | [ 249 | %PoolMetrics{ 250 | pool_index: 1, 251 | pool_size: 10, 252 | available_connections: 10, 253 | in_use_connections: 0 254 | } 255 | ]} = Finch.get_pool_status(finch_name, shp) 256 | end 257 | 258 | test "get pool status with raise before checkin", %{finch_name: finch_name} do 259 | stub(Mint.HTTP, :request, fn _, _, _, _, _ -> 260 | raise "unexpected error" 261 | end) 262 | 263 | start_supervised!( 264 | {Finch, 265 | name: finch_name, 266 | pools: %{ 267 | default: [ 268 | protocols: [:http1], 269 | start_pool_metrics?: true, 270 | size: 10 271 | ] 272 | }} 273 | ) 274 | 275 | url = "http://raise.com" 276 | shp = {:http, "raise.com", 80} 277 | 278 | Enum.map(1..20, fn _idx -> 279 | assert_raise(RuntimeError, fn -> 280 | Finch.build(:get, url) |> Finch.request(finch_name) 281 | end) 282 | end) 283 | 284 | wait_connection_checkin() 285 | 286 | assert {:ok, 287 | [ 288 | %PoolMetrics{ 289 | pool_index: 1, 290 | pool_size: 10, 291 | available_connections: 10, 292 | in_use_connections: 0 293 | } 294 | ]} = Finch.get_pool_status(finch_name, shp) 295 | end 296 | 297 | defp shp_from_bypass(bypass), do: {:http, "localhost", bypass.port} 298 | 299 | defp wait_connection_checkin(), do: Process.sleep(5) 300 | end 301 | -------------------------------------------------------------------------------- /test/finch/http1/pool_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.PoolTest do 2 | use FinchCase, async: true 3 | 4 | alias Finch.HTTP1Server 5 | 6 | setup_all do 7 | port = 4005 8 | url = "http://localhost:#{port}" 9 | 10 | start_supervised!({HTTP1Server, port: port}) 11 | 12 | {:ok, url: url} 13 | end 14 | 15 | @tag capture_log: true 16 | test "should terminate pool after idle timeout", %{bypass: bypass, finch_name: finch_name} do 17 | test_name = to_string(finch_name) 18 | parent = self() 19 | 20 | handler = fn event, _measurements, meta, _config -> 21 | assert event == [:finch, :pool_max_idle_time_exceeded] 22 | assert is_atom(meta.scheme) 23 | assert is_binary(meta.host) 24 | assert is_integer(meta.port) 25 | send(parent, :telemetry_sent) 26 | end 27 | 28 | :telemetry.attach(test_name, [:finch, :pool_max_idle_time_exceeded], handler, nil) 29 | 30 | start_supervised!( 31 | {Finch, 32 | name: IdleFinch, 33 | pools: %{ 34 | default: [ 35 | protocols: [:http1], 36 | pool_max_idle_time: 5 37 | ] 38 | }} 39 | ) 40 | 41 | Bypass.expect_once(bypass, "GET", "/", fn conn -> 42 | Plug.Conn.send_resp(conn, 200, "OK") 43 | end) 44 | 45 | assert {:ok, %{status: 200}} = 46 | Finch.build(:get, endpoint(bypass)) 47 | |> Finch.request(IdleFinch) 48 | 49 | [{_, pool, _, _}] = DynamicSupervisor.which_children(IdleFinch.PoolSupervisor) 50 | 51 | Process.monitor(pool) 52 | 53 | assert_receive {:DOWN, _, :process, ^pool, {:shutdown, :idle_timeout}} 54 | 55 | assert [] = DynamicSupervisor.which_children(IdleFinch.PoolSupervisor) 56 | 57 | assert_receive :telemetry_sent 58 | 59 | :telemetry.detach(test_name) 60 | end 61 | 62 | describe "async_request" do 63 | @describetag bypass: false 64 | 65 | setup %{finch_name: finch_name} do 66 | start_supervised!({Finch, name: finch_name, pools: %{default: [protocols: [:http1]]}}) 67 | :ok 68 | end 69 | 70 | test "sends responses to the caller", %{finch_name: finch_name, url: url} do 71 | request_ref = 72 | Finch.build(:get, url <> "/stream/5/5") 73 | |> Finch.async_request(finch_name) 74 | 75 | assert_receive {^request_ref, {:status, 200}}, 500 76 | assert_receive {^request_ref, {:headers, headers}} when is_list(headers) 77 | for _ <- 1..5, do: assert_receive({^request_ref, {:data, _}}) 78 | assert_receive {^request_ref, :done} 79 | end 80 | 81 | test "sends errors to the caller", %{finch_name: finch_name, url: url} do 82 | request_ref = 83 | Finch.build(:get, url <> "/wait/100") 84 | |> Finch.async_request(finch_name, receive_timeout: 10) 85 | 86 | assert_receive {^request_ref, {:error, %{reason: :timeout}}}, 500 87 | end 88 | 89 | test "canceled with cancel_async_request/1", %{ 90 | finch_name: finch_name, 91 | url: url 92 | } do 93 | ref = 94 | Finch.build(:get, url <> "/stream/1/50") 95 | |> Finch.async_request(finch_name) 96 | 97 | assert_receive {^ref, {:status, 200}}, 500 98 | Finch.HTTP1.Pool.cancel_async_request(ref) 99 | refute_receive {^ref, {:data, _}} 100 | end 101 | 102 | test "canceled if calling process exits normally", %{finch_name: finch_name, url: url} do 103 | outer = self() 104 | 105 | spawn(fn -> 106 | ref = 107 | Finch.build(:get, url <> "/stream/5/500") 108 | |> Finch.async_request(finch_name) 109 | 110 | # allow process to exit normally after sending 111 | send(outer, ref) 112 | end) 113 | 114 | assert_receive {Finch.HTTP1.Pool, pid} when is_pid(pid) 115 | 116 | ref = Process.monitor(pid) 117 | assert_receive {:DOWN, ^ref, _, _, _}, 500 118 | end 119 | 120 | test "canceled if calling process exits abnormally", %{finch_name: finch_name, url: url} do 121 | outer = self() 122 | 123 | caller = 124 | spawn(fn -> 125 | ref = 126 | Finch.build(:get, url <> "/stream/5/500") 127 | |> Finch.async_request(finch_name) 128 | 129 | send(outer, ref) 130 | 131 | # ensure process stays alive until explicitly exited 132 | Process.sleep(:infinity) 133 | end) 134 | 135 | assert_receive {Finch.HTTP1.Pool, pid} when is_pid(pid) 136 | 137 | ref = Process.monitor(pid) 138 | Process.exit(caller, :shutdown) 139 | assert_receive {:DOWN, ^ref, _, _, _}, 500 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/finch/http1/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1.TelemetryTest do 2 | use FinchCase, async: false 3 | 4 | @moduletag :capture_log 5 | 6 | setup %{bypass: bypass, finch_name: finch_name} do 7 | Bypass.expect(bypass, "GET", "/", fn conn -> 8 | Plug.Conn.send_resp(conn, 200, "OK") 9 | end) 10 | 11 | start_supervised!( 12 | {Finch, name: finch_name, pools: %{default: [protocols: [:http1], conn_max_idle_time: 10]}} 13 | ) 14 | 15 | :ok 16 | end 17 | 18 | test "reports request and response headers", %{bypass: bypass, finch_name: finch_name} do 19 | self = self() 20 | 21 | :telemetry.attach_many( 22 | to_string(finch_name), 23 | [[:finch, :send, :start], [:finch, :recv, :stop]], 24 | fn name, _, metadata, _ -> send(self, {:telemetry_event, name, metadata}) end, 25 | nil 26 | ) 27 | 28 | Bypass.expect(bypass, "GET", "/", fn conn -> 29 | conn 30 | |> Plug.Conn.put_resp_header("x-foo-response", "bar-response") 31 | |> Plug.Conn.send_resp(200, "OK") 32 | end) 33 | 34 | request = Finch.build(:get, endpoint(bypass), [{"x-foo-request", "bar-request"}]) 35 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 36 | 37 | assert_receive {:telemetry_event, [:finch, :send, :start], 38 | %{request: %{headers: [{"x-foo-request", "bar-request"}]}}} 39 | 40 | assert_receive {:telemetry_event, [:finch, :recv, :stop], %{headers: headers, trailers: []}} 41 | assert {"x-foo-response", "bar-response"} in headers 42 | 43 | :telemetry.detach(to_string(finch_name)) 44 | end 45 | 46 | test "reports response status code", %{bypass: bypass, finch_name: finch_name} do 47 | self = self() 48 | 49 | :telemetry.attach( 50 | to_string(finch_name), 51 | [:finch, :recv, :stop], 52 | fn name, _, metadata, _ -> send(self, {:telemetry_event, name, metadata}) end, 53 | nil 54 | ) 55 | 56 | Bypass.expect(bypass, "GET", "/", fn conn -> Plug.Conn.send_resp(conn, 201, "OK") end) 57 | 58 | request = Finch.build(:get, endpoint(bypass)) 59 | assert {:ok, %{status: 201}} = Finch.request(request, finch_name) 60 | 61 | assert_receive {:telemetry_event, [:finch, :recv, :stop], %{status: 201}} 62 | 63 | :telemetry.detach(to_string(finch_name)) 64 | end 65 | 66 | test "reports reused connections", %{bypass: bypass, finch_name: finch_name} do 67 | parent = self() 68 | ref = make_ref() 69 | 70 | handler = fn event, _measurements, meta, _config -> 71 | case event do 72 | [:finch, :connect, :start] -> 73 | send(parent, {ref, :start}) 74 | 75 | [:finch, :connect, :stop] -> 76 | send(parent, {ref, :stop}) 77 | 78 | [:finch, :reused_connection] -> 79 | assert is_atom(meta.scheme) 80 | assert is_binary(meta.host) 81 | assert is_integer(meta.port) 82 | send(parent, {ref, :reused}) 83 | 84 | _ -> 85 | flunk("Unknown event") 86 | end 87 | end 88 | 89 | :telemetry.attach_many( 90 | to_string(finch_name), 91 | [ 92 | [:finch, :connect, :start], 93 | [:finch, :connect, :stop], 94 | [:finch, :reused_connection] 95 | ], 96 | handler, 97 | nil 98 | ) 99 | 100 | request = Finch.build(:get, endpoint(bypass)) 101 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 102 | assert_receive {^ref, :start} 103 | assert_receive {^ref, :stop} 104 | 105 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 106 | assert_receive {^ref, :reused} 107 | 108 | :telemetry.detach(to_string(finch_name)) 109 | end 110 | 111 | test "reports conn_max_idle_time_exceeded", %{bypass: bypass, finch_name: finch_name} do 112 | parent = self() 113 | ref = make_ref() 114 | 115 | handler = fn event, measurements, meta, _config -> 116 | case event do 117 | [:finch, :conn_max_idle_time_exceeded] -> 118 | assert is_integer(measurements.idle_time) 119 | assert is_atom(meta.scheme) 120 | assert is_binary(meta.host) 121 | assert is_integer(meta.port) 122 | send(parent, {ref, :conn_max_idle_time_exceeded}) 123 | 124 | _ -> 125 | flunk("Unknown event") 126 | end 127 | end 128 | 129 | :telemetry.attach_many( 130 | to_string(finch_name), 131 | [ 132 | [:finch, :conn_max_idle_time_exceeded] 133 | ], 134 | handler, 135 | nil 136 | ) 137 | 138 | request = Finch.build(:get, endpoint(bypass)) 139 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 140 | Process.sleep(15) 141 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 142 | assert_receive {^ref, :conn_max_idle_time_exceeded} 143 | 144 | :telemetry.detach(to_string(finch_name)) 145 | end 146 | 147 | test "reports max_idle_time_exceeded", %{bypass: bypass, finch_name: finch_name} do 148 | parent = self() 149 | ref = make_ref() 150 | 151 | handler = fn event, measurements, meta, _config -> 152 | case event do 153 | [:finch, :max_idle_time_exceeded] -> 154 | assert is_integer(measurements.idle_time) 155 | assert is_atom(meta.scheme) 156 | assert is_binary(meta.host) 157 | assert is_integer(meta.port) 158 | send(parent, {ref, :max_idle_time_exceeded}) 159 | 160 | _ -> 161 | flunk("Unknown event") 162 | end 163 | end 164 | 165 | :telemetry.attach_many( 166 | to_string(finch_name), 167 | [ 168 | [:finch, :max_idle_time_exceeded] 169 | ], 170 | handler, 171 | nil 172 | ) 173 | 174 | request = Finch.build(:get, endpoint(bypass)) 175 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 176 | Process.sleep(15) 177 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 178 | assert_receive {^ref, :max_idle_time_exceeded} 179 | 180 | :telemetry.detach(to_string(finch_name)) 181 | end 182 | 183 | test "reports request spans", %{bypass: bypass, finch_name: finch_name} do 184 | parent = self() 185 | ref = make_ref() 186 | 187 | handler = fn event, measurements, meta, _config -> 188 | case event do 189 | [:finch, :request, :start] -> 190 | assert is_integer(measurements.system_time) 191 | assert meta.name == finch_name 192 | assert %Finch.Request{} = meta.request 193 | 194 | send(parent, {ref, :start}) 195 | 196 | [:finch, :request, :stop] -> 197 | assert is_integer(measurements.duration) 198 | assert meta.name == finch_name 199 | assert %Finch.Request{} = meta.request 200 | 201 | assert {:ok, %Finch.Response{body: "OK", status: 200}} = meta.result 202 | 203 | send(parent, {ref, :stop}) 204 | 205 | [:finch, :request, :exception] -> 206 | assert is_integer(measurements.duration) 207 | assert meta.name == finch_name 208 | assert %Finch.Request{} = meta.request 209 | assert meta.kind == :exit 210 | assert {:timeout, _} = meta.reason 211 | assert meta.stacktrace != nil 212 | 213 | send(parent, {ref, :exception}) 214 | 215 | _ -> 216 | flunk("Unknown event") 217 | end 218 | end 219 | 220 | :telemetry.attach_many( 221 | to_string(finch_name), 222 | [ 223 | [:finch, :request, :start], 224 | [:finch, :request, :stop], 225 | [:finch, :request, :exception] 226 | ], 227 | handler, 228 | nil 229 | ) 230 | 231 | assert {:ok, %{status: 200}} = 232 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 233 | 234 | assert_receive {^ref, :start} 235 | assert_receive {^ref, :stop} 236 | 237 | Bypass.down(bypass) 238 | 239 | assert_raise RuntimeError, 240 | ~r/Finch was unable to provide a connection within the timeout/, 241 | fn -> 242 | Finch.build(:get, endpoint(bypass)) 243 | |> Finch.request(finch_name, pool_timeout: 0) 244 | end 245 | 246 | assert_receive {^ref, :start} 247 | 248 | :telemetry.detach(to_string(finch_name)) 249 | end 250 | 251 | test "reports queue spans", %{bypass: bypass, finch_name: finch_name} do 252 | parent = self() 253 | ref = make_ref() 254 | 255 | handler = fn event, measurements, meta, _config -> 256 | case event do 257 | [:finch, :queue, :start] -> 258 | assert is_integer(measurements.system_time) 259 | assert is_pid(meta.pool) 260 | assert %Finch.Request{} = meta.request 261 | send(parent, {ref, :start}) 262 | 263 | [:finch, :queue, :stop] -> 264 | assert is_integer(measurements.duration) 265 | assert is_integer(measurements.idle_time) 266 | assert is_pid(meta.pool) 267 | assert %Finch.Request{} = meta.request 268 | send(parent, {ref, :stop}) 269 | 270 | [:finch, :queue, :exception] -> 271 | assert is_integer(measurements.duration) 272 | assert is_pid(meta.pool) 273 | assert meta.kind == :exit 274 | assert {:timeout, _} = meta.reason 275 | assert meta.stacktrace != nil 276 | assert %Finch.Request{} = meta.request 277 | send(parent, {ref, :exception}) 278 | 279 | _ -> 280 | flunk("Unknown event") 281 | end 282 | end 283 | 284 | :telemetry.attach_many( 285 | to_string(finch_name), 286 | [ 287 | [:finch, :queue, :start], 288 | [:finch, :queue, :stop], 289 | [:finch, :queue, :exception] 290 | ], 291 | handler, 292 | nil 293 | ) 294 | 295 | assert {:ok, %{status: 200}} = 296 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 297 | 298 | assert_receive {^ref, :start} 299 | assert_receive {^ref, :stop} 300 | 301 | Bypass.down(bypass) 302 | 303 | assert_raise RuntimeError, 304 | ~r/Finch was unable to provide a connection within the timeout/, 305 | fn -> 306 | Finch.build(:get, endpoint(bypass)) 307 | |> Finch.request(finch_name, pool_timeout: 0) 308 | end 309 | 310 | assert_receive {^ref, :start} 311 | assert_receive {^ref, :exception} 312 | 313 | :telemetry.detach(to_string(finch_name)) 314 | end 315 | 316 | test "reports connection spans", %{bypass: bypass, finch_name: finch_name} do 317 | parent = self() 318 | ref = make_ref() 319 | 320 | handler = fn event, measurements, meta, _config -> 321 | case event do 322 | [:finch, :connect, :start] -> 323 | assert is_integer(measurements.system_time) 324 | assert is_atom(meta.scheme) 325 | assert is_integer(meta.port) 326 | assert is_binary(meta.host) 327 | send(parent, {ref, :start}) 328 | 329 | [:finch, :connect, :stop] -> 330 | assert is_integer(measurements.duration) 331 | assert is_atom(meta.scheme) 332 | assert is_integer(meta.port) 333 | assert is_binary(meta.host) 334 | send(parent, {ref, :stop}) 335 | 336 | _ -> 337 | flunk("Unknown event") 338 | end 339 | end 340 | 341 | :telemetry.attach_many( 342 | to_string(finch_name), 343 | [ 344 | [:finch, :connect, :start], 345 | [:finch, :connect, :stop] 346 | ], 347 | handler, 348 | nil 349 | ) 350 | 351 | assert {:ok, %{status: 200}} = 352 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 353 | 354 | assert_receive {^ref, :start} 355 | assert_receive {^ref, :stop} 356 | 357 | :telemetry.detach(to_string(finch_name)) 358 | end 359 | 360 | test "reports send spans", %{bypass: bypass, finch_name: finch_name} do 361 | parent = self() 362 | ref = make_ref() 363 | 364 | handler = fn event, measurements, meta, _config -> 365 | case event do 366 | [:finch, :send, :start] -> 367 | assert is_integer(measurements.system_time) 368 | assert is_integer(measurements.idle_time) 369 | assert %Finch.Request{} = meta.request 370 | send(parent, {ref, :start}) 371 | 372 | [:finch, :send, :stop] -> 373 | assert is_integer(measurements.duration) 374 | assert is_integer(measurements.idle_time) 375 | assert %Finch.Request{} = meta.request 376 | send(parent, {ref, :stop}) 377 | 378 | _ -> 379 | flunk("Unknown event") 380 | end 381 | end 382 | 383 | :telemetry.attach_many( 384 | to_string(finch_name), 385 | [ 386 | [:finch, :send, :start], 387 | [:finch, :send, :stop] 388 | ], 389 | handler, 390 | nil 391 | ) 392 | 393 | assert {:ok, %{status: 200}} = 394 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 395 | 396 | assert_receive {^ref, :start} 397 | assert_receive {^ref, :stop} 398 | 399 | :telemetry.detach(to_string(finch_name)) 400 | end 401 | 402 | test "reports recv spans", %{bypass: bypass, finch_name: finch_name} do 403 | parent = self() 404 | ref = make_ref() 405 | 406 | handler = fn event, measurements, meta, _config -> 407 | case event do 408 | [:finch, :recv, :start] -> 409 | assert is_integer(measurements.system_time) 410 | assert is_integer(measurements.idle_time) 411 | assert %Finch.Request{} = meta.request 412 | send(parent, {ref, :start}) 413 | 414 | [:finch, :recv, :stop] -> 415 | assert is_integer(measurements.duration) 416 | assert is_integer(measurements.idle_time) 417 | assert %Finch.Request{} = meta.request 418 | assert is_integer(meta.status) 419 | assert is_list(meta.headers) 420 | send(parent, {ref, :stop}) 421 | 422 | _ -> 423 | flunk("Unknown event") 424 | end 425 | end 426 | 427 | :telemetry.attach_many( 428 | to_string(finch_name), 429 | [ 430 | [:finch, :recv, :start], 431 | [:finch, :recv, :stop] 432 | ], 433 | handler, 434 | nil 435 | ) 436 | 437 | assert {:ok, %{status: 200}} = 438 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 439 | 440 | assert_receive {^ref, :start} 441 | assert_receive {^ref, :stop} 442 | 443 | :telemetry.detach(to_string(finch_name)) 444 | end 445 | end 446 | -------------------------------------------------------------------------------- /test/finch/http2/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.IntegrationTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias Finch.TestHelper 5 | 6 | @moduletag :capture_log 7 | 8 | setup_all do 9 | {:ok, url: Application.get_env(:finch, :test_https_h2_url)} 10 | end 11 | 12 | test "sends http2 requests", %{url: url} do 13 | start_supervised!( 14 | {Finch, 15 | name: TestFinch, 16 | pools: %{ 17 | default: [ 18 | protocols: [:http2], 19 | count: 5, 20 | conn_opts: [ 21 | transport_opts: [ 22 | verify: :verify_none 23 | ] 24 | ] 25 | ] 26 | }} 27 | ) 28 | 29 | assert {:ok, response} = Finch.build(:get, url) |> Finch.request(TestFinch) 30 | assert response.body == "Hello world!" 31 | end 32 | 33 | test "sends the query string", %{url: url} do 34 | start_supervised!( 35 | {Finch, 36 | name: TestFinch, 37 | pools: %{ 38 | default: [ 39 | protocols: [:http2], 40 | count: 5, 41 | conn_opts: [ 42 | transport_opts: [ 43 | verify: :verify_none 44 | ] 45 | ] 46 | ] 47 | }} 48 | ) 49 | 50 | query_string = URI.encode_query(test: true, these: "params") 51 | url = url <> "/query?" <> query_string 52 | 53 | assert {:ok, response} = Finch.build(:get, url) |> Finch.request(TestFinch) 54 | assert response.body == query_string 55 | end 56 | 57 | test "multiplexes requests over a single pool", %{url: url} do 58 | start_supervised!( 59 | {Finch, 60 | name: TestFinch, 61 | pools: %{ 62 | default: [ 63 | protocols: [:http2], 64 | count: 1, 65 | conn_opts: [ 66 | transport_opts: [ 67 | verify: :verify_none 68 | ] 69 | ] 70 | ] 71 | }} 72 | ) 73 | 74 | # We create multiple requests here using a single connection. There is a delay 75 | # in the response. But because we allow each request to run simultaneously 76 | # they shouldn't block each other which we check with a rough time estimates 77 | request = Finch.build(:get, url <> "/wait/1000") 78 | 79 | results = 80 | 1..50 81 | |> Enum.map(fn _ -> 82 | Task.async(fn -> 83 | start = System.monotonic_time() 84 | {:ok, _} = Finch.request(request, TestFinch) 85 | System.monotonic_time() - start 86 | end) 87 | end) 88 | |> Enum.map(&Task.await/1) 89 | 90 | for result <- results do 91 | time = System.convert_time_unit(result, :native, :millisecond) 92 | assert time <= 1200 93 | end 94 | end 95 | 96 | @tag skip: TestHelper.ssl_version() < [10, 2] 97 | test "writes TLS secrets to SSLKEYLOGFILE file", %{url: url} do 98 | tmp_dir = System.tmp_dir() 99 | log_file = Path.join(tmp_dir, "ssl-key-file.log") 100 | :ok = System.put_env("SSLKEYLOGFILE", log_file) 101 | 102 | start_supervised!( 103 | {Finch, 104 | name: TestFinch, 105 | pools: %{ 106 | default: [ 107 | protocols: [:http2], 108 | count: 5, 109 | conn_opts: [ 110 | transport_opts: [ 111 | verify: :verify_none, 112 | keep_secrets: true, 113 | versions: [:"tlsv1.2", :"tlsv1.3"] 114 | ] 115 | ] 116 | ] 117 | }} 118 | ) 119 | 120 | try do 121 | assert {:ok, response} = Finch.build(:get, url) |> Finch.request(TestFinch) 122 | assert response.body == "Hello world!" 123 | assert File.stat!(log_file).size > 0 124 | after 125 | File.rm!(log_file) 126 | System.delete_env("SSLKEYLOGFILE") 127 | end 128 | end 129 | 130 | test "cancel streaming response", %{url: url} do 131 | start_supervised!( 132 | {Finch, 133 | name: TestFinch, 134 | pools: %{ 135 | default: [ 136 | protocols: [:http2], 137 | conn_opts: [ 138 | transport_opts: [ 139 | verify: :verify_none 140 | ] 141 | ] 142 | ] 143 | }} 144 | ) 145 | 146 | assert catch_throw( 147 | Finch.stream( 148 | Finch.build(:get, url <> "/stream/1/500"), 149 | TestFinch, 150 | :ok, 151 | fn {:status, _}, :ok -> 152 | throw(:error) 153 | end 154 | ) 155 | ) == :error 156 | 157 | refute_receive _ 158 | end 159 | 160 | test "cancel completed streaming response", %{url: url} do 161 | start_supervised!( 162 | {Finch, 163 | name: TestFinch, 164 | pools: %{ 165 | default: [ 166 | protocols: [:http2], 167 | conn_opts: [ 168 | transport_opts: [ 169 | verify: :verify_none 170 | ] 171 | ] 172 | ] 173 | }} 174 | ) 175 | 176 | assert catch_throw( 177 | Finch.stream( 178 | Finch.build(:get, url), 179 | TestFinch, 180 | :ok, 181 | fn 182 | {:data, _}, :ok -> throw(:error) 183 | _, :ok -> :ok 184 | end 185 | ) 186 | ) == :error 187 | 188 | refute_receive _ 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /test/finch/http2/pool_metrics_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.PoolMetricsTest do 2 | use ExUnit.Case 3 | 4 | alias Finch.HTTP2.PoolMetrics 5 | 6 | setup_all do 7 | {:ok, url: Application.get_env(:finch, :test_https_h2_url)} 8 | end 9 | 10 | test "do not start metrics when opt is false", %{test: finch_name, url: url} do 11 | start_supervised!( 12 | {Finch, 13 | name: finch_name, 14 | pools: %{ 15 | default: [ 16 | protocols: [:http2], 17 | conn_opts: [ 18 | transport_opts: [ 19 | verify: :verify_none 20 | ] 21 | ], 22 | start_pool_metrics?: false 23 | ] 24 | }} 25 | ) 26 | 27 | {:ok, %{status: 200, body: "Hello world!"}} = 28 | Finch.build(:get, "#{url}/") 29 | |> Finch.request(finch_name) 30 | 31 | assert {:error, :not_found} = Finch.get_pool_status(finch_name, url) 32 | end 33 | 34 | test "get pool status async requests", %{test: finch_name, url: url} do 35 | parent = self() 36 | 37 | start_supervised!( 38 | {Finch, 39 | name: finch_name, 40 | pools: %{ 41 | default: [ 42 | protocols: [:http2], 43 | conn_opts: [ 44 | transport_opts: [ 45 | verify: :verify_none 46 | ] 47 | ], 48 | start_pool_metrics?: true 49 | ] 50 | }} 51 | ) 52 | 53 | refs = 54 | Enum.map(1..5, fn i -> 55 | ref = 56 | Finch.build(:get, "#{url}/wait/500") 57 | |> Finch.async_request(finch_name) 58 | 59 | send(parent, {:sent_req, i}) 60 | 61 | ref 62 | end) 63 | 64 | Process.sleep(50) 65 | 66 | assert {:ok, 67 | [ 68 | %PoolMetrics{ 69 | pool_index: 1, 70 | in_flight_requests: 5 71 | } 72 | ]} = Finch.get_pool_status(finch_name, url) 73 | 74 | Enum.each(refs, fn req_ref -> 75 | assert_receive {^req_ref, {:status, 200}}, 1000 76 | end) 77 | 78 | assert {:ok, 79 | [ 80 | %PoolMetrics{ 81 | pool_index: 1, 82 | in_flight_requests: 0 83 | } 84 | ]} = Finch.get_pool_status(finch_name, url) 85 | end 86 | 87 | test "get pool status sync requests", %{test: finch_name, url: url} do 88 | start_supervised!( 89 | {Finch, 90 | name: finch_name, 91 | pools: %{ 92 | default: [ 93 | protocols: [:http2], 94 | conn_opts: [ 95 | transport_opts: [ 96 | verify: :verify_none 97 | ] 98 | ], 99 | start_pool_metrics?: true 100 | ] 101 | }} 102 | ) 103 | 104 | refs = 105 | Enum.map(1..5, fn _ -> 106 | Task.async(fn -> 107 | Finch.build(:get, "#{url}/wait/500") 108 | |> Finch.request(finch_name) 109 | end) 110 | end) 111 | 112 | Process.sleep(50) 113 | 114 | assert {:ok, 115 | [ 116 | %PoolMetrics{ 117 | pool_index: 1, 118 | in_flight_requests: 5 119 | } 120 | ]} = Finch.get_pool_status(finch_name, url) 121 | 122 | result = Task.await_many(refs) 123 | 124 | assert length(result) == 5 125 | 126 | assert {:ok, 127 | [ 128 | %PoolMetrics{ 129 | pool_index: 1, 130 | in_flight_requests: 0 131 | } 132 | ]} = Finch.get_pool_status(finch_name, url) 133 | end 134 | 135 | test "multi pool", %{test: finch_name, url: url} do 136 | parent = self() 137 | 138 | start_supervised!( 139 | {Finch, 140 | name: finch_name, 141 | pools: %{ 142 | default: [ 143 | protocols: [:http2], 144 | conn_opts: [ 145 | transport_opts: [ 146 | verify: :verify_none 147 | ] 148 | ], 149 | count: 2, 150 | start_pool_metrics?: true 151 | ] 152 | }} 153 | ) 154 | 155 | refs = 156 | Enum.map(1..5, fn i -> 157 | ref = 158 | Finch.build(:get, "#{url}/wait/500") 159 | |> Finch.async_request(finch_name) 160 | 161 | send(parent, {:sent_req, i}) 162 | 163 | ref 164 | end) 165 | 166 | Process.sleep(50) 167 | 168 | assert {:ok, 169 | [ 170 | %PoolMetrics{ 171 | pool_index: 1, 172 | in_flight_requests: inflight_1 173 | }, 174 | %PoolMetrics{ 175 | pool_index: 2, 176 | in_flight_requests: inflight_2 177 | } 178 | ]} = Finch.get_pool_status(finch_name, url) 179 | 180 | assert inflight_1 + inflight_2 == 5 181 | 182 | Enum.each(refs, fn req_ref -> 183 | assert_receive {^req_ref, {:status, 200}}, 1000 184 | end) 185 | 186 | assert {:ok, 187 | [ 188 | %PoolMetrics{ 189 | pool_index: 1, 190 | in_flight_requests: 0 191 | }, 192 | %PoolMetrics{ 193 | pool_index: 2, 194 | in_flight_requests: 0 195 | } 196 | ]} = Finch.get_pool_status(finch_name, url) 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /test/finch/http2/pool_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.PoolTest do 2 | use ExUnit.Case 3 | 4 | import Mint.HTTP2.Frame 5 | 6 | alias Finch.HTTP2.Pool 7 | alias Finch.MockHTTP2Server 8 | 9 | defmacrop assert_recv_frames(frames) when is_list(frames) do 10 | quote do: unquote(frames) = recv_next_frames(unquote(length(frames))) 11 | end 12 | 13 | @moduletag :capture_log 14 | 15 | setup do 16 | start_supervised({Registry, keys: :unique, name: :test}) 17 | 18 | request = %{ 19 | scheme: :https, 20 | method: "GET", 21 | path: "/", 22 | query: nil, 23 | host: "localhost", 24 | port: nil, 25 | headers: [], 26 | body: nil 27 | } 28 | 29 | {:ok, request: request} 30 | end 31 | 32 | def start_pool(port) do 33 | Pool.start_link({ 34 | {:https, "localhost", port}, 35 | :test, 36 | [conn_opts: [transport_opts: [verify: :verify_none]]], 37 | false, 38 | 1 39 | }) 40 | end 41 | 42 | describe "requests" do 43 | test "request/response", %{request: req} do 44 | us = self() 45 | 46 | {:ok, pool} = 47 | start_server_and_connect_with(fn port -> 48 | start_pool(port) 49 | end) 50 | 51 | spawn(fn -> 52 | {:ok, resp} = request(pool, req, []) 53 | send(us, {:resp, {:ok, resp}}) 54 | end) 55 | 56 | assert_recv_frames([headers(stream_id: stream_id)]) 57 | 58 | hbf = server_encode_headers([{":status", "200"}]) 59 | 60 | server_send_frames([ 61 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])), 62 | data(stream_id: stream_id, data: "hello to you", flags: set_flags(:data, [:end_stream])) 63 | ]) 64 | 65 | assert_receive {:resp, {:ok, {200, [], "hello to you"}}} 66 | end 67 | 68 | test "errors such as :max_header_list_size_reached are returned to the caller", %{ 69 | request: req 70 | } do 71 | server_settings = [max_header_list_size: 5] 72 | 73 | {:ok, pool} = 74 | start_server_and_connect_with([server_settings: server_settings], fn port -> 75 | start_pool(port) 76 | end) 77 | 78 | assert {:error, error, _acc} = request(pool, %{req | headers: [{"foo", "bar"}]}, []) 79 | assert %{reason: {:max_header_list_size_exceeded, _, _}} = error 80 | end 81 | 82 | test "if server sends GOAWAY and then replies, we get the replies but are closed for writing", 83 | %{request: req} do 84 | us = self() 85 | 86 | {:ok, pool} = 87 | start_server_and_connect_with(fn port -> 88 | start_pool(port) 89 | end) 90 | 91 | spawn(fn -> 92 | result = request(pool, req, []) 93 | send(us, {:resp, result}) 94 | end) 95 | 96 | assert_recv_frames([headers(stream_id: stream_id)]) 97 | 98 | hbf = server_encode_headers([{":status", "200"}]) 99 | 100 | # Force the connection to enter read only mode 101 | server_send_frames([ 102 | goaway(last_stream_id: stream_id, error_code: :no_error, debug_data: "all good") 103 | ]) 104 | 105 | :timer.sleep(10) 106 | 107 | # We can't send any more requests since the connection is closed for writing. 108 | assert {:error, %Finch.Error{reason: :read_only}, _acc} = request(pool, req, []) 109 | 110 | server_send_frames([ 111 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])), 112 | data(stream_id: stream_id, data: "hello", flags: set_flags(:data, [:end_stream])) 113 | ]) 114 | 115 | assert_receive {:resp, {:ok, {200, [], "hello"}}} 116 | 117 | # If the server now closes the socket, we actually shut down. 118 | :ok = :ssl.close(server_socket()) 119 | 120 | Process.sleep(50) 121 | 122 | # If we try to make a request now that the server shut down, we get an error. 123 | assert {:error, %Finch.Error{reason: :disconnected}, _acc} = request(pool, req, []) 124 | end 125 | 126 | test "if server disconnects while there are waiting clients, we notify those clients", %{ 127 | request: req 128 | } do 129 | us = self() 130 | 131 | {:ok, pool} = 132 | start_server_and_connect_with(fn port -> 133 | start_pool(port) 134 | end) 135 | 136 | spawn(fn -> 137 | result = request(pool, req, []) 138 | send(us, {:resp, result}) 139 | end) 140 | 141 | assert_recv_frames([headers(stream_id: stream_id)]) 142 | 143 | hbf = server_encode_headers([{":status", "200"}]) 144 | 145 | server_send_frames([ 146 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])) 147 | ]) 148 | 149 | :ok = :ssl.close(server_socket()) 150 | 151 | assert_receive {:resp, {:error, %Finch.Error{reason: :connection_closed}, _acc}} 152 | end 153 | 154 | test "if connections reaches max concurrent streams, we return an error", %{request: req} do 155 | server_settings = [max_concurrent_streams: 1] 156 | 157 | {:ok, pool} = 158 | start_server_and_connect_with([server_settings: server_settings], fn port -> 159 | start_pool(port) 160 | end) 161 | 162 | spawn(fn -> 163 | request(pool, req, []) 164 | end) 165 | 166 | assert_recv_frames([headers(stream_id: _stream_id)]) 167 | 168 | assert {:error, %Mint.HTTPError{reason: :too_many_concurrent_requests}, _acc} = 169 | request(pool, req, []) 170 | end 171 | 172 | test "request timeout with timeout of 0", %{request: req} do 173 | us = self() 174 | 175 | {:ok, pool} = 176 | start_server_and_connect_with(fn port -> 177 | start_pool(port) 178 | end) 179 | 180 | spawn(fn -> 181 | resp = request(pool, req, receive_timeout: 0) 182 | send(us, {:resp, resp}) 183 | end) 184 | 185 | assert_recv_frames([headers(stream_id: stream_id), rst_stream(stream_id: stream_id)]) 186 | 187 | assert_receive {:resp, {:error, %Finch.Error{reason: :request_timeout}, _acc}} 188 | end 189 | 190 | test "request timeout with timeout > 0", %{request: req} do 191 | us = self() 192 | 193 | {:ok, pool} = 194 | start_server_and_connect_with(fn port -> 195 | start_pool(port) 196 | end) 197 | 198 | spawn(fn -> 199 | resp = request(pool, req, receive_timeout: 50) 200 | send(us, {:resp, resp}) 201 | end) 202 | 203 | assert_recv_frames([headers(stream_id: stream_id)]) 204 | 205 | hbf = server_encode_headers([{":status", "200"}]) 206 | 207 | server_send_frames([ 208 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])) 209 | ]) 210 | 211 | assert_receive {:resp, {:error, %Finch.Error{reason: :request_timeout}, _acc}} 212 | end 213 | 214 | test "request timeout with timeout > 0 that fires after request is done", %{request: req} do 215 | us = self() 216 | 217 | {:ok, pool} = 218 | start_server_and_connect_with(fn port -> 219 | start_pool(port) 220 | end) 221 | 222 | spawn(fn -> 223 | resp = request(pool, req, receive_timeout: 50) 224 | send(us, {:resp, resp}) 225 | end) 226 | 227 | assert_recv_frames([headers(stream_id: stream_id)]) 228 | 229 | server_send_frames([ 230 | headers( 231 | stream_id: stream_id, 232 | hbf: server_encode_headers([{":status", "200"}]), 233 | flags: set_flags(:headers, [:end_headers, :end_stream]) 234 | ) 235 | ]) 236 | 237 | assert_receive {:resp, {:ok, _}} 238 | refute_receive _any, 200 239 | end 240 | 241 | test "request timeout with timeout > 0 where :done arrives after timeout", %{request: req} do 242 | us = self() 243 | 244 | {:ok, pool} = 245 | start_server_and_connect_with(fn port -> 246 | start_pool(port) 247 | end) 248 | 249 | spawn(fn -> 250 | resp = request(pool, req, receive_timeout: 10) 251 | send(us, {:resp, resp}) 252 | end) 253 | 254 | assert_recv_frames([headers(stream_id: stream_id)]) 255 | 256 | # We sleep enough so that the timeout fires, then we send a response. 257 | Process.sleep(30) 258 | 259 | server_send_frames([ 260 | headers( 261 | stream_id: stream_id, 262 | hbf: server_encode_headers([{":status", "200"}]), 263 | flags: set_flags(:headers, [:end_headers, :end_stream]) 264 | ) 265 | ]) 266 | 267 | # When there's a timeout, we cancel the request. 268 | assert_recv_frames([rst_stream(stream_id: ^stream_id, error_code: :cancel)]) 269 | 270 | assert_receive {:resp, {:error, %Finch.Error{reason: :request_timeout}, _acc}} 271 | end 272 | end 273 | 274 | describe "async requests" do 275 | test "sends responses to the caller", %{test: finch_name} do 276 | start_finch!(finch_name) 277 | {:ok, url} = start_server!() 278 | 279 | request_ref = 280 | Finch.build(:get, url) 281 | |> Finch.async_request(finch_name) 282 | 283 | assert_receive {^request_ref, {:status, 200}}, 300 284 | assert_receive {^request_ref, {:headers, headers}} when is_list(headers) 285 | assert_receive {^request_ref, {:data, "Hello world!"}} 286 | assert_receive {^request_ref, :done} 287 | end 288 | 289 | test "sends errors to the caller", %{test: finch_name} do 290 | start_finch!(finch_name) 291 | {:ok, url} = start_server!() 292 | 293 | request_ref = 294 | Finch.build(:get, url <> "/wait/100") 295 | |> Finch.async_request(finch_name, receive_timeout: 10) 296 | 297 | assert_receive {^request_ref, {:error, %{reason: :request_timeout}}}, 300 298 | end 299 | 300 | test "canceled with cancel_async_request/1", %{test: finch_name} do 301 | start_finch!(finch_name) 302 | {:ok, url} = start_server!() 303 | 304 | ref = 305 | Finch.build(:get, url <> "/stream/1/50") 306 | |> Finch.async_request(finch_name) 307 | 308 | assert_receive {^ref, {:status, 200}} 309 | Finch.HTTP2.Pool.cancel_async_request(ref) 310 | refute_receive _ 311 | end 312 | 313 | test "canceled if calling process exits normally", %{test: finch_name} do 314 | start_finch!(finch_name) 315 | {:ok, url} = start_server!() 316 | 317 | outer = self() 318 | 319 | caller = 320 | spawn(fn -> 321 | ref = 322 | Finch.build(:get, url <> "/stream/5/500") 323 | |> Finch.async_request(finch_name) 324 | 325 | # allow process to exit normally after sending 326 | send(outer, ref) 327 | end) 328 | 329 | assert_receive {Finch.HTTP2.Pool, {pool, _}} = ref 330 | 331 | assert {_, %{refs: %{^ref => _}}} = :sys.get_state(pool) 332 | 333 | Process.sleep(100) 334 | refute Process.alive?(caller) 335 | 336 | assert {_, %{refs: refs}} = :sys.get_state(pool) 337 | assert refs == %{} 338 | end 339 | 340 | test "canceled if calling process exits abnormally", %{test: finch_name} do 341 | start_finch!(finch_name) 342 | {:ok, url} = start_server!() 343 | 344 | outer = self() 345 | 346 | caller = 347 | spawn(fn -> 348 | ref = 349 | Finch.build(:get, url <> "/stream/5/500") 350 | |> Finch.async_request(finch_name) 351 | 352 | send(outer, ref) 353 | 354 | # ensure process stays alive until explicitly exited 355 | Process.sleep(:infinity) 356 | end) 357 | 358 | assert_receive {Finch.HTTP2.Pool, {pool, _}} = ref 359 | 360 | assert {_, %{refs: %{^ref => _}}} = :sys.get_state(pool) 361 | 362 | Process.exit(caller, :shutdown) 363 | Process.sleep(100) 364 | refute Process.alive?(caller) 365 | 366 | assert {_, %{refs: refs}} = :sys.get_state(pool) 367 | assert refs == %{} 368 | end 369 | 370 | test "if server sends GOAWAY and then replies, we get the replies but are closed for writing", 371 | %{request: req} do 372 | {:ok, pool} = 373 | start_server_and_connect_with(fn port -> 374 | start_pool(port) 375 | end) 376 | 377 | ref = Pool.async_request(pool, req, nil, []) 378 | 379 | assert_recv_frames([headers(stream_id: stream_id)]) 380 | 381 | hbf = server_encode_headers([{":status", "200"}]) 382 | 383 | # Force the connection to enter read only mode 384 | server_send_frames([ 385 | goaway(last_stream_id: stream_id, error_code: :no_error, debug_data: "all good") 386 | ]) 387 | 388 | :timer.sleep(10) 389 | 390 | # We can't send any more requests since the connection is closed for writing. 391 | ref2 = Pool.async_request(pool, req, nil, []) 392 | assert_receive {^ref2, {:error, %Finch.Error{reason: :read_only}}} 393 | 394 | server_send_frames([ 395 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])), 396 | data(stream_id: stream_id, data: "hello", flags: set_flags(:data, [:end_stream])) 397 | ]) 398 | 399 | assert_receive {^ref, {:status, 200}} 400 | assert_receive {^ref, {:headers, []}} 401 | assert_receive {^ref, {:data, "hello"}} 402 | 403 | # If the server now closes the socket, we actually shut down. 404 | :ok = :ssl.close(server_socket()) 405 | 406 | Process.sleep(50) 407 | 408 | # If we try to make a request now that the server shut down, we get an error. 409 | ref3 = Pool.async_request(pool, req, nil, []) 410 | assert_receive {^ref3, {:error, %Finch.Error{reason: :disconnected}}} 411 | end 412 | 413 | test "if server disconnects while there are waiting clients, we notify those clients", %{ 414 | request: req 415 | } do 416 | {:ok, pool} = 417 | start_server_and_connect_with(fn port -> 418 | start_pool(port) 419 | end) 420 | 421 | ref = Pool.async_request(pool, req, nil, []) 422 | 423 | assert_recv_frames([headers(stream_id: stream_id)]) 424 | 425 | hbf = server_encode_headers([{":status", "200"}]) 426 | 427 | server_send_frames([ 428 | headers(stream_id: stream_id, hbf: hbf, flags: set_flags(:headers, [:end_headers])) 429 | ]) 430 | 431 | assert_receive {^ref, {:status, 200}} 432 | assert_receive {^ref, {:headers, []}} 433 | 434 | :ok = :ssl.close(server_socket()) 435 | 436 | assert_receive {^ref, {:error, %Finch.Error{reason: :connection_closed}}} 437 | end 438 | 439 | test "errors such as :max_header_list_size_reached are returned to the caller", %{ 440 | request: req 441 | } do 442 | server_settings = [max_header_list_size: 5] 443 | 444 | {:ok, pool} = 445 | start_server_and_connect_with([server_settings: server_settings], fn port -> 446 | start_pool(port) 447 | end) 448 | 449 | ref = Pool.async_request(pool, %{req | headers: [{"foo", "bar"}]}, nil, []) 450 | 451 | assert_receive {^ref, {:error, %{reason: {:max_header_list_size_exceeded, _, _}}}} 452 | end 453 | 454 | defp start_finch!(finch_name) do 455 | start_supervised!( 456 | {Finch, 457 | name: finch_name, 458 | pools: %{ 459 | default: [ 460 | protocols: [:http2], 461 | count: 5, 462 | conn_opts: [ 463 | transport_opts: [ 464 | verify: :verify_none 465 | ] 466 | ] 467 | ] 468 | }} 469 | ) 470 | end 471 | 472 | defp start_server! do 473 | {:ok, Application.get_env(:finch, :test_https_h2_url)} 474 | end 475 | end 476 | 477 | @pdict_key {__MODULE__, :http2_test_server} 478 | 479 | defp request(pool, req, opts) do 480 | acc = {nil, [], ""} 481 | 482 | fun = fn 483 | {:status, value}, {_, headers, body} -> {:cont, {value, headers, body}} 484 | {:headers, value}, {status, headers, body} -> {:cont, {status, headers ++ value, body}} 485 | {:data, value}, {status, headers, body} -> {:cont, {status, headers, body <> value}} 486 | end 487 | 488 | Pool.request(pool, req, acc, fun, nil, opts) 489 | end 490 | 491 | defp start_server_and_connect_with(opts \\ [], fun) do 492 | {result, server} = MockHTTP2Server.start_and_connect_with(opts, fun) 493 | 494 | Process.put(@pdict_key, server) 495 | 496 | result 497 | end 498 | 499 | defp recv_next_frames(n) do 500 | server = Process.get(@pdict_key) 501 | MockHTTP2Server.recv_next_frames(server, n) 502 | end 503 | 504 | defp server_encode_headers(headers) do 505 | server = Process.get(@pdict_key) 506 | {server, hbf} = MockHTTP2Server.encode_headers(server, headers) 507 | Process.put(@pdict_key, server) 508 | hbf 509 | end 510 | 511 | defp server_send_frames(frames) do 512 | server = Process.get(@pdict_key) 513 | :ok = MockHTTP2Server.send_frames(server, frames) 514 | end 515 | 516 | defp server_socket() do 517 | server = Process.get(@pdict_key) 518 | MockHTTP2Server.get_socket(server) 519 | end 520 | end 521 | -------------------------------------------------------------------------------- /test/finch/http2/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2.TelemetryTest do 2 | use FinchCase, async: false 3 | 4 | @moduletag :capture_log 5 | 6 | setup %{bypass: bypass, finch_name: finch_name} do 7 | Bypass.expect(bypass, "GET", "/", fn conn -> 8 | Plug.Conn.send_resp(conn, 200, "OK") 9 | end) 10 | 11 | start_supervised!( 12 | {Finch, name: finch_name, pools: %{default: [protocols: [:http2], conn_max_idle_time: 10]}} 13 | ) 14 | 15 | :ok 16 | end 17 | 18 | test "reports request and response headers", %{bypass: bypass, finch_name: finch_name} do 19 | self = self() 20 | 21 | :telemetry.attach_many( 22 | to_string(finch_name), 23 | [[:finch, :send, :start], [:finch, :recv, :stop]], 24 | fn name, _, metadata, _ -> send(self, {:telemetry_event, name, metadata}) end, 25 | nil 26 | ) 27 | 28 | Bypass.expect(bypass, "GET", "/", fn conn -> 29 | conn 30 | |> Plug.Conn.put_resp_header("x-foo-response", "bar-response") 31 | |> Plug.Conn.send_resp(200, "OK") 32 | end) 33 | 34 | request = Finch.build(:get, endpoint(bypass), [{"x-foo-request", "bar-request"}]) 35 | assert {:ok, %{status: 200}} = Finch.request(request, finch_name) 36 | 37 | assert_receive {:telemetry_event, [:finch, :send, :start], 38 | %{request: %{headers: [{"x-foo-request", "bar-request"}]}, name: ^finch_name}} 39 | 40 | assert_receive {:telemetry_event, [:finch, :recv, :stop], 41 | %{headers: headers, name: ^finch_name}} 42 | 43 | assert {"x-foo-response", "bar-response"} in headers 44 | 45 | :telemetry.detach(to_string(finch_name)) 46 | end 47 | 48 | test "reports response status code", %{bypass: bypass, finch_name: finch_name} do 49 | self = self() 50 | 51 | :telemetry.attach( 52 | to_string(finch_name), 53 | [:finch, :recv, :stop], 54 | fn name, _, metadata, _ -> send(self, {:telemetry_event, name, metadata}) end, 55 | nil 56 | ) 57 | 58 | Bypass.expect(bypass, "GET", "/", fn conn -> Plug.Conn.send_resp(conn, 201, "OK") end) 59 | 60 | request = Finch.build(:get, endpoint(bypass)) 61 | assert {:ok, %{status: 201}} = Finch.request(request, finch_name) 62 | 63 | assert_receive {:telemetry_event, [:finch, :recv, :stop], %{status: 201, name: ^finch_name}} 64 | 65 | :telemetry.detach(to_string(finch_name)) 66 | end 67 | 68 | test "reports request spans", %{bypass: bypass, finch_name: finch_name} do 69 | parent = self() 70 | ref = make_ref() 71 | 72 | handler = fn event, measurements, meta, _config -> 73 | case event do 74 | [:finch, :request, :start] -> 75 | assert is_integer(measurements.system_time) 76 | assert meta.name == finch_name 77 | assert %Finch.Request{} = meta.request 78 | 79 | send(parent, {ref, :start}) 80 | 81 | [:finch, :request, :stop] -> 82 | assert is_integer(measurements.duration) 83 | assert meta.name == finch_name 84 | assert %Finch.Request{} = meta.request 85 | 86 | assert {:ok, %Finch.Response{body: "OK", status: 200}} = meta.result 87 | 88 | send(parent, {ref, :stop}) 89 | 90 | [:finch, :request, :exception] -> 91 | assert is_integer(measurements.duration) 92 | assert meta.name == finch_name 93 | assert %Finch.Request{} = meta.request 94 | assert meta.kind == :exit 95 | assert {:timeout, _} = meta.reason 96 | assert meta.stacktrace != nil 97 | 98 | send(parent, {ref, :exception}) 99 | 100 | _ -> 101 | flunk("Unknown event") 102 | end 103 | end 104 | 105 | :telemetry.attach_many( 106 | to_string(finch_name), 107 | [ 108 | [:finch, :request, :start], 109 | [:finch, :request, :stop], 110 | [:finch, :request, :exception] 111 | ], 112 | handler, 113 | nil 114 | ) 115 | 116 | assert {:ok, %{status: 200}} = 117 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 118 | 119 | assert_receive {^ref, :start} 120 | assert_receive {^ref, :stop} 121 | 122 | Bypass.down(bypass) 123 | 124 | :telemetry.detach(to_string(finch_name)) 125 | end 126 | 127 | test "reports connection spans", %{bypass: bypass, finch_name: finch_name} do 128 | parent = self() 129 | ref = make_ref() 130 | 131 | handler = fn event, measurements, meta, _config -> 132 | case event do 133 | [:finch, :connect, :start] -> 134 | assert is_integer(measurements.system_time) 135 | assert is_atom(meta.scheme) 136 | assert is_integer(meta.port) 137 | assert is_binary(meta.host) 138 | assert meta.name == finch_name 139 | send(parent, {ref, :start}) 140 | 141 | [:finch, :connect, :stop] -> 142 | assert is_integer(measurements.duration) 143 | assert is_atom(meta.scheme) 144 | assert is_integer(meta.port) 145 | assert is_binary(meta.host) 146 | assert meta.name == finch_name 147 | send(parent, {ref, :stop}) 148 | 149 | _ -> 150 | flunk("Unknown event") 151 | end 152 | end 153 | 154 | :telemetry.attach_many( 155 | to_string(finch_name), 156 | [ 157 | [:finch, :connect, :start], 158 | [:finch, :connect, :stop] 159 | ], 160 | handler, 161 | nil 162 | ) 163 | 164 | assert {:ok, %{status: 200}} = 165 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 166 | 167 | assert_receive {^ref, :start} 168 | assert_receive {^ref, :stop} 169 | 170 | :telemetry.detach(to_string(finch_name)) 171 | end 172 | 173 | test "reports send spans", %{bypass: bypass, finch_name: finch_name} do 174 | parent = self() 175 | ref = make_ref() 176 | 177 | handler = fn event, measurements, meta, _config -> 178 | case event do 179 | [:finch, :send, :start] -> 180 | assert is_integer(measurements.system_time) 181 | assert %Finch.Request{} = meta.request 182 | assert meta.name == finch_name 183 | send(parent, {ref, :start}) 184 | 185 | [:finch, :send, :stop] -> 186 | assert is_integer(measurements.duration) 187 | assert %Finch.Request{} = meta.request 188 | assert meta.name == finch_name 189 | send(parent, {ref, :stop}) 190 | 191 | _ -> 192 | flunk("Unknown event") 193 | end 194 | end 195 | 196 | :telemetry.attach_many( 197 | to_string(finch_name), 198 | [ 199 | [:finch, :send, :start], 200 | [:finch, :send, :stop] 201 | ], 202 | handler, 203 | nil 204 | ) 205 | 206 | assert {:ok, %{status: 200}} = 207 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 208 | 209 | assert_receive {^ref, :start} 210 | assert_receive {^ref, :stop} 211 | 212 | request_ref = Finch.build(:get, endpoint(bypass)) |> Finch.async_request(finch_name) 213 | 214 | assert_receive {^ref, :start} 215 | assert_receive {^ref, :stop} 216 | assert_receive {^request_ref, {:status, 200}} 217 | 218 | :telemetry.detach(to_string(finch_name)) 219 | end 220 | 221 | test "reports recv spans", %{bypass: bypass, finch_name: finch_name} do 222 | parent = self() 223 | ref = make_ref() 224 | 225 | handler = fn event, measurements, meta, _config -> 226 | case event do 227 | [:finch, :recv, :start] -> 228 | assert is_integer(measurements.system_time) 229 | assert %Finch.Request{} = meta.request 230 | send(parent, {ref, :start}) 231 | 232 | [:finch, :recv, :stop] -> 233 | assert is_integer(measurements.duration) 234 | assert %Finch.Request{} = meta.request 235 | assert is_integer(meta.status) 236 | assert is_list(meta.headers) 237 | assert meta.name == finch_name 238 | send(parent, {ref, :stop}) 239 | 240 | _ -> 241 | flunk("Unknown event") 242 | end 243 | end 244 | 245 | :telemetry.attach_many( 246 | to_string(finch_name), 247 | [ 248 | [:finch, :recv, :start], 249 | [:finch, :recv, :stop] 250 | ], 251 | handler, 252 | nil 253 | ) 254 | 255 | assert {:ok, %{status: 200}} = 256 | Finch.build(:get, endpoint(bypass)) |> Finch.request(finch_name) 257 | 258 | assert_receive {^ref, :start} 259 | assert_receive {^ref, :stop} 260 | 261 | request_ref = Finch.build(:get, endpoint(bypass)) |> Finch.async_request(finch_name) 262 | 263 | assert_receive {^ref, :start} 264 | assert_receive {^ref, :stop} 265 | assert_receive {^request_ref, {:status, 200}} 266 | 267 | :telemetry.detach(to_string(finch_name)) 268 | end 269 | 270 | test "reports recv exceptions", %{bypass: bypass, finch_name: finch_name} do 271 | parent = self() 272 | ref = make_ref() 273 | 274 | handler = fn event, measurements, meta, _config -> 275 | case event do 276 | [:finch, :recv, :start] -> 277 | send(parent, {ref, :start}) 278 | 279 | [:finch, :recv, :exception] -> 280 | assert is_integer(measurements.duration) 281 | assert %Finch.Request{} = meta.request 282 | assert meta.kind == :exit 283 | assert meta.reason == :cancel 284 | assert meta.stacktrace != nil 285 | assert meta.name == finch_name 286 | send(parent, {ref, :exception}) 287 | 288 | _ -> 289 | flunk("Unknown event") 290 | end 291 | end 292 | 293 | :telemetry.attach_many( 294 | to_string(finch_name), 295 | [ 296 | [:finch, :recv, :start], 297 | [:finch, :recv, :exception] 298 | ], 299 | handler, 300 | nil 301 | ) 302 | 303 | spawn(fn -> 304 | Finch.build(:get, endpoint(bypass)) 305 | |> Finch.stream(finch_name, :ok, fn _, _ -> 306 | exit(:cancel) 307 | end) 308 | end) 309 | 310 | assert_receive {^ref, :start} 311 | assert_receive {^ref, :exception} 312 | 313 | :telemetry.detach(to_string(finch_name)) 314 | end 315 | end 316 | -------------------------------------------------------------------------------- /test/finch_request_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Finch.RequestTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "put_private/3" do 5 | test "accepts atoms as keys" do 6 | request = 7 | Finch.build(:get, "http://example.com") 8 | |> Finch.Request.put_private(:my_lib_key, :foo) 9 | 10 | assert request.private == %{my_lib_key: :foo} 11 | end 12 | 13 | test "appends to the map when called multiple times" do 14 | request = 15 | Finch.build(:get, "http://example.com") 16 | |> Finch.Request.put_private(:my_lib_key, :foo) 17 | |> Finch.Request.put_private(:my_lib_key2, :bar) 18 | 19 | assert request.private == %{my_lib_key: :foo, my_lib_key2: :bar} 20 | end 21 | 22 | test "raises when invalid key is used" do 23 | assert_raise ArgumentError, 24 | """ 25 | got unsupported private metadata key "my_key" 26 | only atoms are allowed as keys of the `:private` field. 27 | """, 28 | fn -> 29 | Finch.build(:get, "http://example.com") 30 | |> Finch.Request.put_private("my_key", :foo) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/fixtures/selfsigned.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICzjCCAbYCCQDM0i9xf9D8qTANBgkqhkiG9w0BAQsFADApMQswCQYDVQQGEwJJ 3 | VDELMAkGA1UECAwCUk0xDTALBgNVBAcMBFJvbWUwHhcNMTcxMjI4MTAzMTE1WhcN 4 | MTgxMjI4MTAzMTE1WjApMQswCQYDVQQGEwJJVDELMAkGA1UECAwCUk0xDTALBgNV 5 | BAcMBFJvbWUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6lBy3mpfB 6 | UrtclV/PFOM8OGiBNYVZmNJmZqCZCl2LQE5ekPrJ2Fh+zkcJcO19OxmseN+2F7VT 7 | zCYarty+h5ZXzNUmUcI2Ld60mfYwEfMjQRa4Tmp0K5PkphJ2gG9n9QOhFxky7KWz 8 | C84oe7Zm8iGni6wAQEEBOdo/qTCfGbPHzd39WUV+9Aft8HeDUcnpMhO6vXWDT3Yh 9 | 658p04rXLzj8auyAZpfSq61x9ZS4WQYWB5vRLsJ4/V51RVGfA5nJYFKJ+cwZR4Hz 10 | bTRVc34rXaZS9ggIp8ktqL14NO6jfo9/dRng4RcTmkKMxU+0pWdTNZ7iPJX46/xM 11 | 0XGxEd+7X4uHAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABygqnZTQ4dsd5EFQKF7 12 | UT7ZfKi9a65e+iDGHikhUjHc+hSUMXFyP5RjpN6Z+4igi9LuWhJFZ0dqSZxDwCYG 13 | RdrMZSM/2yTBLKVdgcKXPuiV5eFPXHmYm39ru/WpNEqR/P28Q50xz/HJRoFhg3Qe 14 | AIlncG+v6AaUAKD8Qj6IZOLIVJuMaT8ONsDaa2LJiAz5uzKwgijEWiw7m83dvqGi 15 | FHkrj9/l5SQQVLGej/74Av+OFmMRI6nPc5lIu39atMRxsiPubrcQOVZmXZxRSEg8 16 | P7k3nBjtxCUhAnokjRqv/4rYfm8hvbqiRnL3rmtLlM1IF2L37nOqnfGo2NikjV+G 17 | 1hU= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /test/fixtures/selfsigned_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6lBy3mpfBUrtc 3 | lV/PFOM8OGiBNYVZmNJmZqCZCl2LQE5ekPrJ2Fh+zkcJcO19OxmseN+2F7VTzCYa 4 | rty+h5ZXzNUmUcI2Ld60mfYwEfMjQRa4Tmp0K5PkphJ2gG9n9QOhFxky7KWzC84o 5 | e7Zm8iGni6wAQEEBOdo/qTCfGbPHzd39WUV+9Aft8HeDUcnpMhO6vXWDT3Yh658p 6 | 04rXLzj8auyAZpfSq61x9ZS4WQYWB5vRLsJ4/V51RVGfA5nJYFKJ+cwZR4HzbTRV 7 | c34rXaZS9ggIp8ktqL14NO6jfo9/dRng4RcTmkKMxU+0pWdTNZ7iPJX46/xM0XGx 8 | Ed+7X4uHAgMBAAECggEAW+VNi6UJ778m5z/vU5iPH38NAe7xgiLCJouPuDEhx89h 9 | ijRQQZBcbgB9fonvfwnX6FoUnaRpvB9F+Uh9Ex7HDvGlXl1Qkczf7wYR+rUskwWh 10 | AiAlUJiSHEErwNAbjxFfuz0cPTfPmTNMVCYyvduudc5WZj0/hzIOa+KSPxqysMq+ 11 | kVEuoKm4yzVpbsvfVjGFhYyetFfqbURZ1d0EONGNiYCtwVPTP+gvcjeMTdgGMno4 12 | cpPHFF5RYsfnG/koaARHbOBtFrnkERpmABfsSL6DzwWtQys/ghxwaL2ZhjhdgbXO 13 | 9vjstnXso9mjCAxVZaATyCs6QKXCn//T9BLRkutz+QKBgQDhlwuz2Df7TZwsCOqM 14 | PoYn0GhsY/hHupifEiWvDDtkGCAPuiw48HtCe1aCXFCtbSN/6E3Wdm7ZnG2q7RMY 15 | R5jxEEhnrBGEN7JQhAZPdktndVbupOXMgnGdiz/7nwHPCCghr3X/mdPWY0isMqx/ 16 | T7Bl1bv2C5ipOAqHUER7AKd6awKBgQDTus7n3mwYQusGBqhygmpqOr/5JmTjOcFb 17 | fGbyuOE9JntLSGR6mJhlfOYQiESvFhBZEOtw4eDh/n7r8LnV48x6D009466hBY3+ 18 | Fs5jTq+Ah2a9gxCRhSfUdEXhrJct+YHEjI+BNvXlUs/2D/y3rvc/2f+qa6nzegTI 19 | 1NcmtlEyVQKBgE2dPjV+KqSXqyerWacuy9Fe7s58BqwHEwOHptd3CegCNOW0VAqz 20 | EnVpIfZv9IH2jsQvFLi4vqK4IzMvpeYwm/o0c/TXSp+G2h7BjbpBJOhPgr1Qlo+q 21 | QZTGmBjmOCUW1VfhmmN6dVvJhPNZ6+dRb4tZ4fVhQADYeybbAvSe4QBJAoGAbn1V 22 | 6/pOPnrtWr+ut9MG5VizRbmbfFhvZuaMcq24HMkwHiExDikDnjKHfKkf7p58+X2y 23 | 372ANW8xnL6Ku+uckTXbASkHwE+9wZL1MS2muFPwcYUr6ESsfFoQ/aurWPqTlZYk 24 | bTHZMEr+61F8d/5+WHvSx4RXtA9A3+zyOel6heECgYEAxqgiod1asd9g6Ao0vQ5Q 25 | YIQihF9Qq15stxj5H0qnBskvDKwS+He8GKjMUJjJss0gD43KCYhRvE8bmMD+rgVJ 26 | oy8Hp5oGeMVUHMG7ly8vdAj01RWxU2oIL5wlk3hX+4Pb6XyAN+NuCTMCQ8XukfTf 27 | AyfpMLycjN7uWN86CdTv/YY= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /test/support/finch_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FinchCase do 2 | @moduledoc false 3 | use ExUnit.CaseTemplate 4 | 5 | using do 6 | quote do 7 | import FinchCase 8 | end 9 | end 10 | 11 | setup context do 12 | bypass = 13 | case context do 14 | %{bypass: false} -> [] 15 | %{} -> [bypass: Bypass.open()] 16 | end 17 | 18 | {:ok, bypass ++ [finch_name: context.test]} 19 | end 20 | 21 | @doc "Returns the URL for a Bypass instance" 22 | def endpoint(%{port: port}, path \\ "/"), do: "http://localhost:#{port}#{path}" 23 | end 24 | -------------------------------------------------------------------------------- /test/support/http1_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP1Server do 2 | @moduledoc false 3 | 4 | def child_spec(opts) do 5 | Plug.Cowboy.child_spec( 6 | scheme: :http, 7 | plug: Finch.HTTP1Server.PlugRouter, 8 | options: [ 9 | port: Keyword.fetch!(opts, :port), 10 | otp_app: :finch, 11 | protocol_options: [ 12 | idle_timeout: 3_000, 13 | request_timeout: 10_000 14 | ] 15 | ] 16 | ) 17 | end 18 | 19 | def start(port) do 20 | Supervisor.start_link([child_spec(port: port)], strategy: :one_for_one) 21 | end 22 | end 23 | 24 | defmodule Finch.HTTP1Server.PlugRouter do 25 | @moduledoc false 26 | 27 | use Plug.Router 28 | 29 | plug(:match) 30 | 31 | plug(:dispatch) 32 | 33 | get "/" do 34 | name = conn.params["name"] || "world" 35 | 36 | conn 37 | |> send_resp(200, "Hello #{name}!") 38 | |> halt() 39 | end 40 | 41 | get "/wait/:delay" do 42 | delay = conn.params["delay"] |> String.to_integer() 43 | Process.sleep(delay) 44 | send_resp(conn, 200, "ok") 45 | end 46 | 47 | get "/stream/:num/:delay" do 48 | num = conn.params["num"] |> String.to_integer() 49 | delay = conn.params["delay"] |> String.to_integer() 50 | conn = send_chunked(conn, 200) 51 | 52 | Enum.reduce(1..num, conn, fn i, conn -> 53 | Process.sleep(delay) 54 | {:ok, conn} = chunk(conn, "chunk-#{i}\n") 55 | conn 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/http2_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTP2Server do 2 | @moduledoc false 3 | 4 | @fixtures_dir Path.expand("../fixtures", __DIR__) 5 | 6 | def child_spec(opts) do 7 | Plug.Cowboy.child_spec( 8 | scheme: :https, 9 | plug: Finch.HTTP2Server.PlugRouter, 10 | options: [ 11 | port: Keyword.fetch!(opts, :port), 12 | cipher_suite: :strong, 13 | certfile: Path.join([@fixtures_dir, "selfsigned.pem"]), 14 | keyfile: Path.join([@fixtures_dir, "selfsigned_key.pem"]), 15 | otp_app: :finch, 16 | protocol_options: [ 17 | idle_timeout: 3_000, 18 | inactivity_timeout: 5_000, 19 | max_keepalive: 1_000, 20 | request_timeout: 10_000, 21 | shutdown_timeout: 10_000 22 | ] 23 | ] 24 | ) 25 | end 26 | 27 | def start(port) do 28 | Supervisor.start_link([child_spec(port: port)], strategy: :one_for_one) 29 | end 30 | end 31 | 32 | defmodule Finch.HTTP2Server.PlugRouter do 33 | @moduledoc false 34 | 35 | use Plug.Router 36 | 37 | plug(:match) 38 | 39 | plug(:dispatch) 40 | 41 | get "/" do 42 | name = conn.params["name"] || "world" 43 | 44 | conn 45 | |> send_resp(200, "Hello #{name}!") 46 | |> halt() 47 | end 48 | 49 | get "/query" do 50 | conn = fetch_query_params(conn) 51 | response = URI.encode_query(conn.query_params) 52 | 53 | conn 54 | |> send_resp(200, response) 55 | |> halt() 56 | end 57 | 58 | get "/wait/:delay" do 59 | delay = conn.params["delay"] |> String.to_integer() 60 | Process.sleep(delay) 61 | send_resp(conn, 200, "ok") 62 | end 63 | 64 | get "/stream/:num/:delay" do 65 | num = conn.params["num"] |> String.to_integer() 66 | delay = conn.params["delay"] |> String.to_integer() 67 | conn = send_chunked(conn, 200) 68 | 69 | Enum.reduce(1..num, conn, fn i, conn -> 70 | Process.sleep(delay) 71 | {:ok, conn} = chunk(conn, "chunk-#{i}\n") 72 | conn 73 | end) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/support/https1_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.HTTPS1Server do 2 | @moduledoc false 3 | 4 | @fixtures_dir Path.expand("../fixtures", __DIR__) 5 | 6 | def start(port) do 7 | children = [ 8 | Plug.Cowboy.child_spec( 9 | scheme: :https, 10 | plug: Finch.HTTP1Server.PlugRouter, 11 | options: [ 12 | port: port, 13 | otp_app: :finch, 14 | cipher_suite: :strong, 15 | certfile: Path.join([@fixtures_dir, "selfsigned.pem"]), 16 | keyfile: Path.join([@fixtures_dir, "selfsigned_key.pem"]), 17 | alpn_preferred_protocols: ["undefined"], 18 | protocol_options: [ 19 | idle_timeout: 3_000, 20 | request_timeout: 10_000 21 | ] 22 | ] 23 | ) 24 | ] 25 | 26 | Supervisor.start_link(children, strategy: :one_for_one) 27 | end 28 | end 29 | 30 | defmodule Finch.HTTPS1Server.PlugRouter do 31 | @moduledoc false 32 | 33 | use Plug.Router 34 | 35 | plug(:match) 36 | 37 | plug(:dispatch) 38 | 39 | get "/" do 40 | name = conn.params["name"] || "world" 41 | 42 | conn 43 | |> send_resp(200, "Hello #{name}!") 44 | |> halt() 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/mock_http2_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.MockHTTP2Server do 2 | @moduledoc false 3 | import ExUnit.Assertions 4 | alias Mint.HTTP2.Frame 5 | 6 | defstruct [:socket, :encode_table, :decode_table] 7 | 8 | @fixtures_dir Path.expand("../fixtures", __DIR__) 9 | 10 | @ssl_opts [ 11 | mode: :binary, 12 | packet: :raw, 13 | active: false, 14 | reuseaddr: true, 15 | next_protocols_advertised: ["h2"], 16 | alpn_preferred_protocols: ["h2"], 17 | certfile: Path.join([@fixtures_dir, "selfsigned.pem"]), 18 | keyfile: Path.join([@fixtures_dir, "selfsigned_key.pem"]) 19 | ] 20 | 21 | def start_and_connect_with(options, fun) when is_list(options) and is_function(fun, 1) do 22 | parent = self() 23 | server_settings = Keyword.get(options, :server_settings, []) 24 | 25 | {:ok, listen_socket} = :ssl.listen(0, @ssl_opts) 26 | {:ok, {_address, port}} = :ssl.sockname(listen_socket) 27 | 28 | task = Task.async(fn -> accept(listen_socket, parent, server_settings) end) 29 | 30 | result = fun.(port) 31 | 32 | {:ok, server_socket} = Task.await(task) 33 | :ok = :ssl.setopts(server_socket, active: true) 34 | 35 | server = %__MODULE__{ 36 | socket: server_socket, 37 | encode_table: HPAX.new(4096), 38 | decode_table: HPAX.new(4096) 39 | } 40 | 41 | {result, server} 42 | end 43 | 44 | @spec recv_next_frames(%__MODULE__{}, pos_integer()) :: [frame :: term(), ...] 45 | def recv_next_frames(%__MODULE__{} = server, frame_count) when frame_count > 0 do 46 | recv_next_frames(server, frame_count, [], "") 47 | end 48 | 49 | defp recv_next_frames(_server, 0, frames, buffer) do 50 | if buffer == "" do 51 | Enum.reverse(frames) 52 | else 53 | flunk("Expected no more data, got: #{inspect(buffer)}") 54 | end 55 | end 56 | 57 | defp recv_next_frames(%{socket: server_socket} = server, n, frames, buffer) do 58 | assert_receive {:ssl, ^server_socket, data}, 100 59 | decode_next_frames(server, n, frames, buffer <> data) 60 | end 61 | 62 | defp decode_next_frames(_server, 0, frames, buffer) do 63 | if buffer == "" do 64 | Enum.reverse(frames) 65 | else 66 | flunk("Expected no more data, got: #{inspect(buffer)}") 67 | end 68 | end 69 | 70 | defp decode_next_frames(server, n, frames, data) do 71 | case Frame.decode_next(data) do 72 | {:ok, frame, rest} -> 73 | decode_next_frames(server, n - 1, [frame | frames], rest) 74 | 75 | :more -> 76 | recv_next_frames(server, n, frames, data) 77 | 78 | other -> 79 | flunk("Error decoding frame: #{inspect(other)}") 80 | end 81 | end 82 | 83 | @spec encode_headers(%__MODULE__{}, Mint.Types.headers()) :: {%__MODULE__{}, hbf :: binary()} 84 | def encode_headers(%__MODULE__{} = server, headers) when is_list(headers) do 85 | headers = for {name, value} <- headers, do: {:store_name, name, value} 86 | {hbf, encode_table} = HPAX.encode(headers, server.encode_table) 87 | server = put_in(server.encode_table, encode_table) 88 | {server, IO.iodata_to_binary(hbf)} 89 | end 90 | 91 | @spec decode_headers(%__MODULE__{}, binary()) :: {%__MODULE__{}, Mint.Types.headers()} 92 | def decode_headers(%__MODULE__{} = server, hbf) when is_binary(hbf) do 93 | assert {:ok, headers, decode_table} = HPAX.decode(hbf, server.decode_table) 94 | server = put_in(server.decode_table, decode_table) 95 | {server, headers} 96 | end 97 | 98 | def send_frames(%__MODULE__{socket: socket}, frames) when is_list(frames) and frames != [] do 99 | # TODO: split the data at random places to increase fuzziness. 100 | data = Enum.map(frames, &Frame.encode/1) 101 | :ok = :ssl.send(socket, data) 102 | end 103 | 104 | @spec get_socket(%__MODULE__{}) :: :ssl.sslsocket() 105 | def get_socket(server) do 106 | server.socket 107 | end 108 | 109 | defp accept(listen_socket, parent, server_settings) do 110 | {:ok, socket} = :ssl.transport_accept(listen_socket) 111 | {:ok, socket} = :ssl.handshake(socket) 112 | 113 | :ok = perform_http2_handshake(socket, server_settings) 114 | 115 | # We transfer ownership of the socket to the parent so that this task can die. 116 | :ok = :ssl.controlling_process(socket, parent) 117 | {:ok, socket} 118 | end 119 | 120 | connection_preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" 121 | 122 | defp perform_http2_handshake(socket, server_settings) do 123 | import Mint.HTTP2.Frame, only: [settings: 1] 124 | 125 | no_flags = Frame.set_flags(:settings, []) 126 | ack_flags = Frame.set_flags(:settings, [:ack]) 127 | 128 | # First we get the connection preface. 129 | {:ok, unquote(connection_preface) <> rest} = :ssl.recv(socket, 0, 100) 130 | 131 | # Then we get a SETTINGS frame. 132 | assert {:ok, frame, ""} = Frame.decode_next(rest) 133 | assert settings(flags: ^no_flags, params: _params) = frame 134 | 135 | # We reply with our SETTINGS. 136 | :ok = :ssl.send(socket, Frame.encode(settings(params: server_settings))) 137 | 138 | # We get the SETTINGS ack. 139 | {:ok, data} = :ssl.recv(socket, 0, 100) 140 | assert {:ok, frame, ""} = Frame.decode_next(data) 141 | assert settings(flags: ^ack_flags, params: []) = frame 142 | 143 | # We send the SETTINGS ack back. 144 | :ok = :ssl.send(socket, Frame.encode(settings(flags: ack_flags, params: []))) 145 | 146 | :ok 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/support/mock_socket_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.MockSocketServer do 2 | @moduledoc false 3 | 4 | @fixtures_dir Path.expand("../fixtures", __DIR__) 5 | 6 | @socket_opts [ 7 | active: false, 8 | mode: :binary, 9 | packet: :raw 10 | ] 11 | 12 | @ssl_opts [ 13 | reuseaddr: true, 14 | nodelay: true, 15 | certfile: Path.join([@fixtures_dir, "selfsigned.pem"]), 16 | keyfile: Path.join([@fixtures_dir, "selfsigned_key.pem"]) 17 | ] 18 | 19 | def start(opts \\ []) do 20 | transport = Keyword.get(opts, :transport, :gen_tcp) 21 | handler = Keyword.get(opts, :handler, &default_handler/2) 22 | address = Keyword.get(opts, :address) 23 | 24 | {:ok, socket} = listen(transport, address) 25 | 26 | spawn_link(fn -> 27 | {:ok, client} = accept(transport, socket) 28 | serve(transport, client, handler) 29 | end) 30 | 31 | {:ok, socket} 32 | end 33 | 34 | defp listen(transport, nil) do 35 | transport.listen(0, socket_opts(transport)) 36 | end 37 | 38 | defp listen(transport, {:local, path}) do 39 | socket_opts = [ifaddr: {:local, path}] ++ socket_opts(transport) 40 | transport.listen(0, socket_opts) 41 | end 42 | 43 | defp socket_opts(:gen_tcp), do: @socket_opts 44 | defp socket_opts(:ssl), do: @socket_opts ++ @ssl_opts 45 | 46 | defp accept(:gen_tcp, socket) do 47 | {:ok, _client} = :gen_tcp.accept(socket) 48 | end 49 | 50 | defp accept(:ssl, socket) do 51 | {:ok, client} = :ssl.transport_accept(socket) 52 | 53 | if function_exported?(:ssl, :handshake, 1) do 54 | {:ok, _} = apply(:ssl, :handshake, [client]) 55 | else 56 | :ok = apply(:ssl, :ssl_accept, [client]) 57 | end 58 | 59 | {:ok, client} 60 | end 61 | 62 | defp serve(transport, client, handler) do 63 | case transport.recv(client, 0) do 64 | {:ok, _data} -> 65 | handler.(transport, client) 66 | transport.close(client) 67 | 68 | _ -> 69 | serve(transport, client, handler) 70 | end 71 | end 72 | 73 | defp default_handler(transport, socket) do 74 | :ok = transport.send(socket, "HTTP/1.1 200 OK\r\n\r\n") 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/support/test_usage.ex: -------------------------------------------------------------------------------- 1 | defmodule Finch.TestUsage do 2 | @moduledoc false 3 | # This module only exists in order to force dialyzer to pick up potential type 4 | # errors 5 | 6 | def call do 7 | req = Finch.build(:get, "https://keathley.io") 8 | 9 | case Finch.request(req, __MODULE__, []) do 10 | {:ok, %Finch.Response{} = resp} -> 11 | resp 12 | 13 | {:error, reason} -> 14 | raise reason 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, listen_socket} = :ssl.listen(0, mode: :binary) 2 | {:ok, {_address, port}} = :ssl.sockname(listen_socket) 3 | :ssl.close(listen_socket) 4 | 5 | Finch.HTTP2Server.start(port) 6 | Application.put_env(:finch, :test_https_h2_url, "https://localhost:#{port}") 7 | 8 | Mimic.copy(Mint.HTTP) 9 | 10 | ExUnit.start() 11 | Application.ensure_all_started(:bypass) 12 | 13 | defmodule Finch.TestHelper do 14 | def ssl_version() do 15 | Application.spec(:ssl, :vsn) 16 | |> List.to_string() 17 | |> String.split(".") 18 | |> Enum.map(&String.to_integer/1) 19 | end 20 | end 21 | --------------------------------------------------------------------------------