barkibu-kb 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6665ca6f0d74295cacf6bea9ee921b3dba5decc6b5cb3775bbaa0392fd65b86
4
- data.tar.gz: 700cae8005557d25ff41c3f9583725686485eff37ccbb0eec11ff07473ad9077
3
+ metadata.gz: 72ba3947460c029c6dee0dc965b1066b03b645d3bb6a891b291c2383b8d581e3
4
+ data.tar.gz: d0b5ca779b79f1e7017dbafca93376215faac264ec55210ef1af8f981f352ec5
5
5
  SHA512:
6
- metadata.gz: 97dac9ae9946f9398bd62b2fda16acbd978c1e4e153e23884ab78ac3a055dec7d201fcbf3a98ed4b65403324af2b03d034a6073f30d15c20690699021fccae74
7
- data.tar.gz: f9ed89dff91778420037ad50e7531af48d9b7b6d11c7d92afb1d05cdc31192498310520da51d35aa32e9ed77041e808137f5d9b699fcea24d495f2d17379b475
6
+ metadata.gz: 0d596248f6f49451a5a4bcb9ea1648b3175b3d8b1d7cbf0665082332841d63c32867e7f408d221f2a47ef7387e1b70836c74af5bfd8e5e37ae9536ea9b19ec42
7
+ data.tar.gz: e832f256a27bfbe905d6e122b81245370c932dad1801d5772608778851131c9da1e761817e164df8a91e5e770af79804cd2c0cfdc90310148048bd92ef761ea7
data/CHANGELOG.md CHANGED
@@ -6,7 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [unreleased]
9
- - See diff: https://github.com/barkibu/kb-ruby/compare/v1.1.0...HEAD
9
+ - See diff: https://github.com/barkibu/kb-ruby/compare/v1.3.0...HEAD
10
+
11
+ ## [1.3.0]
12
+ - Retry transport failures once (`faraday-retry`, already in the Faraday 1.10 bundle, now an explicit dependency). `KB::RetryPolicy` decides by the underlying error, not the Faraday class: failures where the request never left (`Net::OpenTimeout`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `EADDRNOTAVAIL`, `SocketError`) retry for every verb; any other transport failure (read/write timeout, reset, EOF, TLS) retries for GET/HEAD only (and not when the call raised its own `read_timeout:`, so a 30s read isn't doubled); HTTP error responses never retry. New settings `KB.config.request.retries` (default 1, 0 disables) and `retry_interval` (default 0.1s, randomized up to 2x). Worst-case latency is now two attempts' worth of phase budgets.
13
+ - `request.kb_client` payload gains `retries` and `retry_errors` on retried calls; the Datadog subscriber tags them as `kb.retries` / `kb.retry_errors`. The event and span cover all attempts, so a call that succeeded on retry is not an error.
14
+ - `KB::Listable.all` (and so `PetParent.all`, `Pet.all`, `Breed.all`, `Product.all`, `Plan.all`, `Assessment.all`) now wraps `Faraday::ConnectionFailed` in `KB::Error` like every other model call, instead of re-raising it raw. Code rescuing `Faraday::ConnectionFailed` around `.all` must rescue `KB::Error` instead; none of Funnel, Global Admin or connected_health does.
15
+
16
+ ## [1.2.0]
17
+ - `KB::Client` emits one `request.kb_client` `ActiveSupport::Notifications` event per KB call (`KB::Client::REQUEST_EVENT`), wrapping cache lookup, connect, TLS, write, read and parsing. Payload: `verb`, `path`, `base_url`, `cache_hit` (GET only), `status`, plus ActiveSupport's `exception`/`exception_object` when the call raised. Every public method now goes through one private `perform` seam; no behaviour change (same cache keys, params and error classes).
18
+ - Add an opt-in Datadog subscriber: `require 'kb/instrumentation/datadog'` + `KB::Instrumentation::Datadog.subscribe!` turns each event into a `kb.client.request` APM span, opened on event start and closed on finish so the tracer's Net::HTTP spans nest under it. The span inherits the app's service and stays there (no `span.kind:client`/`peer.service`, so it is not attributed to the knowledge-base service), tags `peer.hostname`, `kb.method`, `kb.cache_hit`, `http.status_code`, and uses low-cardinality resources (`GET /v1/pets/?/contracts`). Motivation: connect timeouts happen before `Net::HTTP#request`, so the Datadog Net::HTTP tracer never sees them and they were invisible on our dashboards. Works with `ddtrace` 1.x and `datadog` 2.x; the tracer stays the app's dependency.
10
19
 
11
20
  ## [1.1.0]
12
21
  - Add `read_timeout:` to `KB::Client#request` to raise the read budget for a single call (e.g. `GET /v1/pets/birthdays`, whose server-side work runs for seconds). Connect and write budgets stay global; the override does not leak into later calls on the same connection.
data/Gemfile.lock CHANGED
@@ -1,17 +1,18 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- barkibu-kb (1.1.0)
4
+ barkibu-kb (1.3.0)
5
5
  activemodel (>= 4.0.2)
6
6
  activerecord
7
7
  activesupport (>= 3.0.0)
8
8
  dry-configurable (~> 0.9)
9
9
  faraday
10
10
  faraday-net_http (~> 1.0)
11
+ faraday-retry (~> 1.0)
11
12
  faraday_middleware
12
13
  i18n
13
- barkibu-kb-fake (1.1.0)
14
- barkibu-kb (= 1.1.0)
14
+ barkibu-kb-fake (1.3.0)
15
+ barkibu-kb (= 1.3.0)
15
16
  countries
16
17
  sinatra
17
18
  webmock
@@ -44,12 +45,21 @@ GEM
44
45
  base64 (0.3.0)
45
46
  bigdecimal (4.1.2)
46
47
  byebug (11.1.3)
48
+ cgi (0.5.2)
47
49
  concurrent-ruby (1.3.7)
48
50
  connection_pool (3.0.2)
49
51
  countries (5.3.1)
50
52
  unaccent (~> 0.3)
51
53
  crack (0.4.5)
52
54
  rexml
55
+ datadog (2.43.0)
56
+ cgi
57
+ datadog-ruby_core_source (~> 3.5, >= 3.5.5)
58
+ libdatadog (~> 40.0.0.2.0)
59
+ libddwaf (~> 1.30.0.0.0)
60
+ logger
61
+ msgpack
62
+ datadog-ruby_core_source (3.5.5)
53
63
  diff-lcs (1.4.4)
54
64
  docile (1.4.0)
55
65
  drb (2.2.3)
@@ -84,14 +94,19 @@ GEM
84
94
  faraday-retry (1.0.3)
85
95
  faraday_middleware (1.2.0)
86
96
  faraday (~> 1.0)
97
+ ffi (1.17.4)
87
98
  hashdiff (1.0.1)
88
99
  i18n (1.15.2)
89
100
  concurrent-ruby (~> 1.0)
90
101
  json (2.20.0)
102
+ libdatadog (40.0.0.2.0)
103
+ libddwaf (1.30.0.0.2)
104
+ ffi (~> 1.0)
91
105
  logger (1.7.0)
92
106
  minitest (6.0.6)
93
107
  drb (~> 2.0)
94
108
  prism (~> 1.5)
109
+ msgpack (1.8.5)
95
110
  multipart-post (2.3.0)
96
111
  mustermann (3.0.0)
97
112
  ruby2_keywords (~> 0.0.1)
@@ -173,6 +188,7 @@ DEPENDENCIES
173
188
  bigdecimal
174
189
  bundler
175
190
  byebug
191
+ datadog (~> 2.0)
176
192
  rake (>= 12.3.3)
177
193
  rspec (~> 3.0)
178
194
  rubocop
data/README.md CHANGED
@@ -74,6 +74,86 @@ and write budgets stay global:
74
74
  KB::Pet.kb_client.request('birthdays', filters: { month: 9, day: 22, size: 1000 }, read_timeout: 30)
75
75
  ```
76
76
 
77
+ #### Retries
78
+
79
+ The client retries a failed call once when the failure is in the transport, never
80
+ when KB answered with an HTTP error. Which calls retry depends on whether the
81
+ request can have reached KB (`KB::RetryPolicy`):
82
+
83
+ | Failure | Retried for |
84
+ |---|---|
85
+ | Never sent: connect/TLS timeout (`Net::OpenTimeout`), connection refused, host/network unreachable, DNS failure | every verb, POST included |
86
+ | Maybe sent: read/write timeout, connection reset, EOF, TLS error mid-stream | GET and HEAD only |
87
+ | HTTP 4xx/5xx | never |
88
+
89
+ PUT and DELETE are not retried on "maybe sent" failures: `upsert` and
90
+ `PetParent#merge!` are PUTs whose second run is not a no-op on KB's side, and a
91
+ repeated DELETE would turn a success into a 404. A call that raised its own read
92
+ budget (`read_timeout:` on `KB::Client#request`) is not retried on "maybe sent"
93
+ failures either, so a 30s birthdays read can't become 60s; failures that never
94
+ reached KB are still retried.
95
+
96
+ ```ruby
97
+ # config/initializers/kb_ruby.rb
98
+ KB.config.request.retries = 1 # default; 0 disables retries
99
+ KB.config.request.retry_interval = 0.1 # default, seconds; each wait is 1x-2x this
100
+ ```
101
+
102
+ Like the timeouts, these are read when a client builds its connection, i.e. on
103
+ its first call, so set them in an initializer.
104
+
105
+ Worst case, a call now takes two attempts' worth of phase budgets plus the
106
+ interval, e.g. a GET that read-times-out twice takes about 2 x (1 + 3 + 5)s with
107
+ the default timeouts.
108
+
109
+ #### Instrumentation
110
+
111
+ Every KB call emits one `request.kb_client` event through
112
+ `ActiveSupport::Notifications`, wrapping the whole call: cache lookup, TCP
113
+ connect, TLS, write, read and JSON parsing. The payload carries `verb`, `path`,
114
+ `base_url`, `cache_hit` (GET calls only), `status` (when a response arrived),
115
+ `retries` and `retry_errors` (only when the call was retried: the count, and the
116
+ underlying error class of each failed attempt, e.g. `["Net::OpenTimeout"]`) and
117
+ ActiveSupport's `exception` / `exception_object` when the call raised. The event
118
+ covers the whole call including retries, so `exception` is set only when every
119
+ attempt failed. Subscribe to it for logging, metrics or anything else:
120
+
121
+ ```ruby
122
+ ActiveSupport::Notifications.subscribe(KB::Client::REQUEST_EVENT) do |event|
123
+ Rails.logger.info("KB #{event.payload[:verb]} #{event.payload[:path]} #{event.duration.round}ms")
124
+ end
125
+ ```
126
+
127
+ ##### Datadog
128
+
129
+ A ready-made subscriber turns each event into a `kb.client.request` APM span.
130
+ Opt in from the app's Datadog initializer, after `Datadog.configure`. Works with
131
+ both `ddtrace` 1.x and `datadog` 2.x; the tracer gem is the app's dependency.
132
+
133
+ ```ruby
134
+ # config/initializers/datadog_tracer.rb
135
+ Datadog.configure { |c| ... }
136
+
137
+ require 'kb/instrumentation/datadog'
138
+ KB::Instrumentation::Datadog.subscribe!
139
+ ```
140
+
141
+ The span opens when the event starts and closes when it finishes, so the
142
+ tracer's own Net::HTTP spans nest under it. It inherits the app's service
143
+ (`c.service`), so nothing new appears in the APM service list, and it carries no
144
+ `span.kind:client` or `peer.service`, so Datadog does not attribute it to the
145
+ knowledge-base service either: it measures the client's whole call, not a KB
146
+ operation. Resources are low-cardinality (`GET /v1/pets/birthdays`,
147
+ `GET /v1/pets/?/contracts`). Tags: `peer.hostname` (the KB host used),
148
+ `kb.method`, `kb.cache_hit` (GET calls only), `http.status_code`, `kb.retries`
149
+ and `kb.retry_errors` (retried calls only), plus the standard
150
+ `error.type`/`error.message` when the call raises. A span with `kb.retries` and
151
+ no error is a failure the retry absorbed.
152
+
153
+ Why not rely on the Net::HTTP tracer alone: faraday-net_http opens the socket
154
+ before `Net::HTTP#request`, the method that tracer patches, so a connect timeout
155
+ produces no http span at all. This span sees every phase.
156
+
77
157
  ### Exposed Entities
78
158
 
79
159
  #### Pet Parent 🧍🏾
data/barkibu-kb.gemspec CHANGED
@@ -39,6 +39,7 @@ Gem::Specification.new do |spec|
39
39
  spec.add_dependency 'dry-configurable', '~> 0.9'
40
40
  spec.add_development_dependency 'bundler'
41
41
  spec.add_development_dependency 'byebug'
42
+ spec.add_development_dependency 'datadog', '~> 2.0'
42
43
  spec.add_development_dependency 'rake', '>= 12.3.3'
43
44
  spec.add_development_dependency 'rspec', '~> 3.0'
44
45
  spec.add_development_dependency 'rubocop'
@@ -53,5 +54,6 @@ Gem::Specification.new do |spec|
53
54
  spec.add_runtime_dependency 'faraday'
54
55
  spec.add_runtime_dependency 'faraday-net_http', '~> 1.0'
55
56
  spec.add_runtime_dependency 'faraday_middleware'
57
+ spec.add_runtime_dependency 'faraday-retry', '~> 1.0'
56
58
  spec.add_runtime_dependency 'i18n'
57
59
  end
data/lib/barkibu-kb.rb CHANGED
@@ -21,6 +21,9 @@ module KB
21
21
  setting :connect_timeout, default: 1
22
22
  setting :write_timeout, default: 3
23
23
  setting :read_timeout, default: 5
24
+ # Retries after a transport failure, per KB::RetryPolicy. 0 disables retries.
25
+ setting :retries, default: 1
26
+ setting :retry_interval, default: 0.1
24
27
  end
25
28
  end
26
29
 
@@ -29,6 +32,7 @@ require 'kb/inflections'
29
32
  require 'kb/cache'
30
33
  require 'kb/client_resolver'
31
34
  require 'kb/errors'
35
+ require 'kb/retry_policy'
32
36
  require 'kb/client'
33
37
 
34
38
  require 'kb/concerns'
data/lib/kb/client.rb CHANGED
@@ -1,5 +1,11 @@
1
1
  module KB
2
2
  class Client
3
+ # Emitted once per KB call, wrapping cache lookup and the HTTP request.
4
+ # Payload: verb, path, base_url, cache_hit (GET only), status (when a response arrived),
5
+ # retries / retry_errors (only when the call was retried, see KB::RetryPolicy),
6
+ # plus ActiveSupport's exception/exception_object when the call raised.
7
+ REQUEST_EVENT = 'request.kb_client'.freeze
8
+
3
9
  attr_reader :api_key, :base_url
4
10
 
5
11
  def initialize(base_url, api_key: ENV['KB_API_KEY'])
@@ -11,47 +17,38 @@ module KB
11
17
  # for the few endpoints whose server-side work legitimately runs for seconds
12
18
  # (e.g. GET /v1/pets/birthdays). Connect and write budgets stay global.
13
19
  def request(sub_path, filters: nil, method: :get, read_timeout: nil)
14
- options = request_options(read_timeout)
15
- return connection.public_send(method, sub_path, attributes_to_json(filters), &options).body if method != :get
20
+ return perform(method, sub_path, attributes_to_json(filters), read_timeout: read_timeout) if method != :get
16
21
 
17
22
  cache_key = "#{@base_url}/#{sub_path}/#{(filters || {}).sort.to_h}"
18
- KB::Cache.fetch(cache_key) do
19
- connection.public_send(method, sub_path, filters, &options).body
20
- end
23
+ perform(:get, sub_path, filters, cache_key: cache_key, read_timeout: read_timeout)
21
24
  end
22
25
 
23
26
  def all(filters = {})
24
- cache_key = "#{@base_url}/#{filters.sort.to_h}"
25
-
26
- KB::Cache.fetch(cache_key) do
27
- connection.get('', attributes_case_transform(filters)).body
28
- end
27
+ perform(:get, '', attributes_case_transform(filters), cache_key: "#{@base_url}/#{filters.sort.to_h}")
29
28
  end
30
29
 
31
30
  def find(key, params = {})
32
31
  raise Faraday::ResourceNotFound, {} if key.blank?
33
32
 
34
- KB::Cache.fetch("#{@base_url}/#{key}") do
35
- connection.get(key, attributes_case_transform(params)).body
36
- end
33
+ perform(:get, key, attributes_case_transform(params), cache_key: "#{@base_url}/#{key}")
37
34
  end
38
35
 
39
36
  def create(attributes)
40
- connection.post('', attributes_to_json(attributes)).body
37
+ perform(:post, '', attributes_to_json(attributes))
41
38
  end
42
39
 
43
40
  def update(key, attributes)
44
41
  clear_cache_for(key)
45
- connection.patch(key.to_s, attributes_to_json(attributes)).body
42
+ perform(:patch, key.to_s, attributes_to_json(attributes))
46
43
  end
47
44
 
48
45
  def destroy(key)
49
46
  clear_cache_for(key)
50
- connection.delete(key.to_s).body
47
+ perform(:delete, key.to_s)
51
48
  end
52
49
 
53
50
  def upsert(attributes)
54
- connection.put('', attributes_to_json(attributes)).body
51
+ perform(:put, '', attributes_to_json(attributes))
55
52
  end
56
53
 
57
54
  def clear_cache_for(key)
@@ -60,6 +57,35 @@ module KB
60
57
 
61
58
  private
62
59
 
60
+ # Every public method ends up here, so this is the one place a KB call is
61
+ # observable as a whole: cache lookup, connect, TLS, write, read, parse.
62
+ def perform(verb, path, payload = nil, cache_key: nil, read_timeout: nil)
63
+ event = { verb: verb, path: path, base_url: base_url }
64
+ ActiveSupport::Notifications.instrument(REQUEST_EVENT, event) do
65
+ if cache_key
66
+ event[:cache_hit] = true
67
+ KB::Cache.fetch(cache_key) do
68
+ event[:cache_hit] = false
69
+ http(event, payload, read_timeout)
70
+ end
71
+ else
72
+ http(event, payload, read_timeout)
73
+ end
74
+ end
75
+ end
76
+
77
+ def http(event, payload, read_timeout)
78
+ response = connection.public_send(event[:verb], event[:path], payload) do |req|
79
+ req.options.read_timeout = read_timeout if read_timeout
80
+ RetryPolicy.track(req, event, read_timeout)
81
+ end
82
+ event[:status] = response.status
83
+ response.body
84
+ rescue Faraday::ClientError, Faraday::ServerError => e
85
+ event[:status] = e.response && e.response[:status]
86
+ raise
87
+ end
88
+
63
89
  def headers
64
90
  {
65
91
  'Content-Type': 'application/json',
@@ -79,6 +105,7 @@ module KB
79
105
 
80
106
  def connection
81
107
  @connection ||= Faraday.new(url: base_url, headers: headers, request: request_timeouts) do |conn|
108
+ conn.request :retry, RetryPolicy.middleware_options
82
109
  conn.response :json
83
110
  conn.response :raise_error
84
111
  if KB.config.log_level == :debugger
@@ -90,12 +117,6 @@ module KB
90
117
  end
91
118
  end
92
119
 
93
- def request_options(read_timeout)
94
- return nil if read_timeout.nil?
95
-
96
- ->(req) { req.options.read_timeout = read_timeout }
97
- end
98
-
99
120
  def request_timeouts
100
121
  {
101
122
  open_timeout: KB.config.request.connect_timeout,
@@ -0,0 +1,93 @@
1
+ require 'uri'
2
+ require 'active_support/notifications'
3
+
4
+ module KB
5
+ module Instrumentation
6
+ # Opt-in Datadog APM tracing for every KB call, as a subscriber to the
7
+ # client's `request.kb_client` notification.
8
+ #
9
+ # # config/initializers/datadog_tracer.rb, after Datadog.configure
10
+ # require 'kb/instrumentation/datadog'
11
+ # KB::Instrumentation::Datadog.subscribe!
12
+ #
13
+ # One `kb.client.request` span per call, opened when the event starts and
14
+ # closed when it finishes, so it wraps cache lookup, TCP connect, TLS,
15
+ # write, read and JSON parsing, and the tracer's own Net::HTTP spans nest
16
+ # under it. No service is given, so the span inherits the app's, and it stays
17
+ # there: the KB host is a plain `peer.hostname` tag, with no `span.kind:client`
18
+ # or `peer.service` that would attribute it to KB. Works with
19
+ # `ddtrace` 1.x and `datadog` 2.x; the tracer gem is the app's dependency.
20
+ module Datadog
21
+ OPERATION = 'kb.client.request'.freeze
22
+ # Path segments that are identifiers, collapsed to `?` so resources stay
23
+ # low-cardinality: `GET /v1/pets/?/contracts` rather than one per pet.
24
+ IDENTIFIER_SEGMENT = /\A(?:\h{8}-\h{4}-\h{4}-\h{4}-\h{12}|\d+)\z/.freeze
25
+ SPAN_KEY = :datadog_span
26
+
27
+ class TracerMissing < StandardError; end
28
+
29
+ class << self
30
+ def subscribe!
31
+ unless defined?(::Datadog::Tracing)
32
+ raise TracerMissing, "Datadog tracing is not loaded; require 'ddtrace' or 'datadog' first"
33
+ end
34
+
35
+ return @subscriber if @subscriber
36
+
37
+ @subscriber = ActiveSupport::Notifications.subscribe(KB::Client::REQUEST_EVENT, Subscriber.new)
38
+ end
39
+
40
+ def unsubscribe!
41
+ ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
42
+ @subscriber = nil
43
+ end
44
+
45
+ def subscribed?
46
+ !@subscriber.nil?
47
+ end
48
+
49
+ def resource_for(base_url, verb, path)
50
+ segments = (URI(base_url).path.split('/') + path.to_s.split('/')).reject(&:empty?)
51
+ template = segments.map { |segment| segment.match?(IDENTIFIER_SEGMENT) ? '?' : segment }
52
+ "#{verb.to_s.upcase} /#{template.join('/')}"
53
+ end
54
+ end
55
+
56
+ class Subscriber
57
+ def start(_name, _id, payload)
58
+ span = ::Datadog::Tracing.trace(OPERATION, type: 'http',
59
+ resource: Datadog.resource_for(payload[:base_url], payload[:verb],
60
+ payload[:path]))
61
+ # No span.kind:client / peer.service on purpose: the span covers the
62
+ # client's whole call (cache lookup, connect, parse), so it must not be
63
+ # inferred onto the knowledge-base service page as one of KB's operations.
64
+ span.set_tag('peer.hostname', URI(payload[:base_url]).host)
65
+ span.set_tag('kb.method', payload[:verb].to_s.upcase)
66
+ payload[SPAN_KEY] = span
67
+ end
68
+
69
+ def finish(_name, _id, payload)
70
+ span = payload.delete(SPAN_KEY)
71
+ return unless span
72
+
73
+ span.set_tag('kb.cache_hit', payload[:cache_hit].to_s) if payload.key?(:cache_hit)
74
+ span.set_tag('http.status_code', payload[:status].to_s) if payload[:status]
75
+ tag_retries(span, payload)
76
+ span.set_error(payload[:exception_object]) if payload[:exception_object]
77
+ span.finish
78
+ end
79
+
80
+ private
81
+
82
+ # Only on retried calls: `kb.retries` (numeric) and the distinct underlying
83
+ # errors that triggered them, e.g. `Net::OpenTimeout`.
84
+ def tag_retries(span, payload)
85
+ return unless payload[:retries]
86
+
87
+ span.set_tag('kb.retries', payload[:retries])
88
+ span.set_tag('kb.retry_errors', payload[:retry_errors].uniq.join(','))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -11,8 +11,6 @@ module KB
11
11
  kb_client.all(filters).map do |pet_parent|
12
12
  from_api pet_parent
13
13
  end
14
- rescue Faraday::ConnectionFailed => e
15
- raise e
16
14
  rescue Faraday::Error => e
17
15
  raise KB::Error.from_faraday(e)
18
16
  end
@@ -0,0 +1,88 @@
1
+ require 'socket'
2
+ require 'net/http'
3
+ require 'faraday/retry'
4
+
5
+ module KB
6
+ # Decides which failed KB calls the client retries.
7
+ #
8
+ # Two classes of transport failure, told apart by the underlying Ruby error
9
+ # rather than the Faraday class (adapters disagree on the Faraday class: the
10
+ # net_http adapter wraps Net::OpenTimeout as ConnectionFailed, the persistent
11
+ # one as TimeoutError):
12
+ #
13
+ # - never sent: TCP connect / TLS handshake did not complete, so KB cannot have
14
+ # seen the request. Safe to retry for every verb, POST included.
15
+ # - maybe sent: anything else the transport raises (read/write timeout, reset,
16
+ # EOF, TLS error mid-stream). KB may have processed it, so only GET/HEAD are
17
+ # retried. PUT/DELETE are left out on purpose: `upsert` and `merge!` are PUTs
18
+ # whose second run is not a no-op on KB's side, and a repeated DELETE would
19
+ # turn a success into a 404.
20
+ # A call that raised its own read budget (`read_timeout:`, e.g. 30s for
21
+ # birthdays) is not retried on these either, so its worst case isn't doubled.
22
+ #
23
+ # HTTP responses (4xx/5xx) are never retried: KB answered.
24
+ module RetryPolicy
25
+ NOT_SENT_ERRORS = [
26
+ Net::OpenTimeout,
27
+ Errno::ECONNREFUSED,
28
+ Errno::EHOSTUNREACH,
29
+ Errno::ENETUNREACH,
30
+ Errno::EADDRNOTAVAIL,
31
+ SocketError # DNS resolution
32
+ ].freeze
33
+ TRANSPORT_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError].freeze
34
+ MAYBE_SENT_VERBS = %i[get head].freeze
35
+
36
+ module_function
37
+
38
+ def retry?(verb, error, own_read_budget: false)
39
+ return false unless TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
40
+
41
+ not_sent?(error) || (MAYBE_SENT_VERBS.include?(verb) && !own_read_budget)
42
+ end
43
+
44
+ def not_sent?(error)
45
+ cause = root_cause(error)
46
+ NOT_SENT_ERRORS.any? { |klass| cause.is_a?(klass) }
47
+ end
48
+
49
+ # The Ruby error behind a Faraday error. `wrapped_exception` is Faraday's own
50
+ # explicit link to it (Ruby's `cause` is only whatever was being rescued at
51
+ # the raise, usually the same object). Faraday's adapters wrap the Ruby error
52
+ # one level deep, so one level is enough.
53
+ def root_cause(error)
54
+ (error.respond_to?(:wrapped_exception) && error.wrapped_exception) || error.cause || error
55
+ end
56
+
57
+ # Options for faraday-retry's middleware, read from KB.config.request.
58
+ def middleware_options
59
+ {
60
+ max: KB.config.request.retries,
61
+ interval: KB.config.request.retry_interval,
62
+ interval_randomness: 1, # 1x-2x the interval, so a burst of callers doesn't retry in lockstep
63
+ exceptions: TRANSPORT_ERRORS,
64
+ methods: [], # always ask retry_if
65
+ retry_if: lambda do |env, error|
66
+ retry?(env[:method], error, own_read_budget: env[:request].context&.dig(:kb_own_read_budget))
67
+ end,
68
+ retry_block: ->(env, _options, _retries_left, error) { record(env, error) }
69
+ }
70
+ end
71
+
72
+ # Hands the request.kb_client event payload (and whether the call set its own
73
+ # read budget) to the middleware through the request context, so each retry
74
+ # is reported on the call's own event.
75
+ def track(request, event, read_timeout = nil)
76
+ tracking = { kb_event: event, kb_own_read_budget: !read_timeout.nil? }
77
+ request.options.context = (request.options.context || {}).merge(tracking)
78
+ end
79
+
80
+ def record(env, error)
81
+ event = env[:request].context&.dig(:kb_event)
82
+ return unless event
83
+
84
+ event[:retries] = event.fetch(:retries, 0) + 1
85
+ (event[:retry_errors] ||= []) << root_cause(error).class.name
86
+ end
87
+ end
88
+ end
data/lib/kb/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module KB
2
- VERSION = '1.1.0'.freeze
2
+ VERSION = '1.3.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: barkibu-kb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Léo Figea
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: datadog
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: rake
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -247,6 +261,20 @@ dependencies:
247
261
  - - ">="
248
262
  - !ruby/object:Gem::Version
249
263
  version: '0'
264
+ - !ruby/object:Gem::Dependency
265
+ name: faraday-retry
266
+ requirement: !ruby/object:Gem::Requirement
267
+ requirements:
268
+ - - "~>"
269
+ - !ruby/object:Gem::Version
270
+ version: '1.0'
271
+ type: :runtime
272
+ prerelease: false
273
+ version_requirements: !ruby/object:Gem::Requirement
274
+ requirements:
275
+ - - "~>"
276
+ - !ruby/object:Gem::Version
277
+ version: '1.0'
250
278
  - !ruby/object:Gem::Dependency
251
279
  name: i18n
252
280
  requirement: !ruby/object:Gem::Requirement
@@ -313,6 +341,7 @@ files:
313
341
  - lib/kb/fake/bounded_context/pet_family/products.rb
314
342
  - lib/kb/fake/bounded_context/rest_resource.rb
315
343
  - lib/kb/inflections.rb
344
+ - lib/kb/instrumentation/datadog.rb
316
345
  - lib/kb/models.rb
317
346
  - lib/kb/models/assessment.rb
318
347
  - lib/kb/models/base_model.rb
@@ -337,6 +366,7 @@ files:
337
366
  - lib/kb/models/referral.rb
338
367
  - lib/kb/models/search_result.rb
339
368
  - lib/kb/models/symptom.rb
369
+ - lib/kb/retry_policy.rb
340
370
  - lib/kb/type/array_of_conditions_type.rb
341
371
  - lib/kb/type/array_of_strings_type.rb
342
372
  - lib/kb/type/array_of_symptoms_type.rb