├── .ruby-version ├── .rspec ├── bin ├── setup ├── test └── console ├── lib └── faraday │ ├── retry │ ├── version.rb │ ├── retryable.rb │ └── middleware.rb │ ├── retriable_response.rb │ └── retry.rb ├── .github ├── dependabot.yml ├── workflows │ ├── publish.yml │ └── ci.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── Rakefile ├── .gitignore ├── .editorconfig ├── spec ├── faraday │ └── retry │ │ ├── version_spec.rb │ │ └── middleware_spec.rb └── spec_helper.rb ├── Gemfile ├── .rubocop.yml ├── LICENSE.md ├── faraday-retry.gemspec ├── .rubocop_todo.yml ├── CHANGELOG.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require ./spec/spec_helper 2 | --format documentation 3 | --color 4 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | gem install bundler 7 | bundle install 8 | -------------------------------------------------------------------------------- /lib/faraday/retry/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Faraday 4 | module Retry 5 | VERSION = '2.3.2' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle exec rubocop -a --format progress 7 | bundle exec rspec 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .bundle/ 3 | .yardoc 4 | _yardoc/ 5 | coverage/ 6 | doc/ 7 | pkg/ 8 | spec/reports/ 9 | tmp/ 10 | 11 | *.gem 12 | Gemfile.lock 13 | 14 | .rvmrc 15 | 16 | .rspec_status 17 | -------------------------------------------------------------------------------- /lib/faraday/retriable_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Faraday namespace. 4 | module Faraday 5 | # Exception used to control the Retry middleware. 6 | class RetriableResponse < Error 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | -------------------------------------------------------------------------------- /spec/faraday/retry/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Faraday::Retry::VERSION' do 4 | subject { Object.const_get(self.class.description) } 5 | 6 | it { is_expected.to match(/^\d+\.\d+\.\d+(\.\w+(\.\d+)?)?$/) } 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'base64', '~> 0.2' # Avoid a RuboCop warning about bae64 no longer being shipped with later Ruby versions. 8 | 9 | group :test do 10 | gem 'faraday-multipart', '~> 1.0' 11 | end 12 | -------------------------------------------------------------------------------- /lib/faraday/retry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | require_relative 'retriable_response' 5 | require_relative 'retry/middleware' 6 | require_relative 'retry/version' 7 | 8 | module Faraday 9 | # Middleware main module. 10 | module Retry 11 | Faraday::Request.register_middleware(retry: Faraday::Retry::Middleware) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'faraday' 6 | require_relative '../lib/faraday/retry' 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | 11 | # (If you use this, don't forget to add pry to your Gemfile!) 12 | # require "pry" 13 | # Pry.start 14 | 15 | require 'irb' 16 | IRB.start(__FILE__) 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Publish to Rubygems 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | 19 | - name: Set up latest Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: true 23 | 24 | - name: Publish to RubyGems 25 | uses: rubygems/release-gem@v1 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | require 'faraday/multipart' 5 | require_relative '../lib/faraday/retry' 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | 18 | config.order = :random 19 | end 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-packaging 5 | - rubocop-performance 6 | - rubocop-rspec 7 | 8 | inherit_mode: 9 | merge: 10 | - Include 11 | - Exclude 12 | 13 | AllCops: 14 | DisplayCopNames: true 15 | DisplayStyleGuide: true 16 | TargetRubyVersion: 2.6 17 | SuggestExtensions: false 18 | NewCops: enable 19 | 20 | Metrics/ClassLength: 21 | Enabled: false 22 | 23 | Metrics/BlockLength: 24 | Exclude: 25 | - spec/**/*.rb 26 | 27 | Layout/EmptyLinesAroundAttributeAccessor: # (0.83) 28 | Enabled: true 29 | 30 | Layout/LineLength: 31 | Exclude: 32 | - spec/**/*.rb 33 | 34 | Layout/SpaceAroundMethodCallOperator: 35 | Enabled: true 36 | 37 | Style/Documentation: 38 | Exclude: 39 | - 'spec/**/*' 40 | 41 | Packaging/RequireRelativeHardcodingLib: 42 | Exclude: 43 | - bin/**/* 44 | - spec/**/* 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Mattia Giuffrida 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### Basic Version Info 11 | Faraday Version: X.Y.Z 12 | Ruby Version: X.Y.Z 13 | 14 | ### Issue description 15 | 16 | 17 | ### Actual behavior 18 | 19 | 20 | ### Expected behavior 21 | 22 | 23 | ### Steps to reproduce 24 | 25 | ```ruby 26 | #!/usr/bin/env ruby 27 | # frozen_string_literal: true 28 | 29 | require "bundler/inline" 30 | 31 | gemfile do 32 | source "https://rubygems.org" 33 | 34 | gem "faraday" 35 | gem "faraday-retry" 36 | end 37 | 38 | count = 0 39 | expected = 5 40 | retry_options = { 41 | max: expected, 42 | interval: 0.1, 43 | retry_statuses: [503], 44 | retry_block: proc { count += 1 } 45 | } 46 | 47 | faraday = Faraday.new do |conn| 48 | conn.request :retry, **retry_options 49 | end 50 | 51 | faraday.get("https://httpbin.org/status/503") 52 | 53 | exit 0 if count == expected 54 | 55 | warn "Retried #{count} times, expected #{expected}" 56 | exit 1 57 | ``` 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | rubocop: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | # Allow Ruby 2.7 to be used. 14 | - run: rm .ruby-version || true 15 | - name: Set up Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 2.7 19 | bundler-cache: true 20 | - name: Run RuboCop 21 | run: bundle exec rubocop 22 | 23 | rspec: 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ## Due to https://github.com/actions/runner/issues/849, 29 | ## we have to use quotes for '3.0' 30 | ruby: 31 | - '2.6' 32 | - '2.7' 33 | - '3.0' 34 | - '3.1' 35 | - '3.2' 36 | - '3.3' 37 | - '3.4' 38 | steps: 39 | - uses: actions/checkout@v6 40 | - name: Set up Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ${{ matrix.ruby }} 44 | bundler-cache: true 45 | - name: Run RSpec 46 | run: bundle exec rspec 47 | -------------------------------------------------------------------------------- /lib/faraday/retry/retryable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Faraday 4 | # Adds the ability to retry a request based on settings and errors that have occurred. 5 | module Retryable 6 | def with_retries(env:, options:, retries:, body:, errmatch:) 7 | yield 8 | rescue errmatch => e 9 | exhausted_retries(options, env, e) if retries_zero?(retries, env, e) 10 | 11 | if retries.positive? && retry_request?(env, e) 12 | retries -= 1 13 | rewind_files(body) 14 | if (sleep_amount = calculate_sleep_amount(retries + 1, env)) 15 | options.retry_block.call( 16 | env: env, 17 | options: options, 18 | retry_count: options.max - (retries + 1), 19 | exception: e, 20 | will_retry_in: sleep_amount 21 | ) 22 | sleep sleep_amount 23 | retry 24 | end 25 | end 26 | 27 | raise unless e.is_a?(Faraday::RetriableResponse) 28 | 29 | e.response 30 | end 31 | 32 | private 33 | 34 | def retries_zero?(retries, env, exception) 35 | retries.zero? && retry_request?(env, exception) 36 | end 37 | 38 | def exhausted_retries(options, env, exception) 39 | options.exhausted_retries_block.call( 40 | env: env, 41 | exception: exception, 42 | options: options 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /faraday-retry.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/faraday/retry/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'faraday-retry' 7 | spec.version = Faraday::Retry::VERSION 8 | spec.authors = ['Mattia Giuffrida'] 9 | spec.email = ['giuffrida.mattia@gmail.com'] 10 | 11 | spec.summary = 'Catches exceptions and retries each request a limited number of times' 12 | spec.description = <<~DESC 13 | Catches exceptions and retries each request a limited number of times. 14 | DESC 15 | spec.license = 'MIT' 16 | 17 | github_uri = "https://github.com/lostisland/#{spec.name}" 18 | 19 | spec.homepage = github_uri 20 | 21 | spec.metadata = { 22 | 'bug_tracker_uri' => "#{github_uri}/issues", 23 | 'changelog_uri' => "#{github_uri}/blob/v#{spec.version}/CHANGELOG.md", 24 | 'documentation_uri' => "http://www.rubydoc.info/gems/#{spec.name}/#{spec.version}", 25 | 'homepage_uri' => spec.homepage, 26 | 'source_code_uri' => github_uri 27 | } 28 | 29 | spec.files = Dir['lib/**/*', 'README.md', 'LICENSE.md', 'CHANGELOG.md'] 30 | 31 | spec.required_ruby_version = '>= 2.6', '< 4' 32 | 33 | spec.add_runtime_dependency 'faraday', '~> 2.0' 34 | 35 | spec.add_development_dependency 'bundler', '~> 2.0' 36 | spec.add_development_dependency 'rake', '~> 13.0' 37 | spec.add_development_dependency 'rspec', '~> 3.0' 38 | spec.add_development_dependency 'simplecov', '~> 0.21.0' 39 | 40 | spec.add_development_dependency 'rubocop', '~> 1.21.0' 41 | spec.add_development_dependency 'rubocop-packaging', '~> 0.5.0' 42 | spec.add_development_dependency 'rubocop-performance', '~> 1.0' 43 | spec.add_development_dependency 'rubocop-rspec', '~> 2.0' 44 | end 45 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-11-03 21:03:59 UTC using RuboCop version 1.21.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: IgnoredMethods. 11 | Lint/AmbiguousBlockAssociation: 12 | Exclude: 13 | - 'spec/faraday/retry/middleware_spec.rb' 14 | 15 | # Offense count: 1 16 | # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. 17 | Metrics/AbcSize: 18 | Max: 26 19 | 20 | # Offense count: 1 21 | # Configuration parameters: CountComments, CountAsOne. 22 | Metrics/ClassLength: 23 | Max: 107 24 | 25 | # Offense count: 2 26 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 27 | Metrics/MethodLength: 28 | Max: 26 29 | 30 | # Offense count: 12 31 | # Configuration parameters: Prefixes. 32 | # Prefixes: when, with, without 33 | RSpec/ContextWording: 34 | Exclude: 35 | - 'spec/faraday/retry/middleware_spec.rb' 36 | 37 | # Offense count: 2 38 | # Configuration parameters: CountAsOne. 39 | RSpec/ExampleLength: 40 | Max: 9 41 | 42 | # Offense count: 3 43 | RSpec/ExpectInHook: 44 | Exclude: 45 | - 'spec/faraday/retry/middleware_spec.rb' 46 | 47 | # Offense count: 5 48 | # Configuration parameters: AssignmentOnly. 49 | RSpec/InstanceVariable: 50 | Exclude: 51 | - 'spec/faraday/retry/middleware_spec.rb' 52 | 53 | # Offense count: 7 54 | RSpec/MultipleExpectations: 55 | Max: 3 56 | 57 | # Offense count: 13 58 | # Configuration parameters: AllowSubject. 59 | RSpec/MultipleMemoizedHelpers: 60 | Max: 9 61 | 62 | # Offense count: 3 63 | RSpec/NestedGroups: 64 | Max: 4 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | _nothing yet_ 6 | 7 | ## v2.2.1 (2024-04-15) 8 | 9 | * Avoid deprecation warning about ::UploadIO constant when used without faraday-multipart gem [PR #37](https://github.com/lostisland/faraday-retry/pull/37) [@iMacTia] 10 | * Documentation update [PR #30](https://github.com/lostisland/faraday-retry/pull/30) [@olleolleolle] 11 | * Documentation update [PR #32](https://github.com/lostisland/faraday-retry/pull/32) Thanks, [@Drowze]! 12 | 13 | ## v2.2.0 (2023-06-01) 14 | 15 | * Support new `header_parser_block` option. [PR #28](https://github.com/lostisland/faraday-retry/pull/28). Thanks, [@zavan]! 16 | 17 | ## v2.1.0 (2023-03-03) 18 | 19 | * Support for custom RateLimit headers. [PR #13](https://github.com/lostisland/faraday-retry/pull/13). Thanks, [@brookemckim]! 20 | 21 | v2.1.1 (2023-02-17) is a spurious not-released version that you may have seen mentioned in this CHANGELOG. 22 | 23 | ## v2.0.0 (2022-06-08) 24 | 25 | ### Changed 26 | 27 | * `retry_block` now takes keyword arguments instead of positional (backwards incompatible) 28 | * `retry_block`'s `retry_count` argument now counts up from 0, instead of old `retries_remaining` 29 | 30 | ### Added 31 | 32 | * Support for the `RateLimit-Reset` header. [PR #9](https://github.com/lostisland/faraday-retry/pull/9). Thanks, [@maxprokopiev]! 33 | * `retry_block` has additional `will_retry_in` argument with upcoming delay before retry in seconds. 34 | 35 | ## v1.0 36 | 37 | Initial release. 38 | This release consists of the same middleware that was previously bundled with Faraday but removed in Faraday v2.0, plus: 39 | 40 | ### Fixed 41 | 42 | * Retry middleware `retry_block` is not called if retry will not happen due to `max_interval`, https://github.com/lostisland/faraday/pull/1350 43 | 44 | [@maxprokopiev]: https://github.com/maxprokopiev 45 | [@brookemckim]: https://github.com/brookemckim 46 | [@zavan]: https://github.com/zavan 47 | [@Drowze]: https://github.com/Drowze 48 | [@olleolleolle]: https://github.com/olleolleolle 49 | [@iMacTia]: https://github.com/iMacTia 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Faraday Retry 2 | 3 | [![CI](https://github.com/lostisland/faraday-retry/actions/workflows/ci.yml/badge.svg)](https://github.com/lostisland/faraday-retry/actions/workflows/ci.yml) 4 | [![Gem](https://img.shields.io/gem/v/faraday-retry.svg?style=flat-square)](https://rubygems.org/gems/faraday-retry) 5 | [![License](https://img.shields.io/github/license/lostisland/faraday-retry.svg?style=flat-square)](LICENSE.md) 6 | 7 | The `Retry` middleware automatically retries requests that fail due to intermittent client 8 | or server errors (such as network hiccups). 9 | By default, it retries 2 times and handles only timeout exceptions. 10 | It can be configured with an arbitrary number of retries, a list of exceptions to handle, 11 | a retry interval, a percentage of randomness to add to the retry interval, and a backoff factor. 12 | The middleware can also handle the [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) 13 | header automatically when configured with the right status codes (see below for an example). 14 | 15 | ## Installation 16 | 17 | Add this line to your application's Gemfile: 18 | 19 | ```ruby 20 | gem 'faraday-retry' 21 | ``` 22 | 23 | And then execute: 24 | 25 | ```shell 26 | bundle install 27 | ``` 28 | 29 | Or install it yourself as: 30 | 31 | ```shell 32 | gem install faraday-retry 33 | ``` 34 | 35 | ## Usage 36 | 37 | This example will result in a first interval that is random between 0.05 and 0.075 38 | and a second interval that is random between 0.1 and 0.125. 39 | 40 | ```ruby 41 | require 'faraday' 42 | require 'faraday/retry' 43 | 44 | retry_options = { 45 | max: 2, 46 | interval: 0.05, 47 | interval_randomness: 0.5, 48 | backoff_factor: 2 49 | } 50 | 51 | conn = Faraday.new(...) do |f| 52 | f.request :retry, retry_options 53 | #... 54 | end 55 | 56 | conn.get('/') 57 | ``` 58 | 59 | ### Control when the middleware will retry requests 60 | 61 | By default, the `Retry` middleware will only retry idempotent methods and the most common network-related exceptions. 62 | You can change this behaviour by providing the right option when adding the middleware to your connection. 63 | 64 | #### Specify which methods will be retried 65 | 66 | You can provide a `methods` option with a list of HTTP methods. 67 | This will replace the default list of HTTP methods: `delete`, `get`, `head`, `options`, `put`. 68 | 69 | ```ruby 70 | retry_options = { 71 | methods: %i[get post] 72 | } 73 | ``` 74 | 75 | #### Specify which exceptions should trigger a retry 76 | 77 | You can provide an `exceptions` option with a list of exceptions that will replace 78 | the default exceptions: `Errno::ETIMEDOUT`, `Timeout::Error`, `Faraday::TimeoutError`, `Faraday::Error::RetriableResponse`. 79 | This can be particularly useful when combined with the [RaiseError][raise_error] middleware. 80 | 81 | ```ruby 82 | retry_options = { 83 | exceptions: [Faraday::ResourceNotFound, Faraday::UnauthorizedError] 84 | } 85 | ``` 86 | 87 | If you want to inherit default exceptions, do it this way. 88 | 89 | ```ruby 90 | retry_options = { 91 | exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ResourceNotFound, Faraday::UnauthorizedError] 92 | } 93 | ``` 94 | 95 | #### Specify on which response statuses to retry 96 | 97 | By default the `Retry` middleware will only retry the request if one of the expected exceptions arise. 98 | However, you can specify a list of HTTP statuses you'd like to be retried. When you do so, the middleware will 99 | check the response `status` code and will retry the request if included in the list. 100 | 101 | ```ruby 102 | retry_options = { 103 | retry_statuses: [401, 409] 104 | } 105 | ``` 106 | 107 | #### Automatically handle the `Retry-After` and `RateLimit-Reset` headers 108 | 109 | Some APIs, like the [Slack API](https://api.slack.com/docs/rate-limits), will inform you when you reach their API limits by replying with a response status code of `429` 110 | and a response header of `Retry-After` containing a time in seconds. You should then only retry querying after the amount of time provided by the `Retry-After` header, 111 | otherwise you won't get a response. Other APIs communicate their rate limits via the [RateLimit-xxx](https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-05.html#name-providing-ratelimit-fields) headers 112 | where `RateLimit-Reset` behaves similarly to the `Retry-After`. 113 | 114 | You can automatically handle both headers and have Faraday pause and retry for the right amount of time by including the `429` status code in the retry statuses list: 115 | 116 | ```ruby 117 | retry_options = { 118 | retry_statuses: [429] 119 | } 120 | ``` 121 | 122 | If you are working with an API which does not comply with the Rate Limit RFC you can specify custom headers to be used for retry and reset, as well as a block to parse the headers: 123 | 124 | ```ruby 125 | retry_options = { 126 | retry_statuses: [429], 127 | rate_limit_retry_header: 'x-rate-limit-retry-after', 128 | rate_limit_reset_header: 'x-rate-limit-reset', 129 | header_parser_block: ->(value) { Time.at(value.to_i).utc - Time.now.utc } 130 | } 131 | ``` 132 | 133 | #### Specify a custom retry logic 134 | 135 | You can also specify a custom retry logic with the `retry_if` option. 136 | This option accepts a block that will receive the `env` object and the exception raised 137 | and should decide if the code should retry still the action or not independent of the retry count. 138 | This would be useful if the exception produced is non-recoverable or if the the HTTP method called is not idempotent. 139 | 140 | **NOTE:** this option will only be used for methods that are not included in the `methods` option. 141 | If you want this to apply to all HTTP methods, pass `methods: []` as an additional option. 142 | 143 | ```ruby 144 | # Retries the request if response contains { success: false } 145 | retry_options = { 146 | retry_if: -> (env, _exc) { env.body[:success] == 'false' } 147 | } 148 | ``` 149 | 150 | ### Call a block on every retry 151 | 152 | You can specify a proc object through the `retry_block` option that will be called before every retry. 153 | There are many different applications for this feature, ranging from instrumentation to monitoring. 154 | 155 | The block is passed keyword arguments with contextual information: Request environment, middleware options, current number of retries, exception, and amount of time we will wait before retrying. (retry_block is called before the wait time happens) 156 | 157 | For example, you might want to keep track of the response statuses: 158 | 159 | ```ruby 160 | response_statuses = [] 161 | retry_options = { 162 | retry_block: -> (env:, options:, retry_count:, exception:, will_retry_in:) { response_statuses << env.status } 163 | } 164 | ``` 165 | 166 | ### Call a block after retries have been exhausted 167 | 168 | You can specify a lambda object through the `exhausted_retries_block` option that will be called after all retries are exhausted. 169 | This block will be called once. 170 | 171 | The block is passed keyword arguments with contextual information and passed your data: 172 | * Request environment, 173 | * exception, 174 | * middleware options 175 | * and your data. 176 | 177 | In a lambda you can pass any logic for further work. 178 | 179 | For example, you might want to update user by request query. 180 | 181 | ```ruby 182 | retry_options = { 183 | exhausted_retries_block: -> (user_id:, env:, exception:, options:) { User.find_by!(id: user_id).do_admin! } 184 | } 185 | ``` 186 | 187 | ## Development 188 | 189 | After checking out the repo, run `bin/setup` to install dependencies. 190 | 191 | Then, run `bin/test` to run the tests. 192 | 193 | To install this gem onto your local machine, run `rake build`. 194 | 195 | ### Releasing a new version 196 | 197 | To release a new version, make a commit with a message such as "Bumped to 0.0.2", and change the _Unreleased_ heading in `CHANGELOG.md` to a heading like "0.0.2 (2022-01-01)", and then use GitHub Releases to author a release. A GitHub Actions workflow then publishes a new gem to [RubyGems.org](https://rubygems.org/gems/faraday-retry). 198 | 199 | ## Contributing 200 | 201 | Bug reports and pull requests are welcome on [GitHub](https://github.com/lostisland/faraday-retry). 202 | 203 | ## License 204 | 205 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 206 | 207 | [raise_error]: https://lostisland.github.io/faraday/#/middleware/included/raising-errors 208 | -------------------------------------------------------------------------------- /lib/faraday/retry/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'retryable' 4 | 5 | module Faraday 6 | module Retry 7 | # This class provides the main implementation for your middleware. 8 | # Your middleware can implement any of the following methods: 9 | # * on_request - called when the request is being prepared 10 | # * on_complete - called when the response is being processed 11 | # 12 | # Optionally, you can also override the following methods from Faraday::Middleware 13 | # * initialize(app, options = {}) - the initializer method 14 | # * call(env) - the main middleware invocation method. 15 | # This already calls on_request and on_complete, so you normally don't need to override it. 16 | # You may need to in case you need to "wrap" the request or need more control 17 | # (see "retry" middleware: https://github.com/lostisland/faraday/blob/main/lib/faraday/request/retry.rb#L142). 18 | # IMPORTANT: Remember to call `@app.call(env)` or `super` to not interrupt the middleware chain! 19 | class Middleware < Faraday::Middleware 20 | include Retryable 21 | 22 | DEFAULT_EXCEPTIONS = [ 23 | Errno::ETIMEDOUT, 'Timeout::Error', 24 | Faraday::TimeoutError, Faraday::RetriableResponse 25 | ].freeze 26 | IDEMPOTENT_METHODS = %i[delete get head options put].freeze 27 | 28 | # Options contains the configurable parameters for the Retry middleware. 29 | class Options < Faraday::Options.new(:max, :interval, :max_interval, 30 | :interval_randomness, 31 | :backoff_factor, :exceptions, 32 | :methods, :retry_if, :retry_block, 33 | :retry_statuses, :rate_limit_retry_header, 34 | :rate_limit_reset_header, :header_parser_block, 35 | :exhausted_retries_block) 36 | 37 | DEFAULT_CHECK = ->(_env, _exception) { false } 38 | 39 | def self.from(value) 40 | if value.is_a?(Integer) 41 | new(value) 42 | else 43 | super(value) 44 | end 45 | end 46 | 47 | def max 48 | (self[:max] ||= 2).to_i 49 | end 50 | 51 | def interval 52 | (self[:interval] ||= 0).to_f 53 | end 54 | 55 | def max_interval 56 | (self[:max_interval] ||= Float::MAX).to_f 57 | end 58 | 59 | def interval_randomness 60 | (self[:interval_randomness] ||= 0).to_f 61 | end 62 | 63 | def backoff_factor 64 | (self[:backoff_factor] ||= 1).to_f 65 | end 66 | 67 | def exceptions 68 | Array(self[:exceptions] ||= DEFAULT_EXCEPTIONS) 69 | end 70 | 71 | def methods 72 | Array(self[:methods] ||= IDEMPOTENT_METHODS) 73 | end 74 | 75 | def retry_if 76 | self[:retry_if] ||= DEFAULT_CHECK 77 | end 78 | 79 | def retry_block 80 | self[:retry_block] ||= proc {} 81 | end 82 | 83 | def retry_statuses 84 | Array(self[:retry_statuses] ||= []) 85 | end 86 | 87 | def exhausted_retries_block 88 | self[:exhausted_retries_block] ||= proc {} 89 | end 90 | end 91 | 92 | # @param app [#call] 93 | # @param options [Hash] 94 | # @option options [Integer] :max (2) Maximum number of retries 95 | # @option options [Integer] :interval (0) Pause in seconds between retries 96 | # @option options [Integer] :interval_randomness (0) The maximum random 97 | # interval amount expressed as a float between 98 | # 0 and 1 to use in addition to the interval. 99 | # @option options [Integer] :max_interval (Float::MAX) An upper limit 100 | # for the interval 101 | # @option options [Integer] :backoff_factor (1) The amount to multiply 102 | # each successive retry's interval amount by in order to provide backoff 103 | # @option options [Array] :exceptions ([ Errno::ETIMEDOUT, 104 | # 'Timeout::Error', Faraday::TimeoutError, Faraday::RetriableResponse]) 105 | # The list of exceptions to handle. Exceptions can be given as 106 | # Class, Module, or String. 107 | # @option options [Array] :methods (the idempotent HTTP methods 108 | # in IDEMPOTENT_METHODS) A list of HTTP methods, as symbols, to retry without 109 | # calling retry_if. Pass an empty Array to call retry_if 110 | # for all exceptions. 111 | # @option options [Block] :retry_if (false) block that will receive 112 | # the env object and the exception raised 113 | # and should decide if the code should retry still the action or 114 | # not independent of the retry count. This would be useful 115 | # if the exception produced is non-recoverable or if the 116 | # the HTTP method called is not idempotent. 117 | # @option options [Block] :retry_block block that is executed before 118 | # every retry. The block will be yielded keyword arguments: 119 | # * env [Faraday::Env]: Request environment 120 | # * options [Faraday::Options]: middleware options 121 | # * retry_count [Integer]: how many retries have already occured (starts at 0) 122 | # * exception [Exception]: exception that triggered the retry, 123 | # will be the synthetic `Faraday::RetriableResponse` if the 124 | # retry was triggered by something other than an exception. 125 | # * will_retry_in [Float]: retry_block is called *before* the retry 126 | # delay, actual retry will happen in will_retry_in number of 127 | # seconds. 128 | # @option options [Array] :retry_statuses Array of Integer HTTP status 129 | # codes or a single Integer value that determines whether to raise 130 | # a Faraday::RetriableResponse exception based on the HTTP status code 131 | # of an HTTP response. 132 | # @option options [Block] :header_parser_block block that will receive 133 | # the the value of the retry header and should return the number of 134 | # seconds to wait before retrying the request. This is useful if the 135 | # value of the header is not a number of seconds or a RFC 2822 formatted date. 136 | # @option options [Block] :exhausted_retries_block block will receive 137 | # when all attempts are exhausted. The block will be yielded keyword arguments: 138 | # * env [Faraday::Env]: Request environment 139 | # * exception [Exception]: exception that triggered the retry, 140 | # will be the synthetic `Faraday::RetriableResponse` if the 141 | # retry was triggered by something other than an exception. 142 | # * options [Faraday::Options]: middleware options 143 | def initialize(app, options = nil) 144 | super(app) 145 | @options = Options.from(options) 146 | @errmatch = build_exception_matcher(@options.exceptions) 147 | end 148 | 149 | def calculate_sleep_amount(retries, env) 150 | retry_after = [calculate_retry_after(env), calculate_rate_limit_reset(env)].compact.max 151 | retry_interval = calculate_retry_interval(retries) 152 | 153 | return if retry_after && retry_after > @options.max_interval 154 | 155 | if retry_after && retry_after >= retry_interval 156 | retry_after 157 | else 158 | retry_interval 159 | end 160 | end 161 | 162 | # @param env [Faraday::Env] 163 | def call(env) 164 | retries = @options.max 165 | request_body = env[:body] 166 | 167 | with_retries(env: env, options: @options, retries: retries, body: request_body, errmatch: @errmatch) do 168 | # after failure env[:body] is set to the response body 169 | env[:body] = request_body 170 | 171 | @app.call(env).tap do |resp| 172 | raise Faraday::RetriableResponse.new(nil, resp) if @options.retry_statuses.include?(resp.status) 173 | end 174 | end 175 | end 176 | 177 | # An exception matcher for the rescue clause can usually be any object 178 | # that responds to `===`, but for Ruby 1.8 it has to be a Class or Module. 179 | # 180 | # @param exceptions [Array] 181 | # @api private 182 | # @return [Module] an exception matcher 183 | def build_exception_matcher(exceptions) 184 | matcher = Module.new 185 | ( 186 | class << matcher 187 | self 188 | end).class_eval do 189 | define_method(:===) do |error| 190 | exceptions.any? do |ex| 191 | if ex.is_a? Module 192 | error.is_a? ex 193 | else 194 | Object.const_defined?(ex.to_s) && error.is_a?(Object.const_get(ex.to_s)) 195 | end 196 | end 197 | end 198 | end 199 | matcher 200 | end 201 | 202 | private 203 | 204 | def retry_request?(env, exception) 205 | @options.methods.include?(env[:method]) || 206 | @options.retry_if.call(env, exception) 207 | end 208 | 209 | def rewind_files(body) 210 | return unless defined?(Faraday::UploadIO) 211 | return unless body.is_a?(Hash) 212 | 213 | body.each do |_, value| 214 | value.rewind if value.is_a?(Faraday::UploadIO) 215 | end 216 | end 217 | 218 | # RFC for RateLimit Header Fields for HTTP: 219 | # https://www.ietf.org/archive/id/draft-ietf-httpapi-ratelimit-headers-05.html#name-fields-definition 220 | def calculate_rate_limit_reset(env) 221 | reset_header = @options.rate_limit_reset_header || 'RateLimit-Reset' 222 | parse_retry_header(env, reset_header) 223 | end 224 | 225 | # MDN spec for Retry-After header: 226 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After 227 | def calculate_retry_after(env) 228 | retry_header = @options.rate_limit_retry_header || 'Retry-After' 229 | parse_retry_header(env, retry_header) 230 | end 231 | 232 | def calculate_retry_interval(retries) 233 | retry_index = @options.max - retries 234 | current_interval = @options.interval * 235 | (@options.backoff_factor**retry_index) 236 | current_interval = [current_interval, @options.max_interval].min 237 | random_interval = rand * @options.interval_randomness.to_f * 238 | @options.interval 239 | 240 | current_interval + random_interval 241 | end 242 | 243 | def parse_retry_header(env, header) 244 | response_headers = env[:response_headers] 245 | return unless response_headers 246 | 247 | retry_after_value = env[:response_headers][header] 248 | 249 | if @options.header_parser_block 250 | @options.header_parser_block.call(retry_after_value) 251 | else 252 | # Try to parse date from the header value 253 | begin 254 | datetime = DateTime.rfc2822(retry_after_value) 255 | datetime.to_time - Time.now.utc 256 | rescue ArgumentError 257 | retry_after_value.to_f 258 | end 259 | end 260 | end 261 | end 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /spec/faraday/retry/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Faraday::Retry::Middleware do 4 | let(:calls) { [] } 5 | let(:times_called) { calls.size } 6 | let(:options) { [] } 7 | let(:conn) do 8 | Faraday.new do |b| 9 | b.request :retry, *options 10 | 11 | b.adapter :test do |stub| 12 | %w[get post].each do |method| 13 | stub.send(method, '/unstable') do |env| 14 | calls << env.dup 15 | env[:body] = nil # simulate blanking out response body 16 | callback.call 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | context 'when an unexpected error happens' do 24 | let(:callback) { -> { raise 'boom!' } } 25 | 26 | before { expect { conn.get('/unstable') }.to raise_error(RuntimeError) } 27 | 28 | it { expect(times_called).to eq(1) } 29 | 30 | context 'when this is passed as a custom exception' do 31 | let(:options) { [{ exceptions: StandardError }] } 32 | 33 | it { expect(times_called).to eq(3) } 34 | end 35 | 36 | context 'when this is passed as a string custom exception' do 37 | let(:options) { [{ exceptions: 'StandardError' }] } 38 | 39 | it { expect(times_called).to eq(3) } 40 | end 41 | 42 | context 'when a non-existent string custom exception is passed' do 43 | let(:options) { [{ exceptions: 'WrongStandardErrorNotExisting' }] } 44 | 45 | it { expect(times_called).to eq(1) } 46 | end 47 | end 48 | 49 | context 'when an expected error happens' do 50 | let(:callback) { -> { raise Errno::ETIMEDOUT } } 51 | 52 | before do 53 | @started = Time.now 54 | expect { conn.get('/unstable') }.to raise_error(Errno::ETIMEDOUT) 55 | end 56 | 57 | it { expect(times_called).to eq(3) } 58 | 59 | context 'when legacy max_retry set to 1' do 60 | let(:options) { [1] } 61 | 62 | it { expect(times_called).to eq(2) } 63 | end 64 | 65 | context 'when legacy max_retry set to -9' do 66 | let(:options) { [-9] } 67 | 68 | it { expect(times_called).to eq(1) } 69 | end 70 | 71 | context 'when new max_retry set to 3' do 72 | let(:options) { [{ max: 3 }] } 73 | 74 | it { expect(times_called).to eq(4) } 75 | end 76 | 77 | context 'when new max_retry set to -9' do 78 | let(:options) { [{ max: -9 }] } 79 | 80 | it { expect(times_called).to eq(1) } 81 | end 82 | 83 | context 'when both max_retry and interval are set' do 84 | let(:options) { [{ max: 2, interval: 0.1 }] } 85 | 86 | it { expect(Time.now - @started).to be_within(0.04).of(0.2) } 87 | end 88 | 89 | context 'when retry_block is set' do 90 | let(:options) { [{ retry_block: ->(**kwargs) { retry_block_calls << kwargs } }] } 91 | let(:retry_block_calls) { [] } 92 | let(:retry_block_times_called) { retry_block_calls.size } 93 | 94 | it 'calls retry block for each retry' do 95 | expect(retry_block_times_called).to eq(2) 96 | end 97 | 98 | describe 'with arguments to retry_block' do 99 | it { expect(retry_block_calls.first[:exception]).to be_kind_of(Errno::ETIMEDOUT) } 100 | it { expect(retry_block_calls.first[:options]).to be_kind_of(Faraday::Options) } 101 | it { expect(retry_block_calls.first[:env]).to be_kind_of(Faraday::Env) } 102 | it { expect(retry_block_calls.first[:retry_count]).to be_kind_of(Integer) } 103 | it { expect(retry_block_calls.first[:retry_count]).to eq 0 } 104 | end 105 | 106 | describe 'arguments to retry_block on second call' do 107 | it { expect(retry_block_calls[1][:retry_count]).to eq 1 } 108 | end 109 | end 110 | 111 | context 'when exhausted_retries_block is set' do 112 | let(:numbers) { [] } 113 | 114 | # The required arguments are env, exception and options, but we may add more, if we supply a default value. 115 | let(:logic) { ->(number: 1, **) { numbers.push(number) } } 116 | let(:options) do 117 | [ 118 | { 119 | exhausted_retries_block: logic, 120 | max: 2 121 | } 122 | ] 123 | end 124 | 125 | describe 'with arguments to exhausted_retries_block' do 126 | let(:exhausted_retries_block_calls) { [] } 127 | let(:options) { [{ exhausted_retries_block: ->(**kwargs) { exhausted_retries_block_calls << kwargs } }] } 128 | 129 | it { expect(exhausted_retries_block_calls.first[:exception]).to be_kind_of(Errno::ETIMEDOUT) } 130 | it { expect(exhausted_retries_block_calls.first[:options]).to be_kind_of(Faraday::Options) } 131 | it { expect(exhausted_retries_block_calls.first[:env]).to be_kind_of(Faraday::Env) } 132 | end 133 | 134 | it 'calls exhausted_retries_block block once when retries are exhausted' do 135 | expect(numbers).to eq([1]) 136 | end 137 | 138 | it { expect(times_called).to eq(options.first[:max] + 1) } 139 | end 140 | end 141 | 142 | context 'when no exception raised' do 143 | let(:options) { [{ max: 1, retry_statuses: 429 }] } 144 | 145 | before { conn.get('/unstable') } 146 | 147 | context 'when response code is in retry_statuses' do 148 | let(:callback) { -> { [429, {}, ''] } } 149 | 150 | it { expect(times_called).to eq(2) } 151 | end 152 | 153 | context 'when response code is not in retry_statuses' do 154 | let(:callback) { -> { [503, {}, ''] } } 155 | 156 | it { expect(times_called).to eq(1) } 157 | end 158 | end 159 | 160 | describe '#calculate_retry_interval' do 161 | context 'with exponential backoff' do 162 | let(:options) { { max: 5, interval: 0.1, backoff_factor: 2 } } 163 | let(:middleware) { described_class.new(nil, options) } 164 | 165 | it { expect(middleware.send(:calculate_retry_interval, 5)).to eq(0.1) } 166 | it { expect(middleware.send(:calculate_retry_interval, 4)).to eq(0.2) } 167 | it { expect(middleware.send(:calculate_retry_interval, 3)).to eq(0.4) } 168 | end 169 | 170 | context 'with exponential backoff and max_interval' do 171 | let(:options) { { max: 5, interval: 0.1, backoff_factor: 2, max_interval: 0.3 } } 172 | let(:middleware) { described_class.new(nil, options) } 173 | 174 | it { expect(middleware.send(:calculate_retry_interval, 5)).to eq(0.1) } 175 | it { expect(middleware.send(:calculate_retry_interval, 4)).to eq(0.2) } 176 | it { expect(middleware.send(:calculate_retry_interval, 3)).to eq(0.3) } 177 | it { expect(middleware.send(:calculate_retry_interval, 2)).to eq(0.3) } 178 | end 179 | 180 | context 'with exponential backoff and interval_randomness' do 181 | let(:options) { { max: 2, interval: 0.1, interval_randomness: 0.05 } } 182 | let(:middleware) { described_class.new(nil, options) } 183 | 184 | it { expect(middleware.send(:calculate_retry_interval, 2)).to be_between(0.1, 0.105) } 185 | end 186 | end 187 | 188 | context 'when method is not idempotent' do 189 | let(:callback) { -> { raise Errno::ETIMEDOUT } } 190 | 191 | before { expect { conn.post('/unstable') }.to raise_error(Errno::ETIMEDOUT) } 192 | 193 | it { expect(times_called).to eq(1) } 194 | end 195 | 196 | describe 'retry_if option' do 197 | let(:callback) { -> { raise Errno::ETIMEDOUT } } 198 | let(:options) { [{ retry_if: @check }] } 199 | 200 | it 'retries if retry_if block always returns true' do 201 | body = { foo: :bar } 202 | @check = ->(_, _) { true } 203 | expect { conn.post('/unstable', body) }.to raise_error(Errno::ETIMEDOUT) 204 | expect(times_called).to eq(3) 205 | expect(calls).to(be_all { |env| env[:body] == body }) 206 | end 207 | 208 | it 'does not retry if retry_if block returns false checking env' do 209 | @check = ->(env, _) { env[:method] != :post } 210 | expect { conn.post('/unstable') }.to raise_error(Errno::ETIMEDOUT) 211 | expect(times_called).to eq(1) 212 | end 213 | 214 | it 'does not retry if retry_if block returns false checking exception' do 215 | @check = ->(_, exception) { !exception.is_a?(Errno::ETIMEDOUT) } 216 | expect { conn.post('/unstable') }.to raise_error(Errno::ETIMEDOUT) 217 | expect(times_called).to eq(1) 218 | end 219 | 220 | it 'FilePart: should rewind files on retry' do 221 | io = StringIO.new('Test data') 222 | filepart = Faraday::Multipart::FilePart.new(io, 'application/octet/stream') 223 | 224 | rewound = 0 225 | rewind = -> { rewound += 1 } 226 | 227 | @check = ->(_, _) { true } 228 | allow(filepart).to receive(:rewind, &rewind) 229 | expect { conn.post('/unstable', file: filepart) }.to raise_error(Errno::ETIMEDOUT) 230 | expect(times_called).to eq(3) 231 | expect(rewound).to eq(2) 232 | end 233 | 234 | it 'UploadIO: should rewind files on retry' do 235 | io = StringIO.new('Test data') 236 | upload_io = Faraday::Multipart::FilePart.new(io, 'application/octet/stream') 237 | 238 | rewound = 0 239 | rewind = -> { rewound += 1 } 240 | 241 | @check = ->(_, _) { true } 242 | allow(upload_io).to receive(:rewind, &rewind) 243 | expect { conn.post('/unstable', file: upload_io) }.to raise_error(Errno::ETIMEDOUT) 244 | expect(times_called).to eq(3) 245 | expect(rewound).to eq(2) 246 | end 247 | 248 | context 'when explicitly specifying methods to retry' do 249 | let(:options) { [{ retry_if: @check, methods: [:post] }] } 250 | 251 | it 'does not call retry_if for specified methods' do 252 | @check = ->(_, _) { raise 'this should have never been called' } 253 | expect { conn.post('/unstable') }.to raise_error(Errno::ETIMEDOUT) 254 | expect(times_called).to eq(3) 255 | end 256 | end 257 | 258 | context 'with empty list of methods to retry' do 259 | let(:options) { [{ retry_if: @check, methods: [] }] } 260 | 261 | it 'calls retry_if for all methods' do 262 | @check = ->(_, _) { calls.size < 2 } 263 | expect { conn.get('/unstable') }.to raise_error(Errno::ETIMEDOUT) 264 | expect(times_called).to eq(2) 265 | end 266 | end 267 | end 268 | 269 | describe 'retry_after header support' do 270 | let(:callback) { -> { [504, headers, ''] } } 271 | let(:elapsed) { Time.now - @started } 272 | 273 | before do 274 | @started = Time.now 275 | conn.get('/unstable') 276 | end 277 | 278 | context 'when custom retry header is set' do 279 | let(:headers) { { 'x-retry-after' => '0.5' } } 280 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504, rate_limit_retry_header: 'x-retry-after' }] } 281 | 282 | it { expect(elapsed).to be > 0.5 } 283 | end 284 | 285 | context 'when custom reset header is set' do 286 | let(:headers) { { 'x-reset-after' => '0.5' } } 287 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504, rate_limit_reset_header: 'x-reset-after' }] } 288 | 289 | it { expect(elapsed).to be > 0.5 } 290 | end 291 | 292 | context 'when Retry-After bigger than RateLimit-Reset' do 293 | let(:headers) { { 'Retry-After' => '0.5', 'RateLimit-Reset' => '0.1' } } 294 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504 }] } 295 | 296 | it { expect(elapsed).to be > 0.5 } 297 | end 298 | 299 | context 'when RateLimit-Reset bigger than Retry-After' do 300 | let(:headers) { { 'Retry-After' => '0.1', 'RateLimit-Reset' => '0.5' } } 301 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504 }] } 302 | 303 | it { expect(elapsed).to be > 0.5 } 304 | end 305 | 306 | context 'when retry_after smaller than interval' do 307 | let(:headers) { { 'Retry-After' => '0.1' } } 308 | let(:options) { [{ max: 1, interval: 0.2, retry_statuses: 504 }] } 309 | 310 | it { expect(elapsed).to be > 0.2 } 311 | end 312 | 313 | context 'when RateLimit-Reset is a timestamp' do 314 | let(:headers) { { 'Retry-After' => '0.1', 'RateLimit-Reset' => (Time.now.utc + 2).strftime('%a, %d %b %Y %H:%M:%S GMT') } } 315 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504 }] } 316 | 317 | it { expect(elapsed).to be > 1 } 318 | end 319 | 320 | context 'when retry_after is a timestamp' do 321 | let(:headers) { { 'Retry-After' => (Time.now.utc + 2).strftime('%a, %d %b %Y %H:%M:%S GMT') } } 322 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504 }] } 323 | 324 | it { expect(elapsed).to be > 1 } 325 | end 326 | 327 | context 'when custom header_parser_block is set' do 328 | let(:headers) { { 'Retry-After' => '0.1', 'RateLimit-Reset' => (Time.now.utc + 2).to_i.to_s } } 329 | let(:options) { [{ max: 1, interval: 0.1, retry_statuses: 504, header_parser_block: ->(value) { Time.at(value.to_i).utc - Time.now.utc } }] } 330 | 331 | it { expect(elapsed).to be > 1 } 332 | end 333 | 334 | context 'when retry_after is bigger than max_interval' do 335 | let(:headers) { { 'Retry-After' => (Time.now.utc + 20).strftime('%a, %d %b %Y %H:%M:%S GMT') } } 336 | let(:options) { [{ max: 2, interval: 0.1, max_interval: 5, retry_statuses: 504 }] } 337 | 338 | it { expect(times_called).to eq(1) } 339 | 340 | context 'when retry_block is set' do 341 | let(:options) do 342 | [{ 343 | retry_block: ->(**kwargs) { retry_block_calls << kwargs }, 344 | max: 2, 345 | max_interval: 5, 346 | retry_statuses: 504 347 | }] 348 | end 349 | 350 | let(:retry_block_calls) { [] } 351 | let(:retry_block_times_called) { retry_block_calls.size } 352 | 353 | it 'retry_block is not called' do 354 | expect(retry_block_times_called).to eq(0) 355 | end 356 | end 357 | end 358 | end 359 | end 360 | --------------------------------------------------------------------------------