Skip to content

Commit ee1edba

Browse files
committed
Add Feature#around_request hook for wrapping the HTTP exchange lifecycle
Add an around-style hook that wraps the per-attempt send/receive cycle, with the request flowing through the feature chain. This enables features like instrumentation to use ensure-based cleanup for guaranteed span closing on both success and failure. Rewrite the instrumentation feature to use around_request, replacing the separate on_request/wrap_response pair with a single call to instrumenter.instrument, which naturally provides paired start/finish via its own ensure block.
1 parent a4a5b09 commit ee1edba

7 files changed

Lines changed: 190 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- `Feature#on_request` lifecycle hook, called before each request attempt
13-
(including retries), for per-attempt side effects like instrumentation (#826)
12+
- `Feature#on_request` and `Feature#around_request` lifecycle hooks, called
13+
before/around each request attempt (including retries), for per-attempt side
14+
effects like instrumentation spans and circuit breakers (#826)
1415
- Pattern matching support (`deconstruct_keys`) for Response, Response::Status,
1516
Headers, ContentType, and URI (#642)
1617
- Combined global and per-operation timeouts: global and per-operation timeouts

lib/http/client.rb

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,9 @@ def close
136136
# @return [HTTP::Response] the response
137137
# @api private
138138
def perform_once(req, options)
139-
verify_connection!(req.uri)
140-
@state = :dirty
139+
res = perform_exchange(req, options)
141140

142-
send_request(req, options)
143-
res = build_wrapped_response(req, options)
144-
145-
@connection.finish_response if req.verb == :head
141+
@connection.finish_response if res.request.verb == :head
146142
@state = :clean
147143

148144
res
@@ -198,6 +194,27 @@ def notify_features(req, options)
198194
options.features.each_value { |feature| feature.on_request(req) }
199195
end
200196

197+
# Execute the HTTP exchange wrapped by feature around_request hooks
198+
# @return [HTTP::Response] the response
199+
# @api private
200+
def perform_exchange(req, options)
201+
around_request(req, options) do |request|
202+
verify_connection!(request.uri)
203+
@state = :dirty
204+
send_request(request, options)
205+
build_wrapped_response(request, options)
206+
end
207+
end
208+
209+
# Compose around_request chains from all features
210+
# @return [HTTP::Response] the response
211+
# @api private
212+
def around_request(request, options, &block)
213+
options.features.values.reverse.reduce(block) do |inner, feature|
214+
->(req) { feature.around_request(req) { |r| inner.call(r) } }
215+
end.call(request)
216+
end
217+
201218
# Wrap request through feature middleware
202219
# @return [HTTP::Request] the wrapped request
203220
# @api private

lib/http/feature.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ def wrap_response(response)
4141
# @api public
4242
def on_request(_request); end
4343

44+
# Wraps the HTTP exchange for a single request attempt
45+
#
46+
# Called once per attempt (including retries), wrapping the send and
47+
# receive cycle. The block performs the I/O and returns the response.
48+
# Override this to add behavior that must span the entire exchange,
49+
# such as instrumentation spans or circuit breakers.
50+
#
51+
# @example Timing a request
52+
# def around_request(request)
53+
# start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
54+
# yield(request).tap { log_duration(Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) }
55+
# end
56+
#
57+
# @param request [HTTP::Request]
58+
# @yield [HTTP::Request] the request to perform
59+
# @yieldreturn [HTTP::Response]
60+
# @return [HTTP::Response] must return the response from yield
61+
# @api public
62+
def around_request(request)
63+
yield request
64+
end
65+
4466
# Callback for request errors
4567
#
4668
# @example

lib/http/features/instrumentation.rb

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -62,37 +62,29 @@ def initialize(instrumenter: NullInstrumenter.new, namespace: "http")
6262
@error_name = "error.#{namespace}"
6363
end
6464

65-
# Starts instrumentation for a request attempt
65+
# Wraps the HTTP exchange with instrumentation
6666
#
67-
# Called before each request attempt, including retries. Emits a
68-
# separate "start" event so a logger can print the request being run
69-
# without waiting for a response, then starts the main request span.
67+
# Emits a `"start_request.http"` event before the request, then wraps
68+
# the exchange in a `"request.http"` span that is guaranteed to close
69+
# on both success and failure (via the instrumenter's ensure block).
7070
#
7171
# @example
72-
# feature.on_request(request)
72+
# feature.around_request(request) { perform_io }
7373
#
7474
# @param request [HTTP::Request]
75-
# @return [nil]
75+
# @yield Executes the HTTP exchange
76+
# @yieldreturn [HTTP::Response]
77+
# @return [HTTP::Response]
7678
# @api public
77-
def on_request(request)
79+
def around_request(request)
7880
# Emit a separate "start" event, so a logger can print the request
7981
# being run without waiting for a response
8082
instrumenter.instrument("start_#{name}", request: request) {} # rubocop:disable Lint/EmptyBlock
81-
instrumenter.start(name, request: request)
82-
nil
83-
end
84-
85-
# Wraps a response with instrumentation events
86-
#
87-
# @example
88-
# feature.wrap_response(response)
89-
#
90-
# @param response [HTTP::Response]
91-
# @return [HTTP::Response]
92-
# @api public
93-
def wrap_response(response)
94-
instrumenter.finish(name, response: response)
95-
response
83+
instrumenter.instrument(name, request: request) do |payload|
84+
response = yield request
85+
payload[:response] = response if payload
86+
response
87+
end
9688
end
9789

9890
# Instruments a request error

sig/http.rbs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ module HTTP
8686
def perform_once: (Request req, Options options) -> Response
8787
def perform_with_retry: (Request req, Options options) -> Response
8888
def notify_features: (Request req, Options options) -> void
89+
def perform_exchange: (Request req, Options options) -> Response
90+
def around_request: (Request request, Options options) { (Request) -> Response } -> Response
8991
def wrap_request: (Request req, Options opts) -> Request
9092
def build_response: (Request req, Options options) -> Response
9193
def build_wrapped_response: (Request req, Options options) -> Response
@@ -246,6 +248,7 @@ module HTTP
246248
def wrap_request: (untyped request) -> untyped
247249
def wrap_response: (untyped response) -> untyped
248250
def on_request: (untyped _request) -> nil
251+
def around_request: (untyped request) { (untyped) -> Response } -> Response
249252
def on_error: (untyped _request, untyped _error) -> nil
250253
end
251254

@@ -314,8 +317,7 @@ module HTTP
314317
attr_reader error_name: String
315318

316319
def initialize: (?instrumenter: untyped, ?namespace: String) -> void
317-
def on_request: (untyped request) -> nil
318-
def wrap_response: (untyped response) -> untyped
320+
def around_request: (untyped request) { (untyped) -> Response } -> Response
319321
def on_error: (untyped request, untyped error) -> untyped
320322

321323
class NullInstrumenter

test/http/client_test.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,92 @@ def on_request(_request)
441441

442442
assert_equal 1, feature_instance.call_count
443443
end
444+
445+
it "calls on_request once per retry attempt" do
446+
feature_class_on_request =
447+
Class.new(HTTP::Feature) do
448+
attr_reader :call_count
449+
450+
def initialize
451+
super
452+
@call_count = 0
453+
end
454+
455+
def on_request(_request)
456+
@call_count += 1
457+
end
458+
end
459+
feature_instance = feature_class_on_request.new
460+
461+
client.use(test_feature: feature_instance)
462+
.retriable(delay: 0, retry_statuses: [500])
463+
.request(:get, "#{dummy.endpoint}/retry-2")
464+
465+
assert_equal 2, feature_instance.call_count
466+
end
467+
468+
it "wraps each retry attempt with around_request" do
469+
feature_class_around =
470+
Class.new(HTTP::Feature) do
471+
attr_reader :events
472+
473+
def initialize
474+
super
475+
@events = []
476+
end
477+
478+
def around_request(request)
479+
@events << :before
480+
yield(request).tap do
481+
@events << :after
482+
end
483+
end
484+
end
485+
feature_instance = feature_class_around.new
486+
487+
client.use(test_feature: feature_instance)
488+
.retriable(delay: 0, retry_statuses: [500])
489+
.request(:get, "#{dummy.endpoint}/retry-2")
490+
491+
assert_equal %i[before after before after], feature_instance.events
492+
end
493+
494+
it "wraps the exchange with around_request in feature order" do
495+
feature_class_around =
496+
Class.new(HTTP::Feature) do
497+
@order = []
498+
499+
class << self
500+
attr_reader :order
501+
end
502+
503+
def initialize(id:)
504+
super()
505+
@id = id
506+
end
507+
508+
def around_request(request)
509+
self.class.order << "before.#{@id}"
510+
yield(request).tap do
511+
self.class.order << "after.#{@id}"
512+
end
513+
end
514+
end
515+
feature_instance_a = feature_class_around.new(id: "a")
516+
feature_instance_b = feature_class_around.new(id: "b")
517+
feature_instance_c = feature_class_around.new(id: "c")
518+
519+
client.use(
520+
test_feature_a: feature_instance_a,
521+
test_feature_b: feature_instance_b,
522+
test_feature_c: feature_instance_c
523+
).request(:get, dummy.endpoint)
524+
525+
assert_equal(
526+
["before.a", "before.b", "before.c", "after.c", "after.b", "after.a"],
527+
feature_class_around.order
528+
)
529+
end
444530
end
445531
end
446532

test/http/features/instrumentation_test.rb

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,19 @@ def initialize
1414
end
1515

1616
def start(_name, payload)
17-
output[:start] = payload
17+
output[:start] = payload.dup
1818
end
1919

2020
def finish(_name, payload)
21-
output[:finish] = payload
21+
output[:finish] = payload.dup
2222
end
2323
end
2424
end
2525

2626
let(:instrumenter) { instrumenter_class.new }
2727
let(:feature) { HTTP::Features::Instrumentation.new(instrumenter: instrumenter) }
2828

29-
describe "starting instrumentation" do
29+
describe "around_request" do
3030
let(:request) do
3131
HTTP::Request.new(
3232
verb: :post,
@@ -36,28 +36,40 @@ def finish(_name, payload)
3636
)
3737
end
3838

39-
it "starts the instrumentation span" do
40-
feature.on_request(request)
41-
42-
assert_equal({ request: request }, instrumenter.output[:start])
43-
end
44-
end
45-
46-
describe "logging the response" do
4739
let(:response) do
4840
HTTP::Response.new(
4941
version: "1.1",
5042
status: 200,
5143
headers: { content_type: "application/json" },
5244
body: '{"success": true}',
53-
request: HTTP::Request.new(verb: :get, uri: "https://example.com")
45+
request: request
5446
)
5547
end
5648

57-
it "logs the response" do
58-
feature.wrap_response(response)
49+
it "starts the instrumentation span" do
50+
feature.around_request(request) { response }
5951

60-
assert_equal({ response: response }, instrumenter.output[:finish])
52+
assert_equal({ request: request }, instrumenter.output[:start])
53+
end
54+
55+
it "finishes the instrumentation span with the response" do
56+
feature.around_request(request) { response }
57+
58+
assert_equal({ request: request, response: response }, instrumenter.output[:finish])
59+
end
60+
61+
it "returns the response from the block" do
62+
result = feature.around_request(request) { response }
63+
64+
assert_same response, result
65+
end
66+
67+
it "finishes the span even when the block raises" do
68+
assert_raises(RuntimeError) do
69+
feature.around_request(request) { raise "boom" }
70+
end
71+
72+
assert_equal({ request: request }, instrumenter.output[:finish])
6173
end
6274
end
6375

@@ -100,8 +112,17 @@ def finish(_name, _payload = {}); end
100112
HTTP::Request.new(verb: :get, uri: "https://example.com/", headers: {})
101113
end
102114

103-
it "does not raise LocalJumpError in on_request" do
104-
feature.on_request(request)
115+
let(:response) do
116+
HTTP::Response.new(
117+
version: "1.1",
118+
status: 200,
119+
body: "",
120+
request: request
121+
)
122+
end
123+
124+
it "does not raise LocalJumpError in around_request" do
125+
feature.around_request(request) { response }
105126
end
106127

107128
it "does not raise LocalJumpError in on_error" do

0 commit comments

Comments
 (0)