faraday_dynamic_timeout 1.1.0 → 1.1.1

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: a0f2d0bde754191015cbd03f7cf425170a36d65215a843e881dfb0b27b44482b
4
- data.tar.gz: 88d59a4fc7dc1ea3addbf44c3043ae4fedea257a8ecf6bfe3acdceba60641fcf
3
+ metadata.gz: aba9d8b057d18dc3912677fd37e02148e5bc69ea78fbbb57cb8b4c3a790c4624
4
+ data.tar.gz: 3f17c539413375236113a2037a89717639b35669e0625926ea0d727525c37ab6
5
5
  SHA512:
6
- metadata.gz: 4ab05d2fda3b8dc85a2d54b18abe9b7570e5452de94a5bfbabf373350a54be4046c7fa9c9693f925d518fb0d09a6766b24069e2bd7c726b0020e6fc0960ad7a1
7
- data.tar.gz: b3e984c48e93af3aae37fac84a60efb18bd61eb8cf4f719b75e2b08187e7ec37bde20f3da87bb6950f3e11ce2175427bf0deaf326c88855b1cb4383771924f88
6
+ metadata.gz: 57bca13ad57839868f5dec34419271325e72fde1d0e45e0c7a7cd0bedce5350ae62a41ec7b026e95bf3c57fadf50dfc76f44ec630d0bf14d86191d41b9e5b07f
7
+ data.tar.gz: e0f0c827dfdd3b16a9f38e45f7127aa4781ab8d25060ad7bc4a47705e9d0d8410ea7a997abbb8f27d81e332dfd0ee6ea16602bc2f4c9b9e91201f3b8e2cd58ed
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.1.1
8
+
9
+ ### Added
10
+
11
+ - The middleware now fails open on any Redis error: instead of raising and taking down HTTP traffic, the request is made using the highest configured timeout without throttling.
12
+
13
+ ### Fixed
14
+
15
+ - Padded the TTL used to clean up orphaned Redis entries for throttle slots and request counters. Previously the TTL was set to the bucket timeout, but a request's total wall time can legitimately exceed the timeout (adapters apply it to the open/read/write phases individually), so slots could expire and be reclaimed while requests were still in flight, allowing the concurrency limit to be exceeded.
16
+ - A `Restrainer::ThrottledError` raised from within the request itself (e.g. from a nested middleware) is no longer mistaken for a full bucket. Previously such an error would cause the request to be retried on the next bucket, executing the HTTP request a second time.
17
+ - Made bucket configuration memoization safe when the configuration array is mutated concurrently by another thread.
18
+ - Added missing `require "securerandom"`, which previously only worked because a dependency loaded it.
19
+
20
+ ### Documentation
21
+
22
+ - Documented the `RequestInfo` fields, the behavior of `timed_out?` with respect to connection-level timeouts, the fact that `:callback` runs inline and can mask the request result if it raises, and the streaming/parallel adapter limitations.
23
+
24
+ ### Removed
25
+
26
+ - Removed unused internal `Strategy` class.
27
+
7
28
  ## 1.1.0
8
29
 
9
30
  ### Added
data/README.md CHANGED
@@ -52,11 +52,33 @@ In this example, the timeout will be set to 8 seconds if there are 5 or fewer re
52
52
 
53
53
  - `:before_request` - An optional callback that will be called before each request is made. The callback can be a `Proc` or any object that responds to `call`. It will be called before the request is made with the `Faraday::Env` object and the timeout being used. You can use this to make changes to the request based on the timeout being used. This can be used, for example, to add the timeout to the request payload.
54
54
 
55
- - `:callback` - An optional callback that will be called after each request. The callback can be a `Proc` or any object that responds to `call`. It will be called with a `FaradayDyamicTimeout::RequestInfo` argument. You can use this to log the number of concurrent requests or to report metrics to a monitoring system. This can be very useful for tuning the bucket settings.
55
+ - `:filter` - An optional callback that will be called before each request with the `Faraday::Env` object. The callback can be a `Proc` or any object that responds to `call`. If it returns a falsey value, the middleware will pass the request through without applying any timeout or throttling. You can use this to limit the middleware to specific requests (for example, only search requests).
56
+
57
+ - `:callback` - An optional callback that will be called after each request. The callback can be a `Proc` or any object that responds to `call`. It will be called with a `FaradayDynamicTimeout::RequestInfo` argument. You can use this to log the number of concurrent requests or to report metrics to a monitoring system. This can be very useful for tuning the bucket settings. Note that the callback runs inline before the response is returned, so any exception it raises will propagate out of the request and can mask the original result. If your callback might raise (for example, when a metrics backend is unavailable), rescue inside the callback itself.
58
+
59
+ ### The RequestInfo object
60
+
61
+ The `:callback` option receives a `FaradayDynamicTimeout::RequestInfo` object with details about the completed request:
62
+
63
+ - `duration` - The time spent making the request in seconds.
64
+ - `timeout` - The timeout that was applied, or `nil` if none was applied (for example when the request was throttled before it was made).
65
+ - `request_count` - The number of concurrent requests observed for the resource.
66
+ - `error` - The exception raised by the request, if any.
67
+ - `throttled?` - True if the request was rejected because all buckets were full.
68
+ - `timed_out?` - True if the request failed with a `Faraday::TimeoutError`. This reflects the read/request timeout that this middleware sets. Connection-level timeouts are reported by adapters as `Faraday::ConnectionFailed` on some adapters rather than `Faraday::TimeoutError`, so they are not counted as timeouts here; inspect `error` directly if you need to distinguish them.
69
+ - `error?` - True if the request raised any error.
70
+
71
+ ### Redis availability
72
+
73
+ The middleware depends on Redis to coordinate counts across processes, but Redis problems will not take down your HTTP traffic. If any Redis call fails (a connection failure, a timeout, a command error, etc.), the middleware fails open: the request is made using the highest configured timeout without throttling, and the request count reported to the `:callback` will be `1`. Cleanup of Redis bookkeeping is best effort and any leftover entries expire automatically.
74
+
75
+ ### Limitations
76
+
77
+ - **Streaming and parallel adapters.** A request occupies its bucket slot only for the duration of the underlying `app.call`. If you use a streaming response where the body is consumed after the middleware returns, or a Faraday adapter running requests in parallel, the concurrency accounting will not reflect the time spent streaming or the true number of in-flight requests.
56
78
 
57
79
  ### Capacity Strategy
58
80
 
59
- You can use the `FaraadyDynamicTimeout::CapacityStrategy` class to build a bucket configuration based on the current capacity of your application rather than hard coding bucket limits. This can be useful if you have a system that can scale up and down based on load. It works by estimating the total number of threads available in your application and uses that value to calculate bucket limits based on a percentage provided by the `:capacity` option.
81
+ You can use the `FaradayDynamicTimeout::CapacityStrategy` class to build a bucket configuration based on the current capacity of your application rather than hard coding bucket limits. This can be useful if you have a system that can scale up and down based on load. It works by estimating the total number of threads available in your application and uses that value to calculate bucket limits based on a percentage provided by the `:capacity` option.
60
82
 
61
83
  ```ruby
62
84
  capacity = FaradayDynamicTimeout::CapacityStrategy.new(
@@ -86,7 +108,7 @@ redis = Redis.new(url: ENV.fetch("REDIS_URL"))
86
108
  # processing if the request times out.
87
109
  set_query_timeout = ->(env, timeout) do
88
110
  query_params = (Faraday::Utils.parse_query(env.url.query) || {})
89
- query_params["timeout"] = "#{timeout}s"
111
+ query_params["timeout"] = "#{(timeout * 1000).ceil}ms"
90
112
  env.url.query = Faraday::Utils.build_query(query_params)
91
113
  end
92
114
 
@@ -105,14 +127,14 @@ end
105
127
  client = OpenSearch::Client.new(host: 'localhost', port: '9200') do |faraday|
106
128
  faraday.request :dynamic_timeout,
107
129
  buckets: [
108
- {timeout: 8, max_requests: 5},
109
- {timeout: 1, max_requests: 10},
110
- {timeout: 0.5, max_requests: 20}
130
+ {timeout: 8, limit: 5},
131
+ {timeout: 1, limit: 10},
132
+ {timeout: 0.5, limit: 20}
111
133
  ],
112
134
  name: "opensearch",
113
135
  redis: redis,
114
136
  filter: ->(env) { env.url.path.end_with?("/_search") },
115
- before_request: set_payload_timeout,
137
+ before_request: set_query_timeout,
116
138
  callback: metrics_callback
117
139
  end
118
140
  ```
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.1
@@ -14,6 +14,8 @@ Gem::Specification.new do |spec|
14
14
  .
15
15
  Gemfile
16
16
  Gemfile.lock
17
+ Appraisals
18
+ gemfiles/
17
19
  Rakefile
18
20
  bin/
19
21
  spec/
@@ -29,5 +31,5 @@ Gem::Specification.new do |spec|
29
31
 
30
32
  spec.add_development_dependency "bundler"
31
33
 
32
- spec.required_ruby_version = ">= 2.5"
34
+ spec.required_ruby_version = ">= 2.6"
33
35
  end
@@ -11,13 +11,15 @@ module FaradayDynamicTimeout
11
11
  end
12
12
 
13
13
  @memoized_buckets = []
14
- @mutex = Mutex.new
15
14
  end
16
15
 
17
16
  def call(env)
18
- buckets = sorted_buckets
17
+ # A Redis outage must never take down HTTP traffic, so if building the bucket
18
+ # configuration touches Redis (e.g. a capacity based strategy) and it is
19
+ # unavailable, fail open and let the request through with no dynamic timeout.
20
+ buckets = safe_redis { sorted_buckets }
19
21
  redis = redis_client
20
- return app.call(env) if !enabled?(env) || buckets.empty? || redis.nil?
22
+ return app.call(env) if !enabled?(env) || buckets.nil? || buckets.empty? || redis.nil?
21
23
 
22
24
  error = nil
23
25
  bucket_timeout = nil
@@ -55,15 +57,16 @@ module FaradayDynamicTimeout
55
57
  config = config.call if config.respond_to?(:call)
56
58
  config = Array(config)
57
59
  memoized_config, memoized_buckets = @memoized_buckets
58
-
59
- if config == memoized_config
60
- memoized_buckets
61
- else
62
- duplicated_config = @mutex.synchronize { config.collect(&:dup) }
63
- buckets = Bucket.from_hashes(duplicated_config)
64
- @memoized_buckets = [duplicated_config, buckets]
65
- buckets
66
- end
60
+ return memoized_buckets if config == memoized_config
61
+
62
+ # Duplicate the config before storing it so a caller mutating the array or its
63
+ # hashes cannot change the memoized snapshot out from under us. The memo is
64
+ # published with a single assignment; concurrent threads may redundantly rebuild
65
+ # it, which is harmless.
66
+ config = config.collect(&:dup)
67
+ buckets = Bucket.from_hashes(config)
68
+ @memoized_buckets = [config, buckets]
69
+ buckets
67
70
  end
68
71
 
69
72
  def enabled?(env)
@@ -84,10 +87,13 @@ module FaradayDynamicTimeout
84
87
  retval = yield(bucket.timeout)
85
88
  break
86
89
  else
87
- restrainer = Restrainer.new(restrainer_name(uri, bucket.timeout), limit: bucket.limit, timeout: bucket.timeout, redis: redis)
90
+ restrainer = Restrainer.new(restrainer_name(uri, bucket.timeout), limit: bucket.limit, timeout: slot_ttl(bucket.timeout), redis: redis)
88
91
  begin
89
- retval = restrainer.throttle { yield(bucket.timeout) }
90
- break
92
+ # Acquire the slot explicitly rather than using Restrainer#throttle so that a
93
+ # ThrottledError raised from within the request itself (e.g. from a nested
94
+ # middleware) is not mistaken for this bucket being full, which would retry
95
+ # the request on the next bucket and execute it a second time.
96
+ process_id = restrainer.lock!
91
97
  rescue Restrainer::ThrottledError
92
98
  total_requests += bucket.limit
93
99
  if buckets.empty?
@@ -96,6 +102,21 @@ module FaradayDynamicTimeout
96
102
  request_count = [request_count, total_requests + 1].max
97
103
  raise ThrottledError.new("Request to #{base_url(uri)} aborted due to #{request_count} concurrent requests", request_count: request_count)
98
104
  end
105
+ rescue Redis::BaseError
106
+ # Redis is unavailable, so throttling cannot be enforced. Fail open using the
107
+ # current (highest available) timeout rather than failing the request.
108
+ retval = yield(bucket.timeout)
109
+ break
110
+ else
111
+ begin
112
+ retval = yield(bucket.timeout)
113
+ ensure
114
+ # Releasing the slot is best effort; if Redis is unavailable the slot will
115
+ # expire on its own via the TTL. A cleanup failure must not mask the result
116
+ # of a request that has already been made.
117
+ safe_redis { restrainer.release!(process_id) }
118
+ end
119
+ break
99
120
  end
100
121
  end
101
122
  end
@@ -113,20 +134,46 @@ module FaradayDynamicTimeout
113
134
  option(:before_request)&.call(env, timeout)
114
135
  end
115
136
 
137
+ # The TTL used to clean up orphaned slot and counter entries in Redis. The bucket
138
+ # timeout only bounds each phase of the request (open/read/write), not its total
139
+ # wall time, so the TTL is padded to avoid expiring entries for requests that are
140
+ # still legitimately in flight.
141
+ def slot_ttl(timeout)
142
+ [timeout * 3, 60].max
143
+ end
144
+
116
145
  # Track how many requests are currently being executed only if a callback has been configured.
146
+ #
147
+ # Each Redis operation is guarded so that a Redis outage degrades to a pass through
148
+ # (reporting a request count of 1) rather than blocking the request. The counter entry
149
+ # is released on the way out, but only when it was successfully added.
117
150
  def count_request(uri, redis, buckets, callback)
118
- if callback
119
- ttl = buckets.last.timeout
120
- ttl = 60 if ttl <= 0
121
- request_counter = Counter.new(name: request_counter_name(uri), redis: redis, ttl: ttl)
122
- request_counter.execute do
123
- yield request_counter.value
124
- end
125
- else
126
- yield 1
151
+ return yield(1) unless callback
152
+
153
+ # The counter entry is added before a bucket has been selected, so the TTL must
154
+ # conservatively cover the highest timeout (the last bucket); a shorter TTL could
155
+ # expire entries for requests still legitimately in flight. The TTL only comes
156
+ # into play for orphaned entries since entries are normally removed when the
157
+ # request finishes.
158
+ ttl = slot_ttl(buckets.last.timeout)
159
+ request_counter = Counter.new(name: request_counter_name(uri), redis: redis, ttl: ttl)
160
+ id = safe_redis { request_counter.track! }
161
+ begin
162
+ yield(safe_redis { request_counter.value } || 1)
163
+ ensure
164
+ safe_redis { request_counter.release!(id) } if id
127
165
  end
128
166
  end
129
167
 
168
+ # Run a block that talks to Redis, returning nil instead of raising if the Redis
169
+ # call fails for any reason. Used to keep Redis problems from taking down HTTP
170
+ # traffic.
171
+ def safe_redis
172
+ yield
173
+ rescue Redis::BaseError
174
+ nil
175
+ end
176
+
130
177
  def request_counter_name(uri)
131
178
  "#{redis_key_namespace(uri)}.requests"
132
179
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "restrainer"
5
+ require "securerandom"
5
6
  require "socket"
6
7
 
7
8
  require_relative "faraday_dynamic_timeout/bucket"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faraday_dynamic_timeout
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-05-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: faraday
@@ -52,33 +51,27 @@ dependencies:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
53
  version: '0'
55
- description:
56
54
  email:
57
55
  - bbdurand@gmail.com
58
56
  executables: []
59
57
  extensions: []
60
58
  extra_rdoc_files: []
61
59
  files:
62
- - Appraisals
63
60
  - CHANGELOG.md
64
61
  - MIT-LICENSE.txt
65
62
  - README.md
66
63
  - VERSION
67
64
  - faraday_dynamic_timeout.gemspec
68
- - gemfiles/faraday_1.gemfile
69
- - gemfiles/faraday_2.gemfile
70
65
  - lib/faraday_dynamic_timeout.rb
71
66
  - lib/faraday_dynamic_timeout/bucket.rb
72
67
  - lib/faraday_dynamic_timeout/capacity_strategy.rb
73
68
  - lib/faraday_dynamic_timeout/counter.rb
74
69
  - lib/faraday_dynamic_timeout/middleware.rb
75
70
  - lib/faraday_dynamic_timeout/request_info.rb
76
- - lib/faraday_dynamic_timeout/strategy.rb
77
71
  homepage: https://github.com/bdurand/faraday_dynamic_timeout
78
72
  licenses:
79
73
  - MIT
80
74
  metadata: {}
81
- post_install_message:
82
75
  rdoc_options: []
83
76
  require_paths:
84
77
  - lib
@@ -86,15 +79,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
79
  requirements:
87
80
  - - ">="
88
81
  - !ruby/object:Gem::Version
89
- version: '2.5'
82
+ version: '2.6'
90
83
  required_rubygems_version: !ruby/object:Gem::Requirement
91
84
  requirements:
92
85
  - - ">="
93
86
  - !ruby/object:Gem::Version
94
87
  version: '0'
95
88
  requirements: []
96
- rubygems_version: 3.4.10
97
- signing_key:
89
+ rubygems_version: 4.0.3
98
90
  specification_version: 4
99
91
  summary: Faraday middleware to dynamically set a request timeout based on the number
100
92
  of concurrent requests and throttle the number of requests that can be made.
data/Appraisals DELETED
@@ -1,7 +0,0 @@
1
- appraise "faraday_2" do
2
- gem "faraday", "~> 2.0"
3
- end
4
-
5
- appraise "faraday_1" do
6
- gem "faraday", "~> 1.0"
7
- end
@@ -1,15 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "webmock"
6
- gem "appraisal"
7
- gem "dotenv"
8
- gem "rake"
9
- gem "rspec", "~> 3.10"
10
- gem "standard", "~> 1.0"
11
- gem "simplecov", "~> 0.21", require: false
12
- gem "yard"
13
- gem "faraday", "~> 1.0"
14
-
15
- gemspec path: "../"
@@ -1,15 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "webmock"
6
- gem "appraisal"
7
- gem "dotenv"
8
- gem "rake"
9
- gem "rspec", "~> 3.10"
10
- gem "standard", "~> 1.0"
11
- gem "simplecov", "~> 0.21", require: false
12
- gem "yard"
13
- gem "faraday", "~> 2.0"
14
-
15
- gemspec path: "../"
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FaradayDynamicTimeout
4
- class Strategy
5
- def initialize(buckets:, options: {})
6
- @config = buckets
7
- @options = options
8
- end
9
- end
10
- end