From 3e81c2a6418dc9caf5aa6ed721f501c43d88f745 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 30 Jul 2026 13:41:18 +1200 Subject: [PATCH 1/3] Handle remote HTTP errors --- async-http-faraday.gemspec | 2 +- context/getting-started.md | 15 ++++++++++++ gems.rb | 1 + lib/async/http/faraday/adapter.rb | 1 + test/async/http/faraday/adapter.rb | 37 ++++++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 1 deletion(-) 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..180846f 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -48,6 +48,21 @@ connection = Faraday.new(...) do |builder| end ~~~ +### Retrying Remote Failures + +When a remote endpoint fails while processing a request, the adapter raises {ruby Faraday::ConnectionFailed}. You can use the `faraday-retry` middleware to retry idempotent requests: + +~~~ruby +require "faraday/retry" + +connection = Faraday.new(...) do |builder| + builder.request :retry, exceptions: [Faraday::ConnectionFailed] + builder.adapter :async_http +end +~~~ + +By default, `faraday-retry` only retries idempotent methods. Configure its retry policy explicitly before retrying other methods. + The value of isolation cannot be overstated - if you can design you program using a share-nothing (between threads) architecture, you will have a much easier time debugging and reasoning about your program, however this comes at the cost of increased resource usage. Alternatively, if you do not want to cache client connections, you can use the `Async::HTTP::Faraday::Clients` interface, which closes the connection after each request: 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..b9db603 100644 --- a/lib/async/http/faraday/adapter.rb +++ b/lib/async/http/faraday/adapter.rb @@ -105,6 +105,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::RemoteError, Errno::EADDRNOTAVAIL, Errno::ECONNABORTED, Errno::ECONNREFUSED, diff --git a/test/async/http/faraday/adapter.rb b/test/async/http/faraday/adapter.rb index a13414d..d4dc344 100644 --- a/test/async/http/faraday/adapter.rb +++ b/test/async/http/faraday/adapter.rb @@ -16,6 +16,7 @@ require "faraday" require "faraday/multipart" +require "faraday/retry" require "protocol/http/body/file" require "protocol/multipart" @@ -268,6 +269,42 @@ def get_response(url = bound_url, path = "/index", adapter_options: {}) end end + with "a remote failure" do + it "can retry using Faraday middleware" do + attempts = 0 + client = Object.new + client.define_singleton_method(:call) do |request| + attempts += 1 + + if attempts == 1 + body = Protocol::HTTP::Body::Writable.new + body.close_write(Protocol::HTTP::RemoteError.new("The remote endpoint failed!")) + 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] + builder.adapter :async_http, clients: proc{clients} + end + + response = connection.get("/") + + expect(response.body).to be == "Hello World" + expect(attempts).to be == 2 + ensure + connection&.close + end + end + with "a multi-part post body" do include Sus::Fixtures::Async::HTTP::ServerContext From eec8c951fdcb14ffe4c4883dca7bc7974f90863f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 18 Sep 2026 22:53:08 +1200 Subject: [PATCH 2/3] Translate HTTP/2 stream errors directly in the Faraday adapter --- context/getting-started.md | 29 +++++++----- lib/async/http/faraday/adapter.rb | 3 ++ test/async/http/faraday/adapter.rb | 72 +++++++++++++++++------------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 180846f..0da7de1 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -48,29 +48,34 @@ connection = Faraday.new(...) do |builder| end ~~~ -### Retrying Remote Failures +The value of isolation cannot be overstated - if you can design you program using a share-nothing (between threads) architecture, you will have a much easier time debugging and reasoning about your program, however this comes at the cost of increased resource usage. -When a remote endpoint fails while processing a request, the adapter raises {ruby Faraday::ConnectionFailed}. You can use the `faraday-retry` middleware to retry idempotent requests: +Alternatively, if you do not want to cache client connections, you can use the `Async::HTTP::Faraday::Clients` interface, which closes the connection after each request: ~~~ruby -require "faraday/retry" - connection = Faraday.new(...) do |builder| - builder.request :retry, exceptions: [Faraday::ConnectionFailed] - builder.adapter :async_http + builder.adapter :async_http, clients: Async::HTTP::Faraday::Clients end ~~~ -By default, `faraday-retry` only retries idempotent methods. Configure its retry policy explicitly before retrying other methods. +This will reduce memory usage but increase the latency of every request. -The value of isolation cannot be overstated - if you can design you program using a share-nothing (between threads) architecture, you will have a much easier time debugging and reasoning about your program, however this comes at the cost of increased resource usage. +### Retrying Failed Requests -Alternatively, if you do not want to cache client connections, you can use the `Async::HTTP::Faraday::Clients` interface, which closes the connection after each request: +An HTTP/2 stream reset can interrupt a response after its headers have arrived. The adapter translates {ruby Protocol::HTTP2::StreamError} and {ruby Protocol::HTTP::RemoteError} into {ruby Faraday::ConnectionFailed}, preserving the original exception as its cause. This translation does not add retries 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 -connection = Faraday.new(...) do |builder| - builder.adapter :async_http, clients: Async::HTTP::Faraday::Clients +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 ~~~ -This will reduce memory usage but increase the latency of every request. +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/lib/async/http/faraday/adapter.rb b/lib/async/http/faraday/adapter.rb index b9db603..82d013a 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/http2/error" + require_relative "clients" module Async @@ -106,6 +108,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::RemoteError, + ::Protocol::HTTP2::StreamError, Errno::EADDRNOTAVAIL, Errno::ECONNABORTED, Errno::ECONNREFUSED, diff --git a/test/async/http/faraday/adapter.rb b/test/async/http/faraday/adapter.rb index d4dc344..8a31572 100644 --- a/test/async/http/faraday/adapter.rb +++ b/test/async/http/faraday/adapter.rb @@ -269,39 +269,51 @@ def get_response(url = bound_url, path = "/index", adapter_options: {}) end end - with "a remote failure" do - it "can retry using Faraday middleware" do - attempts = 0 - client = Object.new - client.define_singleton_method(:call) do |request| - attempts += 1 + [ + Protocol::HTTP::RemoteError.new("The remote endpoint failed!"), + Protocol::HTTP2::StreamError.for(Protocol::HTTP2::Error::INTERNAL_ERROR), + Protocol::HTTP2::StreamError.for(Protocol::HTTP2::Error::CANCEL), + ].each do |error| + with "#{error.class}: #{error.message}", unique: 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 - if attempts == 1 - body = Protocol::HTTP::Body::Writable.new - body.close_write(Protocol::HTTP::RemoteError.new("The remote endpoint failed!")) - Protocol::HTTP::Response[200, {}, body] - else - Protocol::HTTP::Response[200, {}, ["Hello World"]] + 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 - - 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] - builder.adapter :async_http, clients: proc{clients} - end - - response = connection.get("/") - - expect(response.body).to be == "Hello World" - expect(attempts).to be == 2 - ensure - connection&.close end end From 39a3d742a1a1c8be4f64330e18aa14652d912725 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 18 Sep 2026 23:00:51 +1200 Subject: [PATCH 3/3] Translate HTTP protocol errors through their common base class --- context/getting-started.md | 2 +- lib/async/http/faraday/adapter.rb | 5 ++--- test/async/http/faraday/adapter.rb | 10 +++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 0da7de1..4cb3ba9 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -62,7 +62,7 @@ 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::HTTP2::StreamError} and {ruby Protocol::HTTP::RemoteError} into {ruby Faraday::ConnectionFailed}, preserving the original exception as its cause. This translation does not add retries or classify every possible body-read exception. +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: diff --git a/lib/async/http/faraday/adapter.rb b/lib/async/http/faraday/adapter.rb index 82d013a..4a2998c 100644 --- a/lib/async/http/faraday/adapter.rb +++ b/lib/async/http/faraday/adapter.rb @@ -20,7 +20,7 @@ require "async/http/client" require "async/http/proxy" -require "protocol/http2/error" +require "protocol/http/error" require_relative "clients" @@ -107,8 +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::RemoteError, - ::Protocol::HTTP2::StreamError, + ::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 8a31572..2c2ad3e 100644 --- a/test/async/http/faraday/adapter.rb +++ b/test/async/http/faraday/adapter.rb @@ -19,6 +19,8 @@ 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 @@ -270,11 +272,17 @@ def get_response(url = bound_url, path = "/index", adapter_options: {}) 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.message do + 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 = []