diff --git a/CHANGELOG.md b/CHANGELOG.md index 954aa1ae..9c9a4ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 JVM could garbage-collect llhttp's native callback trampolines while a parser was still in use, after which `llhttp_execute` succeeded without invoking any callbacks. Callback procs are now retained for the lifetime of the parser. +- An IO request body is now rewound when writing it fails partway, not only + after it is sent in full. Previously a retried request (e.g. via + `.retriable` after a connection reset mid-upload) sent the body from where + the failed write stopped: fewer bytes than its `Content-Length`, so the + server waited for the rest until the read timed out. ## [6.0.4] - 2026-07-14 diff --git a/lib/http/request/body.rb b/lib/http/request/body.rb index 9d260ee0..a99897d2 100644 --- a/lib/http/request/body.rb +++ b/lib/http/request/body.rb @@ -90,8 +90,7 @@ def each(&block) if @source.is_a?(String) yield @source elsif @source.respond_to?(:read) - IO.copy_stream(@source, ProcIO.new(block)) - rewind(@source) + copy_io(block) elsif @source @source.each(&block) end @@ -112,6 +111,20 @@ def ==(other) private + # Stream an IO source to the block, then rewind it + # + # The rewind also runs when the block raises, so a retried request + # sends the whole body again instead of the unsent remainder. + # + # @param [Proc] block + # @return [void] + # @api private + def copy_io(block) + IO.copy_stream(@source, ProcIO.new(block)) + ensure + rewind(@source) + end + # Rewind an IO source if possible # @return [void] # @api private diff --git a/sig/http.rbs b/sig/http.rbs index 9b1c4a69..3e9af31d 100644 --- a/sig/http.rbs +++ b/sig/http.rbs @@ -906,6 +906,7 @@ module HTTP private + def copy_io: ((^(String) -> void)? block) -> void def rewind: (untyped io) -> void def validate_source_type!: () -> void diff --git a/test/http/request/body_test.rb b/test/http/request/body_test.rb index 8bacbca8..f22aa9db 100644 --- a/test/http/request/body_test.rb +++ b/test/http/request/body_test.rb @@ -156,6 +156,24 @@ def test_each_when_body_is_an_enumerable_io_allows_multiple_enumerations assert(results.all?(data)) end + def test_each_when_body_is_an_io_rewinds_it_when_a_chunk_fails_to_write + data = ("a" * 16 * 1024) + ("b" * 10 * 1024) + subject = build_body(StringIO.new(data)) + written = 0 + + assert_raises(Errno::EPIPE) do + subject.each do |chunk| + raise Errno::EPIPE if written.positive? + + written += chunk.bytesize + end + end + + chunks = subject.enum_for(:each).map(&:dup) + + assert_equal data, chunks.sum("") + end + def test_each_when_body_is_an_enumerable_yields_elements chunks = build_body(%w[bees cows]).enum_for(:each).map(&:dup)