simple_http_service 0.2.1 → 3.0.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: b48b86c315bf9ac3de107f1cd64d90b8675388691f29a4a11e73e1c544d25085
4
- data.tar.gz: 9ced494e26dc8db12a0067e035d8934c5950321eaa41639da6ae37b6f1baec05
3
+ metadata.gz: 2bca219ad4f3a280dfea6a35fa241481c04414f2e85f553756e7a8612c8f81da
4
+ data.tar.gz: 647ce34790aaea4de53b84f852415d274b26cb51bd093173b8d2b5612381bf92
5
5
  SHA512:
6
- metadata.gz: 13a4f3559c1325a6e6c2a8b6ee81664a6a074ceed6de198473c138071982052800bcdf2ed7110bfaf4d58ccdc5a06541feeb4458bac94880a8285328349f580b
7
- data.tar.gz: 4d915ad4546c9fcb7e9734c5820ccc4e9afb9322cbf1a50b89351e83eb7e968c8bef14308ed19f5dd9104904f6b2be8dce3bf712ab74303978643f194c03c44d
6
+ metadata.gz: 0c2dd8b1ea07c4a43612df855b64595447e219ffa950c3340c8fa271e4ac72323e7ccc507db62f6f4266924e2f8d194dff109075ebb6432dfbb2a02622266819
7
+ data.tar.gz: dfeea32e3a2db839a96165cd9b18da540d014d7a7115a3afb0ccc23f4272c87fdd0b901c71cae80c910a8b9bb49fbe94f3f569d1b19935c75b87701cf3dc7611
data/.gitignore CHANGED
@@ -9,4 +9,12 @@
9
9
  # rspec failure tracking
10
10
  .rspec_status
11
11
 
12
- .idea
12
+ .idea
13
+
14
+ # CCE (code-context-engine)
15
+ # CCE local cache (per-machine, not for version control)
16
+ .cce/
17
+ # Claude Code local settings written by cce init
18
+ .claude/settings.local.json
19
+ # .mcp.json contains absolute paths regenerated by `cce init`
20
+ .mcp.json
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- simple_http_service (0.2.0)
4
+ simple_http_service (3.0.0)
5
5
  net-http (>= 0.4)
6
6
 
7
7
  GEM
@@ -32,7 +32,7 @@ PLATFORMS
32
32
 
33
33
  DEPENDENCIES
34
34
  bundler (~> 2.0)
35
- rake (~> 13.0)
35
+ rake (>= 10.0)
36
36
  rspec (~> 3.13.0)
37
37
  rspec-core (~> 3.13.1)
38
38
  simple_http_service!
data/README.md CHANGED
@@ -45,6 +45,10 @@ client = SimpleHttpService.new(
45
45
  max_retries: 3,
46
46
  additional_headers: {
47
47
  'X-Request-Id': '12345'
48
+ },
49
+ rate_limit: {
50
+ limit: 2,
51
+ interval: 60
48
52
  }
49
53
  )
50
54
  ```
@@ -65,6 +69,50 @@ puts response.body
65
69
  - `max_retries`: The number of times to retry the request in case of failure.
66
70
  - `request_body`: The body of the request (used for POST and PUT requests).
67
71
  - `additional_headers`: Additional headers to include in the request.
72
+ - `rate_limit`: A hash enabling client side throttling. Omit it and no limiting is applied.
73
+ - `limit` (required to enable): Maximum number of requests allowed per window.
74
+ - `interval`: Length of the window in seconds (default is `60`).
75
+ - `wait`: When `true`, `call` sleeps until a slot frees up instead of raising (default is `false`).
76
+ - `key`: Bucket the window under a custom key (default is `scheme://host:port`).
77
+
78
+ ### Rate Limiting
79
+ Rate limiting is opt in. Pass a `rate_limit` hash to cap how often the client fires requests:
80
+
81
+ ```ruby
82
+ client = SimpleHttpService.new(
83
+ url: 'https://api.example.com/endpoint',
84
+ http_method: :get,
85
+ rate_limit: { limit: 2, interval: 60 }
86
+ )
87
+
88
+ 2.times { client.call }
89
+ client.call # raises SimpleHttpService::RateLimitExceeded
90
+ ```
91
+
92
+ The window is a sliding one, shared across every client in the process that resolves to the same
93
+ key, so two clients pointing at the same host draw from the same budget. Use `key` to share (or
94
+ separate) buckets explicitly.
95
+
96
+ To block instead of raising when the budget is used up:
97
+
98
+ ```ruby
99
+ client = SimpleHttpService.new(
100
+ url: 'https://api.example.com/endpoint',
101
+ http_method: :get,
102
+ rate_limit: { limit: 2, interval: 60, wait: true }
103
+ )
104
+ ```
105
+
106
+ `SimpleHttpService::RateLimitExceeded` inherits from `SimpleHttpService::Error` and exposes
107
+ `retry_after`, the number of seconds until the next slot opens:
108
+
109
+ ```ruby
110
+ begin
111
+ client.call
112
+ rescue SimpleHttpService::RateLimitExceeded => e
113
+ puts "Throttled, retry in #{e.retry_after.ceil}s"
114
+ end
115
+ ```
68
116
 
69
117
  ### Example
70
118
  Here's a complete example of using `SimpleHttpService` to make a `GET` request:
@@ -1,8 +1,10 @@
1
1
  require 'net/http'
2
+ require 'simple_http_service/rate_limiter'
3
+
2
4
  module SimpleHttpService
3
5
  class Client
4
6
  attr_accessor :uri, :headers, :http_method, :open_timeout, :read_timeout, :write_timeout,
5
- :max_retries, :request_body, :additional_headers
7
+ :max_retries, :request_body, :additional_headers, :rate_limit
6
8
 
7
9
  def initialize(opts)
8
10
  raise 'URL must be present' unless opts[:url]
@@ -17,9 +19,11 @@ module SimpleHttpService
17
19
  @write_timeout = opts[:write_timeout]
18
20
  @max_retries = opts[:max_retries] || 1
19
21
  @additional_headers = opts[:additional_headers] || {}
22
+ @rate_limit = opts[:rate_limit] || {}
20
23
  end
21
24
 
22
25
  def call
26
+ throttle
23
27
  enable_ssl
24
28
  set_headers
25
29
  set_timeout
@@ -27,8 +31,23 @@ module SimpleHttpService
27
31
  http.request(request)
28
32
  end
29
33
 
34
+ # Limiter backing this client, or nil unless rate_limit[:limit] was passed in.
35
+ def rate_limiter
36
+ return unless rate_limit[:limit]
37
+
38
+ @rate_limiter ||= RateLimiter.for(
39
+ rate_limit[:key] || "#{uri.scheme}://#{uri.host}:#{uri.port}",
40
+ limit: rate_limit[:limit],
41
+ interval: rate_limit[:interval] || RateLimiter::DEFAULT_INTERVAL
42
+ )
43
+ end
44
+
30
45
  private
31
46
 
47
+ def throttle
48
+ rate_limiter&.acquire(wait: rate_limit[:wait] || false)
49
+ end
50
+
32
51
  def set_headers
33
52
  request["Accept"] = headers[:accept] if headers[:accept]
34
53
  request["Authorization"] = headers[:authorization] if headers[:authorization]
@@ -0,0 +1,107 @@
1
+ module SimpleHttpService
2
+ class Error < StandardError; end
3
+
4
+ # Raised when a request would exceed the configured rate limit and
5
+ # the client is not configured to wait for a free slot.
6
+ class RateLimitExceeded < Error
7
+ attr_reader :retry_after
8
+
9
+ def initialize(msg, retry_after: nil)
10
+ @retry_after = retry_after
11
+ super(msg)
12
+ end
13
+ end
14
+
15
+ # Sliding-window rate limiter, shared per key across all Client instances
16
+ # in the process. Thread safe.
17
+ class RateLimiter
18
+ DEFAULT_INTERVAL = 60
19
+
20
+ attr_reader :limit, :interval
21
+
22
+ class << self
23
+ # Returns the limiter registered for +key+, creating it on first use.
24
+ def for(key, limit:, interval: DEFAULT_INTERVAL)
25
+ registry_mutex.synchronize do
26
+ registry[key] ||= new(limit: limit, interval: interval)
27
+ end
28
+ end
29
+
30
+ # Drops every registered limiter. Mainly useful in tests.
31
+ def reset!
32
+ registry_mutex.synchronize { registry.clear }
33
+ end
34
+
35
+ private
36
+
37
+ def registry
38
+ @registry ||= {}
39
+ end
40
+
41
+ def registry_mutex
42
+ @registry_mutex ||= Mutex.new
43
+ end
44
+ end
45
+
46
+ def initialize(limit:, interval: DEFAULT_INTERVAL)
47
+ raise 'rate limit must be a positive integer' unless limit.to_i.positive?
48
+ raise 'rate limit interval must be positive' unless interval.to_f.positive?
49
+
50
+ @limit = limit.to_i
51
+ @interval = interval.to_f
52
+ @timestamps = []
53
+ @mutex = Mutex.new
54
+ end
55
+
56
+ # Consumes one slot. Raises RateLimitExceeded when the window is full,
57
+ # unless +wait+ is true, in which case it sleeps until a slot frees up.
58
+ def acquire(wait: false)
59
+ loop do
60
+ retry_after = try_acquire
61
+ return true unless retry_after
62
+
63
+ unless wait
64
+ raise RateLimitExceeded.new(
65
+ "rate limit of #{limit} request(s) per #{interval.round} seconds exceeded, " \
66
+ "retry in #{retry_after.ceil} second(s)",
67
+ retry_after: retry_after
68
+ )
69
+ end
70
+
71
+ sleep(retry_after)
72
+ end
73
+ end
74
+
75
+ # Slots still available in the current window.
76
+ def remaining
77
+ @mutex.synchronize do
78
+ prune
79
+ limit - @timestamps.size
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Returns nil when a slot was taken, otherwise seconds until the next one.
86
+ def try_acquire
87
+ @mutex.synchronize do
88
+ prune
89
+ if @timestamps.size < limit
90
+ @timestamps << now
91
+ return nil
92
+ end
93
+
94
+ [@timestamps.first + interval - now, 0.001].max
95
+ end
96
+ end
97
+
98
+ def prune
99
+ cutoff = now - interval
100
+ @timestamps.shift while @timestamps.first && @timestamps.first <= cutoff
101
+ end
102
+
103
+ def now
104
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ end
106
+ end
107
+ end
@@ -1,3 +1,3 @@
1
1
  module SimpleHttpService
2
- VERSION = "0.2.1"
2
+ VERSION = "3.0.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_http_service
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gokul (gklsan)
@@ -105,6 +105,7 @@ files:
105
105
  - doc/top-level-namespace.html
106
106
  - lib/simple_http_service.rb
107
107
  - lib/simple_http_service/client.rb
108
+ - lib/simple_http_service/rate_limiter.rb
108
109
  - lib/simple_http_service/version.rb
109
110
  - simple_http_service.gemspec
110
111
  homepage: https://github.com/gklsan/simple_http_service