Skip to content

Commit c1a2fd6

Browse files
Improved handling of rack.response_finished.
1 parent ef7bf49 commit c1a2fd6

File tree

6 files changed

+215
-25
lines changed

6 files changed

+215
-25
lines changed

lib/protocol/rack/adapter/generic.rb

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,38 @@ def make_environment(request)
135135
}
136136
end
137137

138+
# Handle errors that occur during request processing. Logs the error, closes any response body, invokes `rack.response_finished` callbacks, and returns an appropriate failure response.
139+
#
140+
# The `rack.response_finished` callbacks are invoked in reverse order of registration, as specified by the Rack specification. If a callback raises an exception, it is caught and logged, but does not prevent other callbacks from being invoked.
141+
#
142+
# @parameter env [Hash] The Rack environment hash.
143+
# @parameter status [Integer | Nil] The HTTP status code, if available. May be `nil` if the error occurred before the application returned a response.
144+
# @parameter headers [Hash | Nil] The response headers, if available. May be `nil` if the error occurred before the application returned a response.
145+
# @parameter body [Object | Nil] The response body, if available. May be `nil` if the error occurred before the application returned a response.
146+
# @parameter error [Exception] The exception that occurred during request processing.
147+
# @returns [Protocol::HTTP::Response] A failure response representing the error.
148+
def handle_error(env, status, headers, body, error)
149+
Console.error(self, "Error occurred during request processing:", error)
150+
151+
# Close the response body if it exists and supports closing.
152+
body&.close if body.respond_to?(:close)
153+
154+
# Invoke `rack.response_finished` callbacks in reverse order of registration.
155+
# This ensures that callbacks registered later are invoked first, matching the Rack specification.
156+
env&.[](RACK_RESPONSE_FINISHED)&.reverse_each do |callback|
157+
begin
158+
callback.call(env, status, headers, error)
159+
rescue => callback_error
160+
# If a callback raises an exception, log it but continue invoking other callbacks.
161+
# The Rack specification states that callbacks should not raise exceptions, but we handle
162+
# this gracefully to prevent one misbehaving callback from breaking others.
163+
Console.error(self, "Error occurred during response finished callback:", callback_error)
164+
end
165+
end
166+
167+
return failure_response(error)
168+
end
169+
138170
# Build a rack `env` from the incoming request and apply it to the rack middleware.
139171
#
140172
# @parameter request [Protocol::HTTP::Request] The incoming request.
@@ -158,16 +190,8 @@ def call(request)
158190
headers, meta = self.wrap_headers(headers)
159191

160192
return Response.wrap(env, status, headers, meta, body, request)
161-
rescue => exception
162-
Console.error(self, exception)
163-
164-
body&.close if body.respond_to?(:close)
165-
166-
env&.[](RACK_RESPONSE_FINISHED)&.each do |callback|
167-
callback.call(env, status, headers, exception)
168-
end
169-
170-
return failure_response(exception)
193+
rescue => error
194+
return self.handle_error(env, status, headers, body, error)
171195
end
172196

173197
# Generate a suitable response for the given exception.

lib/protocol/rack/adapter/rack2.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,17 +111,8 @@ def call(request)
111111
# end
112112

113113
return Response.wrap(env, status, headers, meta, body, request)
114-
rescue => exception
115-
Console.error(self, exception)
116-
117-
body&.close if body.respond_to?(:close)
118-
119-
# Rack 2 does not include `rack.response_finished` in the specification. However, if the application has set it, we will call the callbacks here as it would be extremely surprising to not do so.
120-
env&.[](RACK_RESPONSE_FINISHED)&.each do |callback|
121-
callback.call(env, status, headers, exception)
122-
end
123-
124-
return failure_response(exception)
114+
rescue => error
115+
return self.handle_error(env, status, headers, body, error)
125116
end
126117

127118
# Process the rack response headers into a {Protocol::HTTP::Headers} instance, along with any extra `rack.` metadata.

lib/protocol/rack/body.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require_relative "body/enumerable"
88
require_relative "constants"
99

10+
require "console"
1011
require "protocol/http/body/completable"
1112
require "protocol/http/body/head"
1213

@@ -103,8 +104,10 @@ def self.wrap(env, status, headers, body, input = nil, head = false)
103104
return body
104105
end
105106

106-
# Create a completion callback for response finished handlers.
107-
# The callback is called with any error that occurred during response processing.
107+
# Create a completion callback for response finished handlers. The callback is called with any error that occurred during response processing.
108+
#
109+
# Callbacks are invoked in reverse order of registration, as specified by the Rack specification.
110+
# If a callback raises an exception, it is caught and logged, but does not prevent other callbacks from being invoked.
108111
#
109112
# @parameter response_finished [Array] Array of response finished callbacks.
110113
# @parameter env [Hash] The Rack environment.
@@ -113,8 +116,16 @@ def self.wrap(env, status, headers, body, input = nil, head = false)
113116
# @returns [Proc] A callback that calls all response finished handlers.
114117
def self.completion_callback(response_finished, env, status, headers)
115118
proc do |error|
116-
response_finished.each do |callback|
117-
callback.call(env, status, headers, error)
119+
# Invoke callbacks in reverse order of registration, as specified by the Rack specification.
120+
response_finished.reverse_each do |callback|
121+
begin
122+
callback.call(env, status, headers, error)
123+
rescue => callback_error
124+
# If a callback raises an exception, log it but continue invoking other callbacks.
125+
# The Rack specification states that callbacks should not raise exceptions, but we handle
126+
# this gracefully to prevent one misbehaving callback from breaking others.
127+
Console.error(self, "Error occurred during response finished callback:", callback_error)
128+
end
118129
end
119130
end
120131
end

releases.md

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

3+
## Unreleased
4+
5+
- Correctly invoke `rack.response_finished` in reverse order.
6+
- Tolerate errors during `rack.response_finished` callbacks.
7+
38
## v0.17.0
49

510
- Support `rack.response_finished` in Rack 2 if it's present in the environment.

test/protocol/rack/adapter/generic.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,71 @@
144144
expect(response.status).to be == 500
145145
expect(response.read).to be == "StandardError: Test error"
146146
end
147+
148+
it "invokes callbacks in reverse order of registration on error" do
149+
call_order = []
150+
151+
callback1 = proc do |env, status, headers, error|
152+
call_order << 1
153+
end
154+
155+
callback2 = proc do |env, status, headers, error|
156+
call_order << 2
157+
end
158+
159+
callback3 = proc do |env, status, headers, error|
160+
call_order << 3
161+
end
162+
163+
app_with_callbacks = proc do |env|
164+
env[Protocol::Rack::RACK_RESPONSE_FINISHED] = [callback1, callback2, callback3]
165+
raise StandardError.new("Test error")
166+
end
167+
168+
adapter_with_callbacks = subject.new(app_with_callbacks)
169+
response = adapter_with_callbacks.call(request)
170+
171+
# Callbacks should be invoked in reverse order: 3, 2, 1
172+
expect(call_order).to be == [3, 2, 1]
173+
expect(response.status).to be == 500
174+
end
175+
176+
it "handles errors from callbacks gracefully and continues invoking other callbacks" do
177+
call_order = []
178+
179+
callback1 = proc do |env, status, headers, error|
180+
call_order << 1
181+
end
182+
183+
callback2 = proc do |env, status, headers, error|
184+
call_order << 2
185+
raise StandardError.new("Callback error")
186+
end
187+
188+
callback3 = proc do |env, status, headers, error|
189+
call_order << 3
190+
end
191+
192+
app_with_callbacks = proc do |env|
193+
env[Protocol::Rack::RACK_RESPONSE_FINISHED] = [callback1, callback2, callback3]
194+
raise StandardError.new("Test error")
195+
end
196+
197+
adapter_with_callbacks = subject.new(app_with_callbacks)
198+
199+
response = adapter_with_callbacks.call(request)
200+
201+
# All callbacks should be invoked despite callback2 raising an error:
202+
# Callbacks should be invoked in reverse order: 3, 2, 1
203+
expect(call_order).to be == [3, 2, 1]
204+
expect(response.status).to be == 500
205+
206+
# Verify that the error was logged:
207+
expect_console.to have_logged(
208+
severity: be == :error,
209+
subject: be == adapter_with_callbacks,
210+
message: be =~ /Error occurred during response finished callback:/
211+
)
212+
end
147213
end
148214
end

test/protocol/rack/body.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
# Released under the MIT License.
44
# Copyright, 2025, by Samuel Williams.
55

6+
require "sus/fixtures/console"
67
require "protocol/rack/body"
78
require "protocol/http/body/readable"
89
require "console"
910

1011
describe Protocol::Rack::Body do
12+
include Sus::Fixtures::Console::CapturedLogger
1113
with "#no_content?" do
1214
it "returns true for status codes that indicate no content" do
1315
expect(subject.no_content?(204)).to be == true
@@ -155,6 +157,97 @@ def body.each
155157
expect(body).to be_nil
156158
expect(called).to be == true
157159
end
160+
161+
it "invokes callbacks in reverse order of registration" do
162+
call_order = []
163+
164+
callback1 = proc do |env, status, headers, error|
165+
call_order << 1
166+
end
167+
168+
callback2 = proc do |env, status, headers, error|
169+
call_order << 2
170+
end
171+
172+
callback3 = proc do |env, status, headers, error|
173+
call_order << 3
174+
end
175+
176+
env[Protocol::Rack::RACK_RESPONSE_FINISHED] = [callback1, callback2, callback3]
177+
178+
body = subject.wrap(env, 200, headers, ["body"])
179+
body.close
180+
181+
# Callbacks should be invoked in reverse order: 3, 2, 1
182+
expect(call_order).to be == [3, 2, 1]
183+
end
184+
185+
it "handles errors from callbacks gracefully and continues invoking other callbacks" do
186+
call_order = []
187+
188+
callback1 = proc do |env, status, headers, error|
189+
call_order << 1
190+
end
191+
192+
callback2 = proc do |env, status, headers, error|
193+
call_order << 2
194+
raise StandardError.new("Callback error")
195+
end
196+
197+
callback3 = proc do |env, status, headers, error|
198+
call_order << 3
199+
end
200+
201+
env[Protocol::Rack::RACK_RESPONSE_FINISHED] = [callback1, callback2, callback3]
202+
203+
body = subject.wrap(env, 200, headers, ["body"])
204+
body.close
205+
206+
# All callbacks should be invoked despite callback2 raising an error
207+
# Callbacks should be invoked in reverse order: 3, 2, 1
208+
expect(call_order).to be == [3, 2, 1]
209+
210+
# Verify that the error was logged
211+
expect_console.to have_logged(
212+
severity: be == :error,
213+
subject: be == Protocol::Rack::Body,
214+
message: be =~ /Error occurred during response finished callback:/
215+
)
216+
end
217+
218+
it "handles errors from callbacks when body is empty" do
219+
call_order = []
220+
221+
callback1 = proc do |env, status, headers, error|
222+
call_order << 1
223+
end
224+
225+
callback2 = proc do |env, status, headers, error|
226+
call_order << 2
227+
raise StandardError.new("Callback error")
228+
end
229+
230+
callback3 = proc do |env, status, headers, error|
231+
call_order << 3
232+
end
233+
234+
env[Protocol::Rack::RACK_RESPONSE_FINISHED] = [callback1, callback2, callback3]
235+
236+
body = subject.wrap(env, 204, headers, [])
237+
238+
expect(body).to be_nil
239+
240+
# All callbacks should be invoked despite callback2 raising an error
241+
# Callbacks should be invoked in reverse order: 3, 2, 1
242+
expect(call_order).to be == [3, 2, 1]
243+
244+
# Verify that the error was logged:
245+
expect_console.to have_logged(
246+
severity: be == :error,
247+
subject: be == Protocol::Rack::Body,
248+
message: be =~ /Error occurred during response finished callback:/
249+
)
250+
end
158251
end
159252
end
160253
end

0 commit comments

Comments
 (0)