Skip to content

Commit a4a5b09

Browse files
committed
Add Feature#on_request hook to fix instrumentation with retries
The instrumentation feature's start/finish spans were mismatched when using .retriable — start was called once via wrap_request at build time, but finish was called on every retry via wrap_response. This caused NoMethodError with ActiveSupport::Notifications on Rails 7+. Add a new Feature#on_request lifecycle hook called before each request attempt (including retries) and move instrumentation's span start logic there, ensuring properly paired start/finish calls. Closes #826
1 parent 1e7650e commit a4a5b09

7 files changed

Lines changed: 68 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ 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)
1214
- Pattern matching support (`deconstruct_keys`) for Response, Response::Status,
1315
Headers, ContentType, and URI (#642)
1416
- Combined global and per-operation timeouts: global and per-operation timeouts
@@ -18,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1820

1921
### Fixed
2022

23+
- Instrumentation feature now correctly starts a new span for each retry
24+
attempt, fixing `NoMethodError` with `ActiveSupport::Notifications` when
25+
using `.retriable` with the instrumentation feature (#826)
2126
- Raise `HTTP::Request::InvalidURIError` for invalid URIs (nil, empty string,
2227
missing scheme, malformed) instead of confusing `UnsupportedSchemeError` or
2328
`Addressable::URI::InvalidURIError` (#565)

lib/http/client.rb

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ def close
137137
# @api private
138138
def perform_once(req, options)
139139
verify_connection!(req.uri)
140-
141140
@state = :dirty
142141

143142
send_request(req, options)
@@ -168,16 +167,16 @@ def perform_with_retry(req, options)
168167
# @return [void]
169168
# @api private
170169
def send_request(req, options)
170+
notify_features(req, options)
171+
171172
@connection ||= HTTP::Connection.new(req, options)
172173

173174
unless @connection.failed_proxy_connect?
174175
@connection.send_request(req)
175176
@connection.read_headers!
176177
end
177178
rescue Error => e
178-
options.features.each_value do |feature|
179-
feature.on_error(req, e)
180-
end
179+
options.features.each_value { |feature| feature.on_error(req, e) }
181180
raise
182181
end
183182

@@ -192,6 +191,13 @@ def build_wrapped_response(req, options)
192191
end
193192
end
194193

194+
# Notify features of an upcoming request attempt
195+
# @return [void]
196+
# @api private
197+
def notify_features(req, options)
198+
options.features.each_value { |feature| feature.on_request(req) }
199+
end
200+
195201
# Wrap request through feature middleware
196202
# @return [HTTP::Request] the wrapped request
197203
# @api private

lib/http/feature.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,20 @@ def wrap_response(response)
2727
response
2828
end
2929

30+
# Callback invoked before each request attempt
31+
#
32+
# Unlike {#wrap_request}, which is called once when the request is built,
33+
# this hook is called before every attempt, including retries. Use it for
34+
# per-attempt side effects like starting instrumentation spans.
35+
#
36+
# @example
37+
# feature.on_request(request)
38+
#
39+
# @param _request [HTTP::Request]
40+
# @return [nil]
41+
# @api public
42+
def on_request(_request); end
43+
3044
# Callback for request errors
3145
#
3246
# @example

lib/http/features/instrumentation.rb

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

65-
# Wraps a request with instrumentation events
65+
# Starts instrumentation for a request attempt
66+
#
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.
6670
#
6771
# @example
68-
# feature.wrap_request(request)
72+
# feature.on_request(request)
6973
#
7074
# @param request [HTTP::Request]
71-
# @return [HTTP::Request]
75+
# @return [nil]
7276
# @api public
73-
def wrap_request(request)
77+
def on_request(request)
7478
# Emit a separate "start" event, so a logger can print the request
7579
# being run without waiting for a response
7680
instrumenter.instrument("start_#{name}", request: request) {} # rubocop:disable Lint/EmptyBlock
7781
instrumenter.start(name, request: request)
78-
request
82+
nil
7983
end
8084

8185
# Wraps a response with instrumentation events

sig/http.rbs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ module HTTP
8585

8686
def perform_once: (Request req, Options options) -> Response
8787
def perform_with_retry: (Request req, Options options) -> Response
88+
def notify_features: (Request req, Options options) -> void
8889
def wrap_request: (Request req, Options opts) -> Request
8990
def build_response: (Request req, Options options) -> Response
9091
def build_wrapped_response: (Request req, Options options) -> Response
@@ -244,6 +245,7 @@ module HTTP
244245
class Feature
245246
def wrap_request: (untyped request) -> untyped
246247
def wrap_response: (untyped response) -> untyped
248+
def on_request: (untyped _request) -> nil
247249
def on_error: (untyped _request, untyped _error) -> nil
248250
end
249251

@@ -312,7 +314,7 @@ module HTTP
312314
attr_reader error_name: String
313315

314316
def initialize: (?instrumenter: untyped, ?namespace: String) -> void
315-
def wrap_request: (untyped request) -> untyped
317+
def on_request: (untyped request) -> nil
316318
def wrap_response: (untyped response) -> untyped
317319
def on_error: (untyped request, untyped error) -> untyped
318320

test/http/client_test.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,28 @@ def wrap_response(res)
419419
feature_class_order.order
420420
)
421421
end
422+
423+
it "calls on_request once per attempt" do
424+
feature_class_on_request =
425+
Class.new(HTTP::Feature) do
426+
attr_reader :call_count
427+
428+
def initialize
429+
super
430+
@call_count = 0
431+
end
432+
433+
def on_request(_request)
434+
@call_count += 1
435+
end
436+
end
437+
feature_instance = feature_class_on_request.new
438+
439+
client.use(test_feature: feature_instance)
440+
.request(:get, dummy.endpoint)
441+
442+
assert_equal 1, feature_instance.call_count
443+
end
422444
end
423445
end
424446

test/http/features/instrumentation_test.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def finish(_name, payload)
2626
let(:instrumenter) { instrumenter_class.new }
2727
let(:feature) { HTTP::Features::Instrumentation.new(instrumenter: instrumenter) }
2828

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

39-
it "logs the request" do
40-
feature.wrap_request(request)
39+
it "starts the instrumentation span" do
40+
feature.on_request(request)
4141

4242
assert_equal({ request: request }, instrumenter.output[:start])
4343
end
@@ -100,8 +100,8 @@ def finish(_name, _payload = {}); end
100100
HTTP::Request.new(verb: :get, uri: "https://example.com/", headers: {})
101101
end
102102

103-
it "does not raise LocalJumpError in wrap_request" do
104-
feature.wrap_request(request)
103+
it "does not raise LocalJumpError in on_request" do
104+
feature.on_request(request)
105105
end
106106

107107
it "does not raise LocalJumpError in on_error" do

0 commit comments

Comments
 (0)