From 80df1588eeed78e6b3b0e7ef92b0b32cdaa3cd7b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 19 Jul 2026 18:33:11 +1200 Subject: [PATCH 1/7] Add request retry preparation --- lib/protocol/http/request.rb | 32 +++++++++++++------------- test/protocol/http/request.rb | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index c9593cf..e4507a6 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -132,25 +132,27 @@ def self.[](method, path = nil, _headers = nil, _body = nil, scheme: nil, author self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response) end - # Whether the request can be replayed without side-effects. + # Whether the request method is idempotent according to HTTP semantics. def idempotent? - # QUERY requests are idempotent, even if they have a body: - if @method == Methods::QUERY - return true - end - - # POST requests are not idempotent, even if they have no body: - if @method == Methods::POST - return false + case @method + when Methods::POST, Methods::PATCH, Methods::CONNECT + false + else + true end + end + + # Prepare the request body to be sent again, if it is safe to retry. + # @returns [Boolean] Whether the request was prepared for retry. + def retry! + return false unless self.idempotent? + return true unless body = @body + return true if body.empty? && !body.rewindable? + return false unless body.rewindable? - # All other requests are idempotent if they have no body: - if @body.nil? || @body.empty? - return true - end + return false unless body.rewind - # Otherwise, we don't know if the request is idempotent or not, so we assume it is not: - return false + return true end # Convert the request to a hash, suitable for serialization. diff --git a/test/protocol/http/request.rb b/test/protocol/http/request.rb index 9958c6b..4510efc 100644 --- a/test/protocol/http/request.rb +++ b/test/protocol/http/request.rb @@ -147,11 +147,53 @@ with "PUT request with a body" do let(:request) {subject["PUT", "/resource", body: "content"]} + it "should be idempotent" do + expect(request).to be(:idempotent?) + end + end + + with "PATCH request without a body" do + let(:request) {subject["PATCH", "/resource"]} + it "should not be idempotent" do expect(request).not.to be(:idempotent?) end end + with "#retry!" do + it "allows idempotent requests without a body" do + expect(request.retry!).to be == true + end + + with "idempotent request with a rewindable body" do + let(:request) {subject["PUT", "/resource", body: "content"]} + + it "rewinds the body" do + expect(request.body.read).to be == "content" + + expect(request.retry!).to be == true + expect(request.body.read).to be == "content" + end + end + + with "idempotent request with a non-rewindable body" do + let(:body) {Protocol::HTTP::Body::Readable.new} + let(:request) {subject.new(nil, nil, "PUT", "/resource", nil, headers, body)} + + it "does not allow retry" do + expect(request.retry!).to be == false + end + end + + with "non-idempotent request" do + let(:request) {subject["POST", "/submit"]} + + it "does not allow retry" do + expect(request.retry!).to be == false + end + end + end + it "should have a string representation" do expect(request.to_s).to be == "http://localhost: GET /index.html HTTP/1.0" end From 077b8d5a46199524ab68d55f036c72742250eae9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 19 Jul 2026 18:33:56 +1200 Subject: [PATCH 2/7] Clarify retry preparation logic --- lib/protocol/http/request.rb | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index e4507a6..0d59132 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -145,12 +145,27 @@ def idempotent? # Prepare the request body to be sent again, if it is safe to retry. # @returns [Boolean] Whether the request was prepared for retry. def retry! - return false unless self.idempotent? - return true unless body = @body - return true if body.empty? && !body.rewindable? - return false unless body.rewindable? + # Only idempotent requests can be retried safely. + if !self.idempotent? + return false + end - return false unless body.rewind + # Requests without a body can be sent again immediately. + if body = @body + # Empty, non-rewindable bodies have nothing left to send. + if body.empty? && !body.rewindable? + return true + end + + # Non-empty bodies must be rewindable so the same data can be sent again. + if !body.rewindable? + return false + end + + if !body.rewind + return false + end + end return true end From e0143dbabcade8d397d13524a64e8cbd36a985e6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 19 Jul 2026 18:51:35 +1200 Subject: [PATCH 3/7] Preserve idempotent compatibility --- lib/protocol/http/request.rb | 27 +++++++++++++++++++-------- test/protocol/http/request.rb | 18 +++++++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index 0d59132..325960b 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -132,21 +132,32 @@ def self.[](method, path = nil, _headers = nil, _body = nil, scheme: nil, author self.new(scheme, authority, method, path, nil, headers, body, protocol, interim_response) end - # Whether the request method is idempotent according to HTTP semantics. + # Whether the request can be replayed without side-effects. def idempotent? - case @method - when Methods::POST, Methods::PATCH, Methods::CONNECT - false - else - true + # QUERY requests are idempotent, even if they have a body: + if @method == Methods::QUERY + return true + end + + # POST requests are not idempotent, even if they have no body: + if @method == Methods::POST + return false end + + # All other requests are idempotent if they have no body: + if @body.nil? || @body.empty? + return true + end + + # Otherwise, we don't know if the request is idempotent or not, so we assume it is not: + return false end # Prepare the request body to be sent again, if it is safe to retry. # @returns [Boolean] Whether the request was prepared for retry. def retry! - # Only idempotent requests can be retried safely. - if !self.idempotent? + # Only idempotent request methods can be retried safely. + if @method == Methods::POST || @method == Methods::PATCH || @method == Methods::CONNECT return false end diff --git a/test/protocol/http/request.rb b/test/protocol/http/request.rb index 4510efc..de31388 100644 --- a/test/protocol/http/request.rb +++ b/test/protocol/http/request.rb @@ -147,16 +147,16 @@ with "PUT request with a body" do let(:request) {subject["PUT", "/resource", body: "content"]} - it "should be idempotent" do - expect(request).to be(:idempotent?) + it "should not be idempotent" do + expect(request).not.to be(:idempotent?) end end with "PATCH request without a body" do let(:request) {subject["PATCH", "/resource"]} - it "should not be idempotent" do - expect(request).not.to be(:idempotent?) + it "should be idempotent" do + expect(request).to be(:idempotent?) end end @@ -168,7 +168,7 @@ with "idempotent request with a rewindable body" do let(:request) {subject["PUT", "/resource", body: "content"]} - it "rewinds the body" do + it "allows retry and rewinds the body" do expect(request.body.read).to be == "content" expect(request.retry!).to be == true @@ -192,6 +192,14 @@ expect(request.retry!).to be == false end end + + with "PATCH request" do + let(:request) {subject["PATCH", "/resource"]} + + it "does not allow retry" do + expect(request.retry!).to be == false + end + end end it "should have a string representation" do From 0d7b6d51a5d09c19f62b1bbb313f45270827896a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 20:00:16 +1200 Subject: [PATCH 4/7] Add request body rewind preparation --- lib/protocol/http/request.rb | 22 ++++++++++++++-------- test/protocol/http/request.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index 325960b..c1303e3 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -153,14 +153,9 @@ def idempotent? return false end - # Prepare the request body to be sent again, if it is safe to retry. - # @returns [Boolean] Whether the request was prepared for retry. - def retry! - # Only idempotent request methods can be retried safely. - if @method == Methods::POST || @method == Methods::PATCH || @method == Methods::CONNECT - return false - end - + # Rewind the request body so it can be sent again. + # @returns [Boolean] Whether the request body was rewound. + def rewind! # Requests without a body can be sent again immediately. if body = @body # Empty, non-rewindable bodies have nothing left to send. @@ -181,6 +176,17 @@ def retry! return true end + # Prepare the request body to be sent again, if it is safe to retry. + # @returns [Boolean] Whether the request was prepared for retry. + def retry! + # Only idempotent request methods can be retried safely. + if @method == Methods::POST || @method == Methods::PATCH || @method == Methods::CONNECT + return false + end + + return self.rewind! + end + # Convert the request to a hash, suitable for serialization. # # @returns [Hash] The request as a hash. diff --git a/test/protocol/http/request.rb b/test/protocol/http/request.rb index de31388..8936196 100644 --- a/test/protocol/http/request.rb +++ b/test/protocol/http/request.rb @@ -160,6 +160,32 @@ end end + with "#rewind!" do + it "allows requests without a body" do + expect(request.rewind!).to be == true + end + + with "request with a rewindable body" do + let(:request) {subject["POST", "/submit", body: "content"]} + + it "rewinds the body" do + expect(request.body.read).to be == "content" + + expect(request.rewind!).to be == true + expect(request.body.read).to be == "content" + end + end + + with "request with a non-rewindable body" do + let(:body) {Protocol::HTTP::Body::Readable.new} + let(:request) {subject.new(nil, nil, "PUT", "/resource", nil, headers, body)} + + it "does not rewind" do + expect(request.rewind!).to be == false + end + end + end + with "#retry!" do it "allows idempotent requests without a body" do expect(request.retry!).to be == true From 4f01d5595f91dd489aa8e72c5b0347c183f5f323 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 20:38:23 +1200 Subject: [PATCH 5/7] Require rewindable retry bodies --- lib/protocol/http/request.rb | 7 +----- test/protocol/http/request.rb | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index c1303e3..c2d1166 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -158,12 +158,7 @@ def idempotent? def rewind! # Requests without a body can be sent again immediately. if body = @body - # Empty, non-rewindable bodies have nothing left to send. - if body.empty? && !body.rewindable? - return true - end - - # Non-empty bodies must be rewindable so the same data can be sent again. + # Bodies must be rewindable so the same data can be sent again. if !body.rewindable? return false end diff --git a/test/protocol/http/request.rb b/test/protocol/http/request.rb index 8936196..fdb9840 100644 --- a/test/protocol/http/request.rb +++ b/test/protocol/http/request.rb @@ -184,6 +184,51 @@ expect(request.rewind!).to be == false end end + + with "request with a consumed non-rewindable body" do + let(:body) do + Class.new(Protocol::HTTP::Body::Readable) do + def initialize + @chunk = "content" + end + + def read + chunk = @chunk + @chunk = nil + return chunk + end + + def empty? + @chunk.nil? + end + end.new + end + + let(:request) {subject.new(nil, nil, "PUT", "/resource", nil, headers, body)} + + it "does not treat empty as rewindable" do + expect(request.body.read).to be == "content" + expect(request.body).to be(:empty?) + + expect(request.rewind!).to be == false + end + end + + with "request with an empty non-rewindable body" do + let(:body) do + Class.new(Protocol::HTTP::Body::Readable) do + def empty? + true + end + end.new + end + + let(:request) {subject.new(nil, nil, "PUT", "/resource", nil, headers, body)} + + it "does not rewind" do + expect(request.rewind!).to be == false + end + end end with "#retry!" do From 3049fe6c724c29169b0f0255cec3a2f3cadbb6ff Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 22:30:53 +1200 Subject: [PATCH 6/7] Add retry preparation release note --- releases.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/releases.md b/releases.md index 12f1250..9aba739 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `Protocol::HTTP::Request#rewind!` and `#retry!` for preparing requests to be sent again. + ## v0.63.0 - Add support for the HTTP `QUERY` method. From 7c962f078e413eee5d3a431ccec8ff4b0d250c9c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 22:58:06 +1200 Subject: [PATCH 7/7] Simplify request rewind preparation --- lib/protocol/http/request.rb | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lib/protocol/http/request.rb b/lib/protocol/http/request.rb index c2d1166..f0d8dc1 100644 --- a/lib/protocol/http/request.rb +++ b/lib/protocol/http/request.rb @@ -156,18 +156,11 @@ def idempotent? # Rewind the request body so it can be sent again. # @returns [Boolean] Whether the request body was rewound. def rewind! - # Requests without a body can be sent again immediately. if body = @body - # Bodies must be rewindable so the same data can be sent again. - if !body.rewindable? - return false - end - - if !body.rewind - return false - end + return body.rewind end + # Requests without a body can be sent again immediately. return true end