Skip to content

Commit d473fbd

Browse files
committed
Adding notifications support for various lifecycle conditions
1 parent 481f999 commit d473fbd

File tree

11 files changed

+337
-20
lines changed

11 files changed

+337
-20
lines changed

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ PATH
22
remote: .
33
specs:
44
zendesk_api (3.1.1)
5+
activesupport
56
base64
67
faraday (> 2.0.0)
78
faraday-multipart

lib/zendesk_api/client.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
require_relative "middleware/response/parse_json"
2020
require_relative "middleware/response/raise_error"
2121
require_relative "middleware/response/logger"
22+
require_relative "middleware/response/zendesk_request_event"
2223
require_relative "delegator"
2324

2425
module ZendeskAPI
@@ -166,6 +167,7 @@ def build_connection
166167
Faraday.new(config.options) do |builder|
167168
# response
168169
builder.use ZendeskAPI::Middleware::Response::RaiseError
170+
builder.use ZendeskAPI::Middleware::Response::ZendeskRequestEvent, self if config.instrumentation.respond_to?(:instrument)
169171
builder.use ZendeskAPI::Middleware::Response::Callback, self
170172
builder.use ZendeskAPI::Middleware::Response::Logger, config.logger if config.logger
171173
builder.use ZendeskAPI::Middleware::Response::ParseIsoDates
@@ -181,7 +183,7 @@ def build_connection
181183
set_authentication(builder, config)
182184

183185
if config.cache
184-
builder.use ZendeskAPI::Middleware::Request::EtagCache, cache: config.cache
186+
builder.use ZendeskAPI::Middleware::Request::EtagCache, {cache: config.cache, instrumentation: config.instrumentation}
185187
end
186188

187189
builder.use ZendeskAPI::Middleware::Request::Upload
@@ -193,7 +195,8 @@ def build_connection
193195
builder.use ZendeskAPI::Middleware::Request::Retry,
194196
logger: config.logger,
195197
retry_codes: config.retry_codes,
196-
retry_on_exception: config.retry_on_exception
198+
retry_on_exception: config.retry_on_exception,
199+
instrumentation: config.instrumentation
197200
end
198201
if config.raise_error_when_rate_limited
199202
builder.use ZendeskAPI::Middleware::Request::RaiseRateLimited, logger: config.logger

lib/zendesk_api/configuration.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class Configuration
5454
# specify if you want a (network layer) exception to elicit a retry
5555
attr_accessor :retry_on_exception
5656

57+
# specify if you wnat instrumentation to be used
58+
attr_accessor :instrumentation
59+
5760
def initialize
5861
@client_options = {}
5962
@use_resource_cache = true

lib/zendesk_api/middleware/request/etag_cache.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "faraday/middleware"
2+
require "active_support/notifications"
23

34
module ZendeskAPI
45
module Middleware
@@ -9,6 +10,7 @@ module Request
910
class EtagCache < Faraday::Middleware
1011
def initialize(app, options = {})
1112
@app = app
13+
@instrumentation = options[:instrumentation] if options[:instrumentation].respond_to?(:instrument)
1214
@cache = options[:cache] ||
1315
raise("need :cache option e.g. ActiveSupport::Cache::MemoryStore.new")
1416
@cache_key_prefix = options.fetch(:cache_key_prefix, :faraday_etags)
@@ -41,8 +43,18 @@ def call(environment)
4143
content_length: cached[:response_headers][:content_length],
4244
content_encoding: cached[:response_headers][:content_encoding]
4345
)
46+
@instrumentation&.instrument("zendesk.cache_hit",
47+
{
48+
endpoint: env[:url].path,
49+
status: env[:status]
50+
})
4451
elsif env[:status] == 200 && env[:response_headers]["Etag"] # modified and cacheable
4552
@cache.write(cache_key(env), env.to_hash)
53+
@instrumentation&.instrument("zendesk.cache_miss",
54+
{
55+
endpoint: env[:url].path,
56+
status: env[:status]
57+
})
4658
end
4759
end
4860
end

lib/zendesk_api/middleware/request/retry.rb

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,44 +15,74 @@ def initialize(app, options = {})
1515
@logger = options[:logger]
1616
@error_codes = (options.key?(:retry_codes) && options[:retry_codes]) ? options[:retry_codes] : DEFAULT_ERROR_CODES
1717
@retry_on_exception = (options.key?(:retry_on_exception) && options[:retry_on_exception]) ? options[:retry_on_exception] : false
18+
@instrumentation = options[:instrumentation]
1819
end
1920

2021
def call(env)
22+
# Duplicate env for retries but keep attempt counter persistent
2123
original_env = env.dup
24+
original_env[:call_attempt] ||= 0
25+
2226
exception_happened = false
27+
response = nil
28+
2329
if @retry_on_exception
2430
begin
2531
response = @app.call(env)
26-
rescue => e
32+
rescue => ex
2733
exception_happened = true
34+
exception = ex
2835
end
2936
else
37+
# Allow exceptions to propagate normally when not retrying
3038
response = @app.call(env)
3139
end
3240

33-
if exception_happened || @error_codes.include?(response.env[:status])
41+
if exception_happened
42+
original_env[:call_attempt] += 1
43+
seconds_left = DEFAULT_RETRY_AFTER.to_i
44+
@logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{exception}"
45+
instrument_retry(original_env, "exception", seconds_left)
46+
sleep_with_logging(seconds_left)
47+
return @app.call(original_env)
48+
end
3449

35-
if exception_happened
36-
seconds_left = DEFAULT_RETRY_AFTER.to_i
37-
@logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{e}"
38-
else
39-
seconds_left = (response.env[:response_headers][:retry_after] || DEFAULT_RETRY_AFTER).to_i
40-
end
50+
# Loop through consecutive retryable responses (e.g., multiple 429s)
51+
while response && @error_codes.include?(response.env[:status])
52+
original_env[:call_attempt] += 1
53+
seconds_left = (response.env[:response_headers][:retry_after] || DEFAULT_RETRY_AFTER).to_i
54+
@logger&.warn "You may have been rate limited. Retrying in #{seconds_left} seconds..."
55+
instrument_retry(original_env, (response.env[:status] == 429) ? "rate_limited" : "server_error", seconds_left)
56+
sleep_with_logging(seconds_left)
57+
response = @app.call(original_env)
58+
end
4159

42-
@logger&.warn "You have been rate limited. Retrying in #{seconds_left} seconds..."
60+
response
61+
end
4362

44-
seconds_left.times do |i|
45-
sleep 1
46-
time_left = seconds_left - i
47-
@logger&.warn "#{time_left}..." if time_left > 0 && time_left % 5 == 0
48-
end
63+
private
4964

50-
@logger&.warn ""
65+
def instrument_retry(env, reason, delay)
66+
return unless @instrumentation
67+
@instrumentation.instrument(
68+
"zendesk.retry",
69+
{
70+
attempt: env[:call_attempt],
71+
endpoint: env[:url].path,
72+
method: env[:method],
73+
reason: reason,
74+
delay: delay
75+
}
76+
)
77+
end
5178

52-
@app.call(original_env)
53-
else
54-
response
79+
def sleep_with_logging(seconds_left)
80+
seconds_left.times do |i|
81+
sleep 1 if seconds_left > 0
82+
time_left = seconds_left - i
83+
@logger&.warn "#{time_left}..." if time_left > 0 && time_left % 5 == 0
5584
end
85+
@logger&.warn "" if seconds_left > 0
5686
end
5787
end
5888
end
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
require "faraday/response"
2+
3+
module ZendeskAPI
4+
module Middleware
5+
module Response
6+
# @private
7+
class ZendeskRequestEvent < Faraday::Middleware
8+
def initialize(app, client)
9+
super(app)
10+
@client = client
11+
end
12+
13+
def call(env)
14+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
15+
@app.call(env).on_complete do |response_env|
16+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17+
duration = (end_time - start_time) * 1000.0
18+
instrumentation = @client.config.instrumentation
19+
if instrumentation
20+
instrumentation.instrument("zendesk.request",
21+
{duration: duration,
22+
endpoint: response_env[:url].path,
23+
method: response_env[:method],
24+
status: response_env[:status]})
25+
if response_env[:status] < 500
26+
instrumentation.instrument("zendesk.rate_limit",
27+
{
28+
endpoint: response_env[:url].path,
29+
status: response_env[:status],
30+
threshold: response_env[:response_headers] ? response_env[:response_headers]["X-Rate-Limit-Remaining"] : nil,
31+
limit: response_env[:response_headers] ? response_env[:response_headers]["X-Rate-Limit"] : nil,
32+
reset: response_env[:response_headers] ? response_env[:response_headers]["X-Rate-Limit-Reset"] : nil
33+
})
34+
end
35+
end
36+
end
37+
end
38+
end
39+
end
40+
end
41+
end

spec/core/middleware/request/etag_cache_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
require "core/spec_helper"
2+
require "active_support/cache"
3+
14
describe ZendeskAPI::Middleware::Request::EtagCache do
25
it "caches" do
36
client.config.cache.size = 1
@@ -16,4 +19,62 @@
1619
expect(response.headers[header]).to eq(first_response.headers[header])
1720
end
1821
end
22+
23+
context "instrumentation" do
24+
let(:instrumentation) { double("Instrumentation") }
25+
let(:cache) { ActiveSupport::Cache::MemoryStore.new }
26+
let(:status) { nil }
27+
let(:middleware) do
28+
ZendeskAPI::Middleware::Request::EtagCache.new(
29+
->(env) { Faraday::Response.new(env) },
30+
cache: cache,
31+
instrumentation: instrumentation
32+
)
33+
end
34+
let(:env) do
35+
{
36+
url: URI("https://example.zendesk.com/api/v2/blergh"),
37+
method: :get,
38+
request_headers: {},
39+
response_headers: {"Etag" => "x", :x_rate_limit_remaining => 10},
40+
status: status,
41+
body: {"x" => 1},
42+
response_body: {"x" => 1}
43+
}
44+
end
45+
let(:no_instrumentation_middleware) do
46+
ZendeskAPI::Middleware::Request::EtagCache.new(
47+
->(env) { Faraday::Response.new(env) },
48+
cache: cache,
49+
instrumentation: nil
50+
)
51+
end
52+
before do
53+
allow(instrumentation).to receive(:instrument)
54+
end
55+
56+
it "emits cache_miss on first request" do
57+
expect(instrumentation).to receive(:instrument).with(
58+
"zendesk.cache_miss",
59+
hash_including(endpoint: "/api/v2/blergh", status: 200)
60+
)
61+
env[:status] = 200
62+
middleware.call(env).on_complete { |_e| 1 }
63+
end
64+
65+
it "don't care on no instrumentation" do
66+
env[:status] = 200
67+
no_instrumentation_middleware.call(env).on_complete { |_e| 1 }
68+
end
69+
70+
it "emits cache_hit on 304 response" do
71+
cache.write(middleware.cache_key(env), env)
72+
expect(instrumentation).to receive(:instrument).with(
73+
"zendesk.cache_hit",
74+
hash_including(endpoint: "/api/v2/blergh", status: 304)
75+
)
76+
env[:status] = 304
77+
middleware.call(env).on_complete { |_e| 1 }
78+
end
79+
end
1980
end

spec/core/middleware/request/retry_spec.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,76 @@ def runtime
116116
end
117117
end
118118
end
119+
120+
context "with instrumentation on retry" do
121+
let(:instrumentation) { double("Instrumentation") }
122+
let(:middleware) do
123+
ZendeskAPI::Middleware::Request::Retry.new(client.connection.builder.app)
124+
end
125+
126+
before do
127+
allow(instrumentation).to receive(:instrument)
128+
client.config.instrumentation = instrumentation
129+
# Inject instrumentation into middleware instance
130+
allow_any_instance_of(ZendeskAPI::Middleware::Request::Retry).to receive(:instrumentation).and_return(instrumentation)
131+
stub_request(:get, %r{instrumented}).to_return(status: 429, headers: {retry_after: 1}).to_return(status: 200)
132+
end
133+
134+
it "calls instrumentation on retry" do
135+
expect(instrumentation).to receive(:instrument).with(
136+
"zendesk.retry",
137+
hash_including(attempt: 1, endpoint: anything, method: anything, reason: anything, delay: anything)
138+
).at_least(:once)
139+
client.connection.get("instrumented")
140+
end
141+
142+
it "calls instrumentation on second retry attempt" do
143+
# Override stub for this test to force two rate limit responses before success
144+
stub_request(:get, %r{instrumented_twice})
145+
.to_return(status: 429, headers: {retry_after: 0})
146+
.to_return(status: 429, headers: {retry_after: 0})
147+
.to_return(status: 200)
148+
149+
# Expect instrumentation for attempt 1 and attempt 2
150+
expect(instrumentation).to receive(:instrument).with(
151+
"zendesk.retry",
152+
hash_including(attempt: 1, endpoint: anything, method: anything, reason: "rate_limited", delay: anything)
153+
).at_least(:once)
154+
expect(instrumentation).to receive(:instrument).with(
155+
"zendesk.retry",
156+
hash_including(attempt: 2, endpoint: anything, method: anything, reason: "rate_limited", delay: anything)
157+
).at_least(:once)
158+
159+
client.connection.get("instrumented_twice")
160+
end
161+
162+
it "calls instrumentation with first attempt server error, and rate limited on second retry attempt" do
163+
# Override stub for this test to force two rate limit responses before success
164+
stub_request(:get, %r{instrumented_twice})
165+
.to_return(status: 503, headers: {retry_after: 0})
166+
.to_return(status: 429, headers: {retry_after: 0})
167+
.to_return(status: 200)
168+
169+
# Expect instrumentation for attempt 1 and attempt 2
170+
expect(instrumentation).to receive(:instrument).with(
171+
"zendesk.retry",
172+
hash_including(attempt: 1, endpoint: anything, method: anything, reason: "server_error", delay: anything)
173+
).at_least(:once)
174+
expect(instrumentation).to receive(:instrument).with(
175+
"zendesk.retry",
176+
hash_including(attempt: 2, endpoint: anything, method: anything, reason: "rate_limited", delay: anything)
177+
).at_least(:once)
178+
179+
client.connection.get("instrumented_twice")
180+
end
181+
182+
it "does not call instrumentation when no retry occurs" do
183+
stub_request(:get, %r{no_retry}).to_return(status: 200)
184+
expect(instrumentation).not_to receive(:instrument).with(
185+
"zendesk.retry",
186+
hash_including(:attempt, :endpoint, :method, :reason, :delay)
187+
)
188+
client.connection.get("no_retry")
189+
end
190+
end
119191
end

0 commit comments

Comments
 (0)