Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion async-http-faraday.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions gems.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
gem "bake-test"

gem "faraday-multipart"
gem "faraday-retry"

gem "sus-fixtures-async"
gem "sus-fixtures-async-http"
Expand Down
3 changes: 3 additions & 0 deletions lib/async/http/faraday/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
require "async/http/client"
require "async/http/proxy"

require "protocol/http/error"

require_relative "clients"

module Async
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions test/async/http/faraday/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading