diff --git a/async-http-faraday.gemspec b/async-http-faraday.gemspec index 1878d6d..289a16e 100644 --- a/async-http-faraday.gemspec +++ b/async-http-faraday.gemspec @@ -27,6 +27,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "async-http", "~> 0.42" + spec.add_dependency "async-http", "~> 0.99" spec.add_dependency "faraday" end diff --git a/context/getting-started.md b/context/getting-started.md index e4e195a..4cb3ba9 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -59,3 +59,23 @@ end ~~~ This will reduce memory usage but increase the latency of every request. + +### Retrying Failed Requests + +An HTTP/2 stream reset can interrupt a response after its headers have arrived. The adapter translates {ruby Protocol::HTTP::Error} and its subclasses, including HTTP/1 errors, {ruby Protocol::HTTP2::StreamError}, and {ruby Protocol::HTTP::RemoteError}, into {ruby Faraday::ConnectionFailed}, preserving the original exception as its cause. This includes locally detected protocol errors, not just remote failures. The translation does not add retries, guarantee that retrying is safe or useful, or classify every possible body-read exception. + +For requests whose incomplete responses can be discarded, you can configure `faraday-retry` to repeat the request. This example allows up to two retries with backoff for bodyless `GET` and `HEAD` requests: + +~~~ruby +require "faraday/retry" + +connection = Faraday.new("https://api.example.com") do |builder| + builder.request :retry, max: 2, interval: 0.1, backoff_factor: 2, + methods: [:get, :head], exceptions: [Faraday::ConnectionFailed] + builder.adapter :async_http +end +~~~ + +Async HTTP already retries eligible failures before returning a response. Faraday retries encompass response-body consumption as well, and can repeat requests after the internal attempts have been exhausted. Each Faraday attempt may therefore involve several underlying attempts; configure retry limits and an overall deadline accordingly. + +Idempotency alone is not sufficient for requests with bodies, such as `PUT`: the body must also be replayable. Do not assume that `faraday-retry` rewinds arbitrary IO or streaming bodies. Ensure that the complete request body is restored before each attempt. When streaming response chunks to a callback, ensure that partial output can be discarded or that repeated chunks can be handled safely before enabling retries. diff --git a/gems.rb b/gems.rb index 75f8bd2..95e8429 100644 --- a/gems.rb +++ b/gems.rb @@ -27,6 +27,7 @@ gem "bake-test" gem "faraday-multipart" + gem "faraday-retry" gem "sus-fixtures-async" gem "sus-fixtures-async-http" diff --git a/lib/async/http/faraday/adapter.rb b/lib/async/http/faraday/adapter.rb index 82a0ea4..4a2998c 100644 --- a/lib/async/http/faraday/adapter.rb +++ b/lib/async/http/faraday/adapter.rb @@ -20,6 +20,8 @@ require "async/http/client" require "async/http/proxy" +require "protocol/http/error" + require_relative "clients" module Async @@ -105,6 +107,7 @@ def self.setup_parallel_manager(**options) # The exceptions that are considered connection errors and result in a `Faraday::ConnectionFailed` exception. CONNECTION_EXCEPTIONS = [ + ::Protocol::HTTP::Error, Errno::EADDRNOTAVAIL, Errno::ECONNABORTED, Errno::ECONNREFUSED, diff --git a/test/async/http/faraday/adapter.rb b/test/async/http/faraday/adapter.rb index a13414d..2c2ad3e 100644 --- a/test/async/http/faraday/adapter.rb +++ b/test/async/http/faraday/adapter.rb @@ -16,8 +16,11 @@ require "faraday" require "faraday/multipart" +require "faraday/retry" require "protocol/http/body/file" +require "protocol/http1/error" +require "protocol/http2/error" require "protocol/multipart" describe Async::HTTP::Faraday::Adapter do @@ -268,6 +271,60 @@ def get_response(url = bound_url, path = "/index", adapter_options: {}) end end + [ + Protocol::HTTP::Error.new("The HTTP exchange failed!"), + Protocol::HTTP::RemoteError.new("The remote endpoint failed!"), + Protocol::HTTP::RefusedError.new("The request was refused!"), + Protocol::HTTP1::Error.new("The HTTP/1 exchange failed!"), + Protocol::HTTP1::ContentLengthError.new("The response body was truncated!"), + Protocol::HTTP2::Error.new("The HTTP/2 exchange failed!"), + Protocol::HTTP2::StreamError.for(Protocol::HTTP2::Error::INTERNAL_ERROR), + Protocol::HTTP2::StreamError.for(Protocol::HTTP2::Error::CANCEL), + Protocol::HTTP2::GoawayError.for(Protocol::HTTP2::Error::INTERNAL_ERROR), + ].each do |error| + with "#{error.class}: #{error.message}", unique: "#{error.class}: #{error.message}" do + it "can retry using Faraday middleware while preserving the cause" do + attempts = 0 + failures = [] + client = Object.new + client.define_singleton_method(:call) do |request| + attempts += 1 + + if attempts == 1 + body = Protocol::HTTP::Body::Writable.new + body.close_write(error) + Protocol::HTTP::Response[200, {}, body] + else + Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + end + + clients = Object.new + clients.define_singleton_method(:with_client) do |endpoint, &block| + block.call(client) + end + clients.define_singleton_method(:close){} + + connection = Faraday.new("https://example.com") do |builder| + builder.request :retry, max: 1, exceptions: [Faraday::ConnectionFailed], + retry_block: proc{|exception:, **| failures << exception} + builder.adapter :async_http, clients: proc{clients} + end + + response = connection.get("/") + + expect(response.body).to be == "Hello World" + expect(attempts).to be == 2 + expect(failures.size).to be == 1 + expect(failures.first).to be_a(Faraday::ConnectionFailed).and( + have_attributes(cause: be_equal(error)) + ) + ensure + connection&.close + end + end + end + with "a multi-part post body" do include Sus::Fixtures::Async::HTTP::ServerContext