From 9211f02dec9b7dcd77e1c7b03d7707292315f214 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 20:04:43 +1200 Subject: [PATCH 1/3] Rewind request bodies before retry --- async-http.gemspec | 2 +- lib/async/http/client.rb | 4 +-- releases.md | 4 +++ test/async/http/client.rb | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/async-http.gemspec b/async-http.gemspec index 41caf1d9..9fa21f4d 100644 --- a/async-http.gemspec +++ b/async-http.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "async-pool", "~> 0.11" spec.add_dependency "io-endpoint", "~> 0.14" spec.add_dependency "io-stream", "~> 0.6" - spec.add_dependency "protocol-http", "~> 0.62" + spec.add_dependency "protocol-http", "~> 0.63", ">= 0.63.1" spec.add_dependency "protocol-http1", "~> 0.39" spec.add_dependency "protocol-http2", "~> 0.26" spec.add_dependency "protocol-url", "~> 0.2" diff --git a/lib/async/http/client.rb b/lib/async/http/client.rb index 56bc9f6b..5c5add94 100755 --- a/lib/async/http/client.rb +++ b/lib/async/http/client.rb @@ -121,7 +121,7 @@ def call(request) connection = nil end - if attempt < @retries + if attempt < @retries and request.rewind! retry else raise @@ -132,7 +132,7 @@ def call(request) connection = nil end - if request.idempotent? and attempt < @retries + if attempt < @retries and request.retry! retry else raise diff --git a/releases.md b/releases.md index 778138a9..dcc70003 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Rewind request bodies before retrying requests. + ## v0.97.0 - Exposed all supported protocol names from the plaintext HTTP protocol negotiator. diff --git a/test/async/http/client.rb b/test/async/http/client.rb index 5091ed26..d19d9f3f 100644 --- a/test/async/http/client.rb +++ b/test/async/http/client.rb @@ -14,6 +14,61 @@ require "sus/fixtures/async/http" describe Async::HTTP::Client do + class RetryClient < Async::HTTP::Client + def initialize(failures) + @pool = FakePool.new + @protocol = nil + @retries = 3 + @scheme = "https" + @authority = "example.com" + @failures = failures + @chunks = [] + end + + attr :chunks + + class FakePool + def acquire + Object.new + end + + def release(connection) + end + end + + def make_response(request, connection, attempt) + @chunks << request.body&.read + + if failure = @failures.shift + raise failure + end + + return Protocol::HTTP::Response[200, {}, ["OK"]] + end + end + + with "retries" do + it "rewinds idempotent request bodies after ambiguous failures" do + client = RetryClient.new([EOFError.new]) + request = Protocol::HTTP::Request["PUT", "/", {}, ["Hello"]] + + response = client.call(request) + + expect(response).to be(:success?) + expect(client.chunks).to be == ["Hello", "Hello"] + end + + it "rewinds non-idempotent request bodies after refused requests" do + client = RetryClient.new([Protocol::HTTP::RefusedError.new("Request not processed.")]) + request = Protocol::HTTP::Request["POST", "/", {}, ["Hello"]] + + response = client.call(request) + + expect(response).to be(:success?) + expect(client.chunks).to be == ["Hello", "Hello"] + end + end + with "basic server" do include Sus::Fixtures::Async::HTTP::ServerContext From da6ee228a1c9c2be4d037134dc32a1d88e5f8e0a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 25 Jul 2026 09:13:43 +1200 Subject: [PATCH 2/3] Update protocol-http dependency --- async-http.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async-http.gemspec b/async-http.gemspec index 9fa21f4d..cb325cf5 100644 --- a/async-http.gemspec +++ b/async-http.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "async-pool", "~> 0.11" spec.add_dependency "io-endpoint", "~> 0.14" spec.add_dependency "io-stream", "~> 0.6" - spec.add_dependency "protocol-http", "~> 0.63", ">= 0.63.1" + spec.add_dependency "protocol-http", "~> 0.64" spec.add_dependency "protocol-http1", "~> 0.39" spec.add_dependency "protocol-http2", "~> 0.26" spec.add_dependency "protocol-url", "~> 0.2" From c4a26a41ac3b9309107a29519d7b7198d6105c5a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 25 Jul 2026 09:25:12 +1200 Subject: [PATCH 3/3] Move client retry tests --- test/async/http/client.rb | 55 ---------------------------- test/async/http/client/retry.rb | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 test/async/http/client/retry.rb diff --git a/test/async/http/client.rb b/test/async/http/client.rb index d19d9f3f..5091ed26 100644 --- a/test/async/http/client.rb +++ b/test/async/http/client.rb @@ -14,61 +14,6 @@ require "sus/fixtures/async/http" describe Async::HTTP::Client do - class RetryClient < Async::HTTP::Client - def initialize(failures) - @pool = FakePool.new - @protocol = nil - @retries = 3 - @scheme = "https" - @authority = "example.com" - @failures = failures - @chunks = [] - end - - attr :chunks - - class FakePool - def acquire - Object.new - end - - def release(connection) - end - end - - def make_response(request, connection, attempt) - @chunks << request.body&.read - - if failure = @failures.shift - raise failure - end - - return Protocol::HTTP::Response[200, {}, ["OK"]] - end - end - - with "retries" do - it "rewinds idempotent request bodies after ambiguous failures" do - client = RetryClient.new([EOFError.new]) - request = Protocol::HTTP::Request["PUT", "/", {}, ["Hello"]] - - response = client.call(request) - - expect(response).to be(:success?) - expect(client.chunks).to be == ["Hello", "Hello"] - end - - it "rewinds non-idempotent request bodies after refused requests" do - client = RetryClient.new([Protocol::HTTP::RefusedError.new("Request not processed.")]) - request = Protocol::HTTP::Request["POST", "/", {}, ["Hello"]] - - response = client.call(request) - - expect(response).to be(:success?) - expect(client.chunks).to be == ["Hello", "Hello"] - end - end - with "basic server" do include Sus::Fixtures::Async::HTTP::ServerContext diff --git a/test/async/http/client/retry.rb b/test/async/http/client/retry.rb new file mode 100644 index 00000000..7111541f --- /dev/null +++ b/test/async/http/client/retry.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/http/client" + +class RetryClient < Async::HTTP::Client + def initialize(failures) + @pool = FakePool.new + @protocol = nil + @retries = 3 + @scheme = "https" + @authority = "example.com" + @failures = failures + @chunks = [] + end + + attr :chunks + + class FakePool + def acquire + Object.new + end + + def release(connection) + end + end + + def make_response(request, connection, attempt) + @chunks << request.body&.read + + if failure = @failures.shift + raise failure + end + + return Protocol::HTTP::Response[200, {}, ["OK"]] + end +end + +describe Async::HTTP::Client do + with "retries" do + it "rewinds idempotent request bodies after ambiguous failures" do + client = RetryClient.new([EOFError.new]) + request = Protocol::HTTP::Request["PUT", "/", {}, ["Hello"]] + + response = client.call(request) + + expect(response).to be(:success?) + expect(client.chunks).to be == ["Hello", "Hello"] + end + + it "rewinds non-idempotent request bodies after refused requests" do + client = RetryClient.new([Protocol::HTTP::RefusedError.new("Request not processed.")]) + request = Protocol::HTTP::Request["POST", "/", {}, ["Hello"]] + + response = client.call(request) + + expect(response).to be(:success?) + expect(client.chunks).to be == ["Hello", "Hello"] + end + end +end