Skip to content

Commit 3a34e82

Browse files
Better handling of refused requests.
1 parent 7a8850e commit 3a34e82

File tree

7 files changed

+58
-24
lines changed

7 files changed

+58
-24
lines changed

async-http.gemspec

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ Gem::Specification.new do |spec|
2929
spec.add_dependency "io-endpoint", "~> 0.14"
3030
spec.add_dependency "io-stream", "~> 0.6"
3131
spec.add_dependency "metrics", "~> 0.12"
32-
spec.add_dependency "protocol-http", "~> 0.58"
33-
spec.add_dependency "protocol-http1", "~> 0.36"
34-
spec.add_dependency "protocol-http2", "~> 0.22"
32+
spec.add_dependency "protocol-http", "~> 0.62"
33+
spec.add_dependency "protocol-http1", "~> 0.39"
34+
spec.add_dependency "protocol-http2", "~> 0.26"
3535
spec.add_dependency "protocol-url", "~> 0.2"
3636
spec.add_dependency "traces", "~> 0.10"
3737
end

lib/async/http/client.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ def call(request)
114114
# This signals that the ensure block below should not try to release the connection, because it's bound into the response which will be returned:
115115
connection = nil
116116
return response
117-
rescue Protocol::RequestFailed
118-
# This is a specific case where the entire request wasn't sent before a failure occurred. So, we can even resend non-idempotent requests.
117+
rescue ::Protocol::HTTP::RefusedError
118+
# This is a specific case where the request was not processed by the server. So, we can resend even non-idempotent requests.
119119
if connection
120120
@pool.release(connection)
121121
connection = nil

lib/async/http/protocol/http1/client.rb

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,17 @@ def call(request, task: Task.current)
3838
headers = request.headers.header
3939

4040
# We carefully interpret https://tools.ietf.org/html/rfc7230#section-6.3.1 to implement this correctly.
41-
begin
42-
target = request.path
43-
authority = request.authority
44-
45-
# If we are using a CONNECT request, we need to use the authority as the target:
46-
if request.connect?
47-
target = authority
48-
authority = nil
49-
end
50-
51-
write_request(authority, request.method, target, @version, headers)
52-
rescue
53-
# If we fail to fully write the request and body, we can retry this request.
54-
raise RequestFailed
41+
target = request.path
42+
authority = request.authority
43+
44+
# If we are using a CONNECT request, we need to use the authority as the target:
45+
if request.connect?
46+
target = authority
47+
authority = nil
5548
end
5649

50+
write_request(authority, request.method, target, @version, headers)
51+
5752
if request.body?
5853
body = request.body
5954

lib/async/http/protocol/http2/response.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def send_request(request)
261261
begin
262262
@stream.send_headers(headers)
263263
rescue
264-
raise RequestFailed
264+
raise ::Protocol::HTTP::RefusedError
265265
end
266266

267267
@stream.send_body(request.body, trailer)

lib/async/http/protocol/request.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@
1111
module Async
1212
module HTTP
1313
module Protocol
14-
# Failed to send the request. The request body has NOT been consumed (i.e. #read) and you should retry the request.
15-
class RequestFailed < StandardError
16-
end
17-
1814
# An incoming HTTP request generated by server protocol implementations.
1915
class Request < ::Protocol::HTTP::Request
2016
# @returns [Connection | Nil] The underlying protocol connection.

releases.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Use `Protocol::HTTP::RefusedError` for safe retry of requests not processed by the server, including non-idempotent methods like PUT.
6+
- Remove `Async::HTTP::Protocol::RequestFailed` in favour of `Protocol::HTTP::RefusedError`.
7+
- HTTP/1: Delegate request write failure handling to `protocol-http1`.
8+
- HTTP/2: Handle GOAWAY and REFUSED_STREAM via `protocol-http2`, enabling automatic retry of unprocessed requests.
9+
310
## v0.94.3
411

512
- Fix response body leak in HTTP/2 server when stream is reset before `send_response` completes (e.g. client-side gRPC cancellation). The response body's `close` was never called, leaking any resources tied to body lifecycle (such as `rack.response_finished` callbacks and utilization metrics).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "async/http/protocol/http2"
7+
require "sus/fixtures/async/http"
8+
9+
describe Async::HTTP::Protocol::HTTP2 do
10+
with "REFUSED_STREAM converts to RefusedError" do
11+
include Sus::Fixtures::Async::HTTP::ServerContext
12+
let(:protocol) {subject}
13+
14+
let(:request_count) {Async::Variable.new}
15+
16+
let(:app) do
17+
request_count = self.request_count
18+
count = 0
19+
20+
Protocol::HTTP::Middleware.for do |request|
21+
count += 1
22+
request_count.value = count
23+
24+
Protocol::HTTP::Response[200, {}, ["OK"]]
25+
end
26+
end
27+
28+
it "retries non-idempotent request" do
29+
response = client.put("/", {}, ["Hello"])
30+
expect(response).to be(:success?)
31+
32+
count = Async::Task.current.with_timeout(1.0){request_count.wait}
33+
expect(count).to be == 1
34+
end
35+
end
36+
end

0 commit comments

Comments
 (0)