async-http 0.101.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/context/choosing-a-client.md +164 -0
  4. data/context/concurrent-requests.md +134 -0
  5. data/context/getting-started.md +106 -69
  6. data/context/index.yaml +14 -3
  7. data/context/testing.md +173 -45
  8. data/lib/async/http/body/hijack.rb +1 -1
  9. data/lib/async/http/body/pipe.rb +2 -2
  10. data/lib/async/http/body.rb +1 -1
  11. data/lib/async/http/internet.rb +1 -1
  12. data/lib/async/http/middleware/location_redirector.rb +1 -1
  13. data/lib/async/http/mock/endpoint.rb +1 -1
  14. data/lib/async/http/protocol/configurable.rb +1 -1
  15. data/lib/async/http/protocol/http.rb +1 -1
  16. data/lib/async/http/protocol/http1/finishable.rb +2 -1
  17. data/lib/async/http/protocol/http1/request.rb +1 -1
  18. data/lib/async/http/protocol/http1/response.rb +1 -1
  19. data/lib/async/http/protocol/http1.rb +1 -1
  20. data/lib/async/http/protocol/http10.rb +1 -1
  21. data/lib/async/http/protocol/http11.rb +1 -1
  22. data/lib/async/http/protocol/http2/client.rb +7 -2
  23. data/lib/async/http/protocol/http2/connection.rb +20 -6
  24. data/lib/async/http/protocol/http2/input.rb +14 -3
  25. data/lib/async/http/protocol/http2/output.rb +23 -8
  26. data/lib/async/http/protocol/http2/response.rb +15 -2
  27. data/lib/async/http/protocol/http2/server.rb +1 -1
  28. data/lib/async/http/protocol/http2/stream.rb +61 -4
  29. data/lib/async/http/protocol/http2.rb +1 -1
  30. data/lib/async/http/protocol/request.rb +1 -1
  31. data/lib/async/http/protocol/response.rb +1 -1
  32. data/lib/async/http/proxy.rb +1 -1
  33. data/lib/async/http/statistics.rb +1 -1
  34. data/lib/async/http/version.rb +1 -1
  35. data/lib/async/http.rb +1 -1
  36. data/lib/traces/provider/async/http/client.rb +1 -1
  37. data/lib/traces/provider/async/http/protocol/http1/client.rb +1 -1
  38. data/lib/traces/provider/async/http/protocol/http2/client.rb +1 -1
  39. data/lib/traces/provider/async/http/server.rb +1 -1
  40. data/license.md +2 -1
  41. data/readme.md +18 -13
  42. data/releases.md +5 -0
  43. data.tar.gz.sig +0 -0
  44. metadata +11 -6
  45. metadata.gz.sig +0 -0
data/context/testing.md CHANGED
@@ -1,77 +1,205 @@
1
1
  # Testing
2
2
 
3
- This guide explains how to use `Async::HTTP` clients and servers in your tests.
3
+ This guide explains how to test `Async::HTTP` clients and servers without depending on external HTTP services.
4
4
 
5
- In general, you should avoid making real HTTP requests in your tests. Instead, you should use a mock server or a fake client.
5
+ Real network services make tests slower and less deterministic. Prefer one of these approaches:
6
6
 
7
- ## Mocking HTTP Responses
7
+ - Use [`sus-fixtures-protocol-http`](https://socketry.github.io/sus-fixtures-protocol-http/guides/getting-started/) to exercise HTTP middleware directly without a client or server.
8
+ - Use `sus-fixtures-async-http` to run an application with a managed local server and client.
9
+ - Use ruby:`Async::HTTP::Mock::Endpoint` when testing a client that expects to connect to a particular remote endpoint.
8
10
 
9
- The mocking feature of `Async::HTTP` uses a real server running in a separate task, and routes all requests to it. This allows you to intercept requests and return custom responses, but still use the real HTTP client.
11
+ ## Testing Middleware Directly
10
12
 
11
- In order to enable this feature, you must create an instance of {ruby Async::HTTP::Mock::Endpoint} which will handle the requests.
13
+ When a test only needs to construct requests and inspect responses, `sus-fixtures-protocol-http` can call `Protocol::HTTP` middleware in-process without starting a client or server. Add the fixture to your test dependencies:
14
+
15
+ ~~~ bash
16
+ $ bundle add sus --group test
17
+ $ bundle add sus-fixtures-protocol-http --group test
18
+ ~~~
19
+
20
+ Include `MiddlewareContext` and provide the middleware under test:
12
21
 
13
22
  ~~~ ruby
14
- require 'async/http'
15
- require 'async/http/mock'
23
+ require "sus/fixtures/protocol/http/middleware_context"
24
+
25
+ describe "My HTTP application" do
26
+ include Sus::Fixtures::Protocol::HTTP::MiddlewareContext
27
+
28
+ let(:middleware) do
29
+ Protocol::HTTP::Middleware.for do |request|
30
+ Protocol::HTTP::Response[200, {}, ["Hello #{request.path}"]]
31
+ end
32
+ end
33
+
34
+ it "handles a request directly" do
35
+ response = client.get("/world")
36
+
37
+ expect(response.status).to be == 200
38
+ expect(response.read).to be == "Hello /world"
39
+ end
40
+ end
41
+ ~~~
42
+
43
+ The fixture closes the final request, response, and middleware after each test. Use `sus-fixtures-async-http` when the test needs a real client/server exchange.
16
44
 
17
- mock_endpoint = Async::HTTP::Mock::Endpoint.new
45
+ ## Testing with a Client and Server
18
46
 
19
- Sync do
20
- # Start a background server:
21
- server_task = Async(transient: true) do
22
- mock_endpoint.run do |request|
23
- # Respond to the request:
24
- ::Protocol::HTTP::Response[200, {}, ["Hello, World"]]
47
+ The `ServerContext` fixture manages an ephemeral listening endpoint, server task, and connected client. Add the fixture to your test dependencies:
48
+
49
+ ~~~ bash
50
+ $ bundle add sus --group test
51
+ $ bundle add sus-fixtures-async-http --group test
52
+ ~~~
53
+
54
+ Define the application under test and make requests through the provided `client`:
55
+
56
+ ~~~ ruby
57
+ require "sus/fixtures/async/http"
58
+
59
+ describe "My HTTP application" do
60
+ include Sus::Fixtures::Async::HTTP::ServerContext
61
+
62
+ let(:app) do
63
+ Protocol::HTTP::Middleware.for do |request|
64
+ case request.path
65
+ when "/health"
66
+ Protocol::HTTP::Response[
67
+ 200,
68
+ {"content-type" => "application/json"},
69
+ ['{"status":"ok"}'],
70
+ ]
71
+ else
72
+ Protocol::HTTP::Response[404, {}, ["Not Found"]]
73
+ end
25
74
  end
26
75
  end
27
76
 
28
- endpoint = Async::HTTP::Endpoint.parse("https://www.google.com")
29
- mocked_endpoint = mock_endpoint.wrap(endpoint)
30
- client = Async::HTTP::Client.new(mocked_endpoint)
77
+ it "serves the health endpoint" do
78
+ response = client.get("/health")
79
+
80
+ expect(response).to be(:success?)
81
+ expect(response.headers["content-type"]).to be == "application/json"
82
+ expect(response.read).to be == '{"status":"ok"}'
83
+ ensure
84
+ response&.close
85
+ end
31
86
 
32
- response = client.get("/")
33
- puts response.read
34
- # => "Hello, World"
87
+ it "returns not found for unknown paths" do
88
+ response = client.get("/missing")
89
+ expect(response.status).to be == 404
90
+ ensure
91
+ response&.close
92
+ end
35
93
  end
36
94
  ~~~
37
95
 
38
- ## Transparent Mocking
96
+ The fixture closes the client, stops the server, and releases the bound endpoint after each test. Override `app`, `url`, `protocol`, `endpoint_options`, or `retries` to configure a scenario.
39
97
 
40
- Using your test framework's mocking capabilities, you can easily replace the `Async::HTTP::Client#new` with a method that returns a client with a mocked endpoint.
98
+ ### Testing HTTP/2
41
99
 
42
- ### Sus Integration
100
+ Override `protocol` when behavior must be verified with a specific HTTP version:
43
101
 
44
102
  ~~~ ruby
45
- require 'async/http'
46
- require 'async/http/mock'
47
- require 'sus/fixtures/async/reactor_context'
103
+ describe "My HTTP/2 application" do
104
+ include Sus::Fixtures::Async::HTTP::ServerContext
105
+
106
+ let(:protocol) {Async::HTTP::Protocol::HTTP2}
107
+
108
+ it "responds using HTTP/2" do
109
+ response = client.get("/")
110
+ expect(response.version).to be == "HTTP/2"
111
+ ensure
112
+ response&.close
113
+ end
114
+ end
115
+ ~~~
116
+
117
+ Test normal behavior without forcing a protocol unless the distinction is relevant to the feature under test.
48
118
 
49
- include Sus::Fixtures::Async::ReactorContext
119
+ ## Testing a Client with a Mock Endpoint
50
120
 
51
- let(:mock_endpoint) {Async::HTTP::Mock::Endpoint.new}
121
+ ruby:`Async::HTTP::Mock::Endpoint` connects the real client and server protocol implementations through a local socket pair. It does not open a network port, but requests still exercise serialization, connection handling, and response bodies.
122
+
123
+ Use ruby:`Async::HTTP::Mock::Endpoint#wrap` to preserve the scheme and authority expected by the client:
124
+
125
+ ~~~ ruby
126
+ require "async/http"
127
+ require "async/http/mock"
128
+ require "sus/fixtures/async/reactor_context"
52
129
 
53
- def before
54
- super
130
+ describe "A remote service client" do
131
+ include Sus::Fixtures::Async::ReactorContext
55
132
 
56
- # Mock the HTTP client:
57
- mock(Async::HTTP::Client) do |mock|
58
- mock.wrap(:new) do |original, endpoint|
59
- original.call(mock_endpoint.wrap(endpoint))
133
+ it "handles a successful response" do
134
+ mock_endpoint = Async::HTTP::Mock::Endpoint.new
135
+ server_task = Async do
136
+ mock_endpoint.run do |request|
137
+ Protocol::HTTP::Response[200, {}, ["Authority: #{request.authority}"]]
138
+ end
60
139
  end
140
+
141
+ remote_endpoint = Async::HTTP::Endpoint.parse("https://api.example.com")
142
+ client = Async::HTTP::Client.new(mock_endpoint.wrap(remote_endpoint))
143
+ response = client.get("/status")
144
+
145
+ expect(response.read).to be == "Authority: api.example.com"
146
+ ensure
147
+ response&.close
148
+ client&.close
149
+ server_task&.stop
61
150
  end
151
+ end
152
+ ~~~
153
+
154
+ Return different statuses, headers, bodies, delays, or malformed behavior from the mock server to exercise client error handling.
155
+
156
+ ## Transparently Replacing Client Endpoints
157
+
158
+ Some applications construct ruby:`Async::HTTP::Client` internally. A test can wrap the constructor so those clients connect to a mock endpoint while retaining the original endpoint metadata and client options:
159
+
160
+ ~~~ ruby
161
+ require "async/http"
162
+ require "async/http/mock"
163
+ require "sus/fixtures/async/reactor_context"
164
+
165
+ describe "A client created by application code" do
166
+ include Sus::Fixtures::Async::ReactorContext
167
+
168
+ let(:mock_endpoint) {Async::HTTP::Mock::Endpoint.new}
62
169
 
63
- # Run the mock server:
64
- Async(transient: true) do
65
- mock_endpoint.run do |request|
66
- ::Protocol::HTTP::Response[200, {}, ["Hello, World"]]
170
+ def before
171
+ super
172
+
173
+ replacement_endpoint = mock_endpoint
174
+ mock(Async::HTTP::Client) do |wrapper|
175
+ wrapper.wrap(:new) do |original, endpoint, **options|
176
+ original.call(replacement_endpoint.wrap(endpoint), **options)
177
+ end
178
+ end
179
+
180
+ @server_task = Async do
181
+ mock_endpoint.run do |request|
182
+ Protocol::HTTP::Response[200, {}, ["Hello, World"]]
183
+ end
67
184
  end
68
185
  end
69
- end
70
-
71
- it "should perform a web request" do
72
- client = Async::HTTP::Client.new(Async::HTTP::Endpoint.parse("https://www.google.com"))
73
- response = client.get("/")
74
- # The response is mocked:
75
- expect(response.read).to be == "Hello, World"
186
+
187
+ def after(error = nil)
188
+ @server_task&.stop
189
+ super
190
+ end
191
+
192
+ it "routes the request through the mock endpoint" do
193
+ endpoint = Async::HTTP::Endpoint.parse("https://api.example.com")
194
+ client = Async::HTTP::Client.new(endpoint, retries: 1)
195
+ response = client.get("/")
196
+
197
+ expect(response.read).to be == "Hello, World"
198
+ ensure
199
+ response&.close
200
+ client&.close
201
+ end
76
202
  end
77
203
  ~~~
204
+
205
+ Always accept and forward `**options` when wrapping the constructor so the test does not silently change client configuration.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http/body/readable"
7
7
  require "protocol/http/body/stream"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
  # Copyright, 2020, by Bruno Sutic.
6
6
 
7
7
  require_relative "writable"
@@ -63,7 +63,7 @@ module Async
63
63
  end
64
64
 
65
65
  # Read from the head of the pipe and write to the @output stream.
66
- # If the @tail is closed, this will cause chunk to be nil, which in turn will call `@output.close` and `@head.close`
66
+ # A write-side close on @tail produces EOF and closes @output independently of the input direction.
67
67
  def writer(task)
68
68
  @writer = task
69
69
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http/body/buffered"
7
7
  require_relative "body/writable"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
  # Copyright, 2024, by Igor Sidorov.
6
6
 
7
7
  require_relative "client"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024-2025, by Samuel Williams.
4
+ # Copyright, 2024-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http/middleware"
7
7
  require "protocol/http/body/rewindable"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../protocol"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module HTTP
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2024, by Thomas Morgan.
5
- # Copyright, 2024-2025, by Samuel Williams.
5
+ # Copyright, 2024-2026, by Samuel Williams.
6
6
 
7
7
  require_relative "defaulton"
8
8
 
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2026, by Samuel Williams.
5
+ # Copyright, 2026, by Jun Jiang.
5
6
 
6
7
  require "protocol/http/body/wrapper"
7
8
  require "async/promise"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../request"
7
7
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2024, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
  # Copyright, 2023, by Josh Huber.
6
6
 
7
7
  require_relative "../response"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2025, by Samuel Williams.
4
+ # Copyright, 2017-2026, by Samuel Williams.
5
5
  # Copyright, 2024, by Thomas Morgan.
6
6
 
7
7
  require_relative "configurable"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2025, by Samuel Williams.
4
+ # Copyright, 2017-2026, by Samuel Williams.
5
5
  # Copyright, 2024, by Thomas Morgan.
6
6
 
7
7
  require_relative "http1"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2017-2025, by Samuel Williams.
4
+ # Copyright, 2017-2026, by Samuel Williams.
5
5
  # Copyright, 2018, by Janko Marohnić.
6
6
  # Copyright, 2024, by Thomas Morgan.
7
7
 
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
+ # Copyright, 2026, by Denis Talakevich.
5
6
 
6
7
  require_relative "connection"
7
8
  require_relative "response"
@@ -34,7 +35,11 @@ module Async
34
35
 
35
36
  # Used by the client to send requests to the remote server.
36
37
  def call(request)
37
- raise ::Protocol::HTTP2::Error, "Connection closed!" if self.closed?
38
+ # The remote peer has sent a GOAWAY frame, so it will not process any new streams on this connection. The request has not been sent yet, so it is safe to retry it on a new connection, even if it is not idempotent. This is checked first, because a connection with no streams left to drain is closed by the GOAWAY itself.
39
+ raise ::Protocol::HTTP::RefusedError, "Connection is going away!" if self.goaway_received?
40
+
41
+ # The connection can close after being acquired from the pool. No stream has been created yet, so the request has not been written and is safe to retry.
42
+ raise ::Protocol::HTTP::RefusedError, "Connection closed!" if self.closed?
38
43
 
39
44
  response = create_response
40
45
  write_request(response, request)
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2018-2026, by Samuel Williams.
5
5
  # Copyright, 2020, by Bruno Sutic.
6
6
  # Copyright, 2025, by Jean Boussier.
7
+ # Copyright, 2026, by Denis Talakevich.
7
8
 
8
9
  require_relative "stream"
9
10
 
@@ -88,16 +89,27 @@ module Async
88
89
 
89
90
  # Close the connection and stop the background reader.
90
91
  def close(error = nil)
91
- # Ensure the reader task is stopped.
92
- if @reader
93
- reader = @reader
92
+ if reader = @reader
94
93
  @reader = nil
95
- reader.stop
94
+
95
+ # The reader task can close the connection itself, e.g. when the last stream completes and the connection is released back to the pool. Stopping it here would cancel the current task in the middle of this method, leaving the underlying stream open, so we let it unwind by itself: `closed?` is now true, so the read loop exits.
96
+ reader.stop unless reader.current?
96
97
  end
97
98
 
98
99
  super
99
100
  end
100
101
 
102
+ # The connection has finished draining the streams which the remote peer accepted before its graceful GOAWAY.
103
+ #
104
+ # The last of those streams can complete on the sending side, in a task other than the background reader - the response arrived first and the request body was still being written. The reader is then parked in a blocking read and will never notice that the connection is closed, so we stop it and let its `ensure` close the connection.
105
+ def close_if_drained!
106
+ super
107
+
108
+ if self.closed? and (reader = @reader) and !reader.current?
109
+ reader.stop
110
+ end
111
+ end
112
+
101
113
  # Start a transient background task that reads frames from the connection.
102
114
  def read_in_background(parent: Task.current)
103
115
  raise RuntimeError, "Connection is closed!" if closed?
@@ -142,12 +154,14 @@ module Async
142
154
 
143
155
  # Can we use this connection to make requests?
144
156
  def viable?
145
- @stream&.readable?
157
+ !self.goaway_received? && @stream&.readable?
146
158
  end
147
159
 
148
160
  # @returns [Boolean] Whether the connection can be reused.
161
+ #
162
+ # Once the remote peer has sent a GOAWAY frame, it will not process any new streams on this connection, so it must not be handed out for another request, even while the streams it accepted are still being drained.
149
163
  def reusable?
150
- !self.closed?
164
+ !self.closed? && !self.goaway_received?
151
165
  end
152
166
 
153
167
  # @returns [String] The HTTP version string.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2024, by Samuel Williams.
4
+ # Copyright, 2020-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http/body/writable"
7
7
 
@@ -25,8 +25,8 @@ module Async
25
25
  # @returns [String | Nil] The next chunk, or `nil` if the body is complete.
26
26
  def read
27
27
  if chunk = super
28
- # If we read a chunk fron the stream, we want to extend the window if required so more data will be provided.
29
- @stream.request_window_update
28
+ # If we read a chunk from the stream, we want to extend the window if required so more data will be provided.
29
+ @stream&.request_window_update
30
30
  end
31
31
 
32
32
  # We track the expected length and check we got what we were expecting.
@@ -42,6 +42,17 @@ module Async
42
42
 
43
43
  return chunk
44
44
  end
45
+
46
+ # Close the application-facing input body and notify the stream that incoming data is no longer being consumed. While local output is active, the HTTP/2 stream remains open. Once output also closes, the remaining wire stream is terminated without an error.
47
+ # @parameter error [Exception | Nil] The error that caused the input to be closed, if any.
48
+ def close(error = nil)
49
+ super
50
+
51
+ if stream = @stream
52
+ @stream = nil
53
+ stream.finish_input(self, error)
54
+ end
55
+ end
45
56
  end
46
57
  end
47
58
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2024, by Samuel Williams.
4
+ # Copyright, 2020-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http/body/stream"
7
7
 
@@ -54,22 +54,24 @@ module Async
54
54
  # @parameter chunk [String] The data to write.
55
55
  def write(chunk)
56
56
  until chunk.empty?
57
- maximum_size = @stream.available_frame_size
57
+ stream = @stream or raise IOError, "HTTP/2 stream is closed!"
58
+ maximum_size = stream.available_frame_size
58
59
 
59
60
  # We try to avoid synchronization if possible:
60
61
  if maximum_size <= 0
61
62
  @guard.synchronize do
62
- maximum_size = @stream.available_frame_size
63
+ maximum_size = stream.available_frame_size
63
64
 
64
65
  while maximum_size <= 0
65
66
  @window_updated.wait(@guard)
66
67
 
67
- maximum_size = @stream.available_frame_size
68
+ stream = @stream or raise IOError, "HTTP/2 stream is closed!"
69
+ maximum_size = stream.available_frame_size
68
70
  end
69
71
  end
70
72
  end
71
73
 
72
- break unless chunk = send_data(chunk, maximum_size)
74
+ break unless chunk = send_data(stream, chunk, maximum_size)
73
75
  end
74
76
  end
75
77
 
@@ -96,6 +98,19 @@ module Async
96
98
  end
97
99
  end
98
100
 
101
+ # Close the wire output without cancelling a streamable body. This allows bidirectional bodies to observe an orderly input closure and finish normally. A non-streaming producer has no input side through which closure can propagate, so it is stopped directly.
102
+ def close_stream
103
+ if @body.stream?
104
+ @stream = nil
105
+
106
+ @guard.synchronize do
107
+ @window_updated.broadcast
108
+ end
109
+ else
110
+ stop(nil)
111
+ end
112
+ end
113
+
99
114
  private
100
115
 
101
116
  def stream(task)
@@ -137,11 +152,11 @@ module Async
137
152
  # @param maximum_size [Integer] send up to this many bytes of data.
138
153
  # @param stream [Stream] the stream to use for sending data frames.
139
154
  # @return [String, nil] any data that could not be written.
140
- def send_data(chunk, maximum_size)
155
+ def send_data(stream, chunk, maximum_size)
141
156
  if chunk.bytesize <= maximum_size
142
- @stream.send_data(chunk, maximum_size: maximum_size)
157
+ stream.send_data(chunk, maximum_size: maximum_size)
143
158
  else
144
- @stream.send_data(chunk.byteslice(0, maximum_size), maximum_size: maximum_size)
159
+ stream.send_data(chunk.byteslice(0, maximum_size), maximum_size: maximum_size)
145
160
 
146
161
  # The window was not big enough to send all the data, so we save it for next time:
147
162
  return chunk.byteslice(maximum_size, chunk.bytesize - maximum_size)
@@ -30,11 +30,13 @@ module Async
30
30
  # Wait for the response headers and return the response body.
31
31
  # @returns [Protocol::HTTP::Body::Readable | Nil] The response body.
32
32
  def wait_for_input
33
+ response = @response
34
+
33
35
  # The input isn't ready until the response headers have been received:
34
- @response.wait
36
+ response.wait
35
37
 
36
38
  # There is a possible race condition if you try to access @input - it might already be closed and nil.
37
- return @response.body
39
+ return response.body
38
40
  end
39
41
 
40
42
  # Handle a push promise stream from the server.
@@ -169,6 +171,17 @@ module Async
169
171
  @stream.wait
170
172
  end
171
173
 
174
+ # Close this response as quickly as possible. If the response body is still active, cancel the HTTP/2 exchange rather than draining it.
175
+ # @parameter error [Exception | Nil] The error which closed the response.
176
+ def close(error = nil)
177
+ if @body && !@stream.closed?
178
+ code = error ? ::Protocol::HTTP2::Error::INTERNAL_ERROR : ::Protocol::HTTP2::Error::CANCEL
179
+ @stream.send_reset_stream(code)
180
+ end
181
+
182
+ super
183
+ end
184
+
172
185
  # @returns [Boolean] Whether the original request was a HEAD request.
173
186
  def head?
174
187
  @request&.head?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2018-2025, by Samuel Williams.
4
+ # Copyright, 2018-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "connection"
7
7
  require_relative "request"