From ad8284bf409272dc08e2442e7d1b37d9dd49e5d1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 29 Jul 2026 16:19:03 +1200 Subject: [PATCH] Retry remote HTTP/2 internal errors --- async-http.gemspec | 2 +- lib/async/http/client.rb | 3 ++- lib/async/http/protocol/http2/response.rb | 6 ++++++ releases.md | 4 ++++ test/async/http/client/retry.rb | 21 +++++++++++++++++++ .../connection_close_with_active_streams.rb | 20 ++++++++++++++++++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/async-http.gemspec b/async-http.gemspec index 3a710a2..b134eb0 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.14" - spec.add_dependency "protocol-http", "~> 0.64" + spec.add_dependency "protocol-http", "~> 0.66" 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 5c5add9..054356e 100755 --- a/lib/async/http/client.rb +++ b/lib/async/http/client.rb @@ -9,6 +9,7 @@ require "async/pool/controller" require "protocol/http/body/completable" +require "protocol/http/error" require "protocol/http/methods" require_relative "protocol" @@ -126,7 +127,7 @@ def call(request) else raise end - rescue SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE + rescue ::Protocol::HTTP::RemoteError, SocketError, IOError, EOFError, Errno::ECONNRESET, Errno::EPIPE if connection @pool.release(connection) connection = nil diff --git a/lib/async/http/protocol/http2/response.rb b/lib/async/http/protocol/http2/response.rb index 359554e..3da6ab2 100644 --- a/lib/async/http/protocol/http2/response.rb +++ b/lib/async/http/protocol/http2/response.rb @@ -122,6 +122,12 @@ def wait if @exception raise @exception end + rescue ::Protocol::HTTP2::StreamError => error + if error.code == ::Protocol::HTTP2::Error::INTERNAL_ERROR + raise ::Protocol::HTTP::RemoteError, error.message + end + + raise end # Called when the stream is closed. diff --git a/releases.md b/releases.md index fa1193c..852c910 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Retry safe requests when a remote HTTP/2 endpoint resets the stream with `INTERNAL_ERROR` before returning a response. + ## v0.98.1 - Probe idle HTTP/1 connections before reuse, avoiding requests on connections already closed by the peer. diff --git a/test/async/http/client/retry.rb b/test/async/http/client/retry.rb index 7111541..9e86da0 100644 --- a/test/async/http/client/retry.rb +++ b/test/async/http/client/retry.rb @@ -50,6 +50,27 @@ def make_response(request, connection, attempt) expect(client.chunks).to be == ["Hello", "Hello"] end + it "rewinds idempotent request bodies after remote failures" do + client = RetryClient.new([Protocol::HTTP::RemoteError.new("Remote endpoint failed.")]) + request = Protocol::HTTP::Request["PUT", "/", {}, ["Hello"]] + + response = client.call(request) + + expect(response).to be(:success?) + expect(client.chunks).to be == ["Hello", "Hello"] + end + + it "does not retry non-idempotent requests after remote failures" do + client = RetryClient.new([Protocol::HTTP::RemoteError.new("Remote endpoint failed.")]) + request = Protocol::HTTP::Request["POST", "/", {}, ["Hello"]] + + expect do + client.call(request) + end.to raise_exception(Protocol::HTTP::RemoteError) + + expect(client.chunks).to be == ["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"]] diff --git a/test/async/http/protocol/http2/connection_close_with_active_streams.rb b/test/async/http/protocol/http2/connection_close_with_active_streams.rb index a0d3c95..dcc24b9 100644 --- a/test/async/http/protocol/http2/connection_close_with_active_streams.rb +++ b/test/async/http/protocol/http2/connection_close_with_active_streams.rb @@ -49,6 +49,26 @@ end.wait end + it "maps a remote internal error while waiting for headers" do + Async do |task| + client_connection = Async::HTTP::Protocol::HTTP2::Client.new(client_stream) + client_connection.open! + + response = client_connection.create_response + response.stream.close!(Protocol::HTTP2::INTERNAL_ERROR) + + expect do + client_connection.read_response(response) + end.to raise_exception(Protocol::HTTP::RemoteError).and( + have_attributes( + cause: be_a(Protocol::HTTP2::StreamError).and( + have_attributes(code: be == Protocol::HTTP2::INTERNAL_ERROR) + ) + ) + ) + end.wait + end + it "does not raise error when connection closes without active streams" do Async do |task| # Create client connection