fbe 0.23.4 → 0.23.6

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: ebe33dc4f8380eeabe72495993dea6fe88842c8d062d7f41ce43111e664ba825
4
- data.tar.gz: 2763f2c522defeb27a03bda9b15ec07d690721a663cb5bda91c2b07ea69b0b49
3
+ metadata.gz: bed0dbc2fe40503e276fad9620585658b3781cc02ce1fd171136ec9cae18bb67
4
+ data.tar.gz: 7ff721a9a5550eb960e4f98be1148b68e67a0198f821ba7485683022b0af2d1c
5
5
  SHA512:
6
- metadata.gz: de77042cef4d5d7110fb13584e0b9765537dc156da8150c355a97f6d2b0b3212f8b46e8d20b24f96eacf3e1b93a30728e31514b731ca4fb6e92955046cc3f4b9
7
- data.tar.gz: 5693137ce96120cad70e867b3e85655e29a4bc7d00e30435203f5c7b871e4b1248cd277190bb123dbcbe536beccbe99b72a4257da399a10f221ed0e4048e45b3
6
+ metadata.gz: 6f9a87c2d309bd1806aff0184d1173f903b8e5612b6564de1e7b1a4fa9c932c88793f294e33a341255d810095a161d0bdb492492510ffd19c6ffae2c14724609
7
+ data.tar.gz: c91361e3272512623ca9f3c7f7b35c056bd55e37a3baf5635525974043fc62a1d80eac2dd533385bd124c91b0ccb54167319f2691e1e439be47c30c751ae3873
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'faraday'
7
+ require 'json'
8
+ require_relative '../../fbe'
9
+ require_relative '../../fbe/middleware'
10
+
11
+ # Faraday middleware that caches GitHub API rate limit information.
12
+ #
13
+ # This middleware intercepts calls to the /rate_limit endpoint and caches
14
+ # the results locally. It tracks the remaining requests count and decrements
15
+ # it for each API call. Every 100 requests, it refreshes the cached data
16
+ # by allowing the request to pass through to the GitHub API.
17
+ #
18
+ # @example Usage in Faraday middleware stack
19
+ # connection = Faraday.new do |f|
20
+ # f.use Fbe::Middleware::RateLimit
21
+ # end
22
+ #
23
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
24
+ # Copyright:: Copyright (c) 2024-2025 Zerocracy
25
+ # License:: MIT
26
+ class Fbe::Middleware::RateLimit < Faraday::Middleware
27
+ # Initializes the rate limit middleware.
28
+ #
29
+ # @param [Object] app The next middleware in the stack
30
+ def initialize(app)
31
+ super
32
+ @cached_response = nil
33
+ @remaining_count = nil
34
+ @request_counter = 0
35
+ end
36
+
37
+ # Processes the HTTP request and handles rate limit caching.
38
+ #
39
+ # @param [Faraday::Env] env The request environment
40
+ # @return [Faraday::Response] The response from cache or the next middleware
41
+ def call(env)
42
+ if env.url.path == '/rate_limit'
43
+ handle_rate_limit_request(env)
44
+ else
45
+ track_request
46
+ @app.call(env)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Handles requests to the rate_limit endpoint.
53
+ #
54
+ # @param [Faraday::Env] env The request environment
55
+ # @return [Faraday::Response] Cached or fresh response
56
+ def handle_rate_limit_request(env)
57
+ if @cached_response.nil? || @request_counter >= 100
58
+ response = @app.call(env)
59
+ @cached_response = response.dup
60
+ @remaining_count = extract_remaining_count(response)
61
+ @request_counter = 0
62
+ response
63
+ else
64
+ response = @cached_response.dup
65
+ update_remaining_count(response)
66
+ Faraday::Response.new(response_env(env, response))
67
+ end
68
+ end
69
+
70
+ # Tracks non-rate_limit requests and decrements counter.
71
+ def track_request
72
+ return if @remaining_count.nil?
73
+ @remaining_count -= 1 if @remaining_count.positive?
74
+ @request_counter += 1
75
+ end
76
+
77
+ # Extracts the remaining count from the response body.
78
+ #
79
+ # @param [Faraday::Response] response The API response
80
+ # @return [Integer] The remaining requests count
81
+ def extract_remaining_count(response)
82
+ body = response.body
83
+ if body.is_a?(String)
84
+ begin
85
+ body = JSON.parse(body)
86
+ rescue JSON::ParserError
87
+ return 0
88
+ end
89
+ end
90
+ return 0 unless body.is_a?(Hash)
91
+ body.dig('rate', 'remaining') || 0
92
+ end
93
+
94
+ # Updates the remaining count in the response body.
95
+ #
96
+ # @param [Faraday::Response] response The cached response to update
97
+ def update_remaining_count(response)
98
+ body = response.body
99
+ original_was_string = body.is_a?(String)
100
+ if original_was_string
101
+ begin
102
+ body = JSON.parse(body)
103
+ rescue JSON::ParserError
104
+ return
105
+ end
106
+ end
107
+ return unless body.is_a?(Hash) && body['rate']
108
+ body['rate']['remaining'] = @remaining_count
109
+ return unless original_was_string
110
+ response.instance_variable_set(:@body, body.to_json)
111
+ end
112
+
113
+ # Creates a response environment for the cached response.
114
+ #
115
+ # @param [Faraday::Env] env The original request environment
116
+ # @param [Faraday::Response] response The cached response
117
+ # @return [Hash] Response environment hash
118
+ def response_env(env, response)
119
+ headers = response.headers.dup
120
+ headers['x-ratelimit-remaining'] = @remaining_count.to_s if @remaining_count
121
+ {
122
+ method: env.method,
123
+ url: env.url,
124
+ request_headers: env.request_headers,
125
+ request_body: env.request_body,
126
+ status: response.status,
127
+ response_headers: headers,
128
+ body: response.body
129
+ }
130
+ end
131
+ end
data/lib/fbe/octo.rb CHANGED
@@ -20,6 +20,7 @@ require 'verbose'
20
20
  require_relative '../fbe'
21
21
  require_relative 'middleware'
22
22
  require_relative 'middleware/formatter'
23
+ require_relative 'middleware/rate_limit'
23
24
  require_relative 'middleware/sqlite_store'
24
25
  require_relative 'middleware/trace'
25
26
 
@@ -107,6 +108,7 @@ def Fbe.octo(options: $options, global: $global, loog: $loog)
107
108
  end
108
109
  builder.use(Octokit::Response::RaiseError)
109
110
  builder.use(Faraday::Response::Logger, loog, formatter: Fbe::Middleware::Formatter)
111
+ builder.use(Fbe::Middleware::RateLimit)
110
112
  builder.use(Fbe::Middleware::Trace, trace)
111
113
  builder.adapter(Faraday.default_adapter)
112
114
  end
data/lib/fbe.rb CHANGED
@@ -10,5 +10,5 @@
10
10
  # License:: MIT
11
11
  module Fbe
12
12
  # Current version of the gem (changed by +.rultor.yml+ on every release)
13
- VERSION = '0.23.4' unless const_defined?(:VERSION)
13
+ VERSION = '0.23.6' unless const_defined?(:VERSION)
14
14
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require 'faraday'
7
+ require 'webmock'
8
+ require_relative '../../test__helper'
9
+ require_relative '../../../lib/fbe'
10
+ require_relative '../../../lib/fbe/middleware'
11
+ require_relative '../../../lib/fbe/middleware/rate_limit'
12
+
13
+ # Test.
14
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
15
+ # Copyright:: Copyright (c) 2024-2025 Zerocracy
16
+ # License:: MIT
17
+ class RateLimitTest < Fbe::Test
18
+ def test_caches_rate_limit_response_on_first_call
19
+ rate_limit_response = {
20
+ 'rate' => {
21
+ 'limit' => 5000,
22
+ 'remaining' => 4999,
23
+ 'reset' => 1_672_531_200
24
+ }
25
+ }
26
+ stub_request(:get, 'https://api.github.com/rate_limit')
27
+ .to_return(status: 200, body: rate_limit_response.to_json, headers: { 'Content-Type' => 'application/json' })
28
+ conn = create_connection
29
+ response = conn.get('/rate_limit')
30
+ assert_equal 200, response.status
31
+ assert_equal 4999, response.body['rate']['remaining']
32
+ end
33
+
34
+ def test_returns_cached_response_on_subsequent_calls
35
+ rate_limit_response = {
36
+ 'rate' => {
37
+ 'limit' => 5000,
38
+ 'remaining' => 4999,
39
+ 'reset' => 1_672_531_200
40
+ }
41
+ }
42
+ stub_request(:get, 'https://api.github.com/rate_limit')
43
+ .to_return(status: 200, body: rate_limit_response.to_json, headers: { 'Content-Type' => 'application/json' })
44
+ .times(1)
45
+ conn = create_connection
46
+ conn.get('/rate_limit')
47
+ response = conn.get('/rate_limit')
48
+ assert_equal 200, response.status
49
+ assert_equal 4999, response.body['rate']['remaining']
50
+ assert_requested :get, 'https://api.github.com/rate_limit', times: 1
51
+ end
52
+
53
+ def test_decrements_remaining_count_for_non_rate_limit_requests
54
+ rate_limit_response = {
55
+ 'rate' => {
56
+ 'limit' => 5000,
57
+ 'remaining' => 4999,
58
+ 'reset' => 1_672_531_200
59
+ }
60
+ }
61
+ stub_request(:get, 'https://api.github.com/rate_limit')
62
+ .to_return(status: 200, body: rate_limit_response.to_json, headers: { 'Content-Type' => 'application/json',
63
+ 'X-RateLimit-Remaining' => '4999' })
64
+ stub_request(:get, 'https://api.github.com/user')
65
+ .to_return(status: 200, body: '{"login": "test"}', headers: { 'Content-Type' => 'application/json' })
66
+ conn = create_connection
67
+ conn.get('/rate_limit')
68
+ conn.get('/user')
69
+ response = conn.get('/rate_limit')
70
+ assert_equal 4998, response.body['rate']['remaining']
71
+ assert_equal '4998', response.headers['x-ratelimit-remaining']
72
+ end
73
+
74
+ def test_refreshes_cache_after_hundred_requests
75
+ rate_limit_response = {
76
+ 'rate' => {
77
+ 'limit' => 5000,
78
+ 'remaining' => 4999,
79
+ 'reset' => 1_672_531_200
80
+ }
81
+ }
82
+ refreshed_response = {
83
+ 'rate' => {
84
+ 'limit' => 5000,
85
+ 'remaining' => 4950,
86
+ 'reset' => 1_672_531_200
87
+ }
88
+ }
89
+ stub_request(:get, 'https://api.github.com/rate_limit')
90
+ .to_return(status: 200, body: rate_limit_response.to_json, headers: { 'Content-Type' => 'application/json' })
91
+ .then
92
+ .to_return(status: 200, body: refreshed_response.to_json, headers: { 'Content-Type' => 'application/json' })
93
+ stub_request(:get, 'https://api.github.com/user')
94
+ .to_return(status: 200, body: '{"login": "test"}', headers: { 'Content-Type' => 'application/json' })
95
+ .times(100)
96
+ conn = create_connection
97
+ conn.get('/rate_limit')
98
+ 100.times { conn.get('/user') }
99
+ response = conn.get('/rate_limit')
100
+ assert_equal 4950, response.body['rate']['remaining']
101
+ assert_requested :get, 'https://api.github.com/rate_limit', times: 2
102
+ end
103
+
104
+ def test_handles_response_without_rate_data
105
+ stub_request(:get, 'https://api.github.com/rate_limit')
106
+ .to_return(status: 200, body: '{}', headers: { 'Content-Type' => 'application/json' })
107
+ conn = create_connection
108
+ response = conn.get('/rate_limit')
109
+ assert_equal 200, response.status
110
+ assert_empty(response.body)
111
+ end
112
+
113
+ def test_ignores_non_hash_response_body
114
+ stub_request(:get, 'https://api.github.com/rate_limit')
115
+ .to_return(status: 200, body: 'invalid json', headers: { 'Content-Type' => 'text/plain' })
116
+ conn = create_connection
117
+ response = conn.get('/rate_limit')
118
+ assert_equal 200, response.status
119
+ assert_equal 'invalid json', response.body
120
+ end
121
+
122
+ def test_handles_zero_remaining_count
123
+ rate_limit_response = {
124
+ 'rate' => {
125
+ 'limit' => 5000,
126
+ 'remaining' => 1,
127
+ 'reset' => 1_672_531_200
128
+ }
129
+ }
130
+ stub_request(:get, 'https://api.github.com/rate_limit')
131
+ .to_return(status: 200, body: rate_limit_response.to_json, headers: { 'Content-Type' => 'application/json' })
132
+ stub_request(:get, 'https://api.github.com/user')
133
+ .to_return(status: 200, body: '{"login": "test"}', headers: { 'Content-Type' => 'application/json' })
134
+ .times(2)
135
+ conn = create_connection
136
+ conn.get('/rate_limit')
137
+ conn.get('/user')
138
+ conn.get('/user')
139
+ response = conn.get('/rate_limit')
140
+ assert_equal 0, response.body['rate']['remaining']
141
+ end
142
+
143
+ private
144
+
145
+ def create_connection
146
+ Faraday.new(url: 'https://api.github.com') do |f|
147
+ f.use Fbe::Middleware::RateLimit
148
+ f.response :json
149
+ f.adapter :net_http
150
+ end
151
+ end
152
+ end
@@ -79,11 +79,7 @@ class TestConclude < Fbe::Test
79
79
  }
80
80
  )
81
81
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
82
- { body: 'hm...', headers: { 'X-RateLimit-Remaining' => '777' } },
83
- { body: 'hm...', headers: { 'X-RateLimit-Remaining' => '777' } },
84
- { body: 'hm...', headers: { 'X-RateLimit-Remaining' => '777' } },
85
- { body: 'hm...', headers: { 'X-RateLimit-Remaining' => '999' } },
86
- { body: 'hm...', headers: { 'X-RateLimit-Remaining' => '9' } }
82
+ { body: '{"rate":{"remaining":51}}', headers: { 'X-RateLimit-Remaining' => '51' } }
87
83
  )
88
84
  global = {}
89
85
  o = Fbe.octo(loog: Loog::NULL, options:, global:)
@@ -99,7 +99,7 @@ class TestOcto < Fbe::Test
99
99
  def test_caching
100
100
  WebMock.disable_net_connect!
101
101
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
102
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '222' } }
102
+ { body: '{"rate":{"remaining":222}}', headers: { 'X-RateLimit-Remaining' => '222' } }
103
103
  )
104
104
  global = {}
105
105
  o = Fbe.octo(loog: Loog::NULL, global:, options: Judges::Options.new)
@@ -133,12 +133,10 @@ class TestOcto < Fbe::Test
133
133
  def test_off_quota?
134
134
  WebMock.disable_net_connect!
135
135
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
136
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '333' } },
137
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '333' } },
138
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '3' } }
136
+ { body: '{"rate":{"remaining":50}}', headers: { 'X-RateLimit-Remaining' => '50' } }
139
137
  )
140
138
  stub_request(:get, 'https://api.github.com/user/42').to_return(
141
- body: '', headers: { 'X-RateLimit-Remaining' => '3' }
139
+ body: '', headers: { 'X-RateLimit-Remaining' => '49' }
142
140
  )
143
141
  o = Fbe.octo(loog: Loog::NULL, global: {}, options: Judges::Options.new)
144
142
  refute_predicate(o, :off_quota?)
@@ -149,11 +147,7 @@ class TestOcto < Fbe::Test
149
147
  def test_off_quota_twice
150
148
  WebMock.disable_net_connect!
151
149
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
152
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '333' } },
153
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '333' } },
154
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '333' } },
155
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '5555' } },
156
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '5' } }
150
+ { body: '{"rate":{"remaining":51}}', headers: { 'X-RateLimit-Remaining' => '51' } }
157
151
  )
158
152
  stub_request(:get, 'https://api.github.com/user/42').to_return(
159
153
  { body: '', headers: { 'X-RateLimit-Remaining' => '5555' } },
@@ -372,9 +366,9 @@ class TestOcto < Fbe::Test
372
366
  loog = Loog::Buffer.new
373
367
  WebMock.disable_net_connect!
374
368
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
375
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '222' } },
376
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '222' } },
377
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '222' } }
369
+ { body: '{"rate":{"remaining":222}}', headers: { 'X-RateLimit-Remaining' => '222' } },
370
+ { body: '{"rate":{"remaining":222}}', headers: { 'X-RateLimit-Remaining' => '222' } },
371
+ { body: '{"rate":{"remaining":222}}', headers: { 'X-RateLimit-Remaining' => '222' } }
378
372
  )
379
373
  stub_request(:get, 'https://api.github.com/user/123').to_return do
380
374
  {
@@ -396,9 +390,9 @@ class TestOcto < Fbe::Test
396
390
  octo.repository('foo/bar')
397
391
  octo.print_trace!(all: true, max: 9_999)
398
392
  output = loog.to_s
399
- assert_includes output, '3 URLs vs 6 requests'
400
- assert_includes output, '222 quota left'
401
- assert_includes output, '/rate_limit: 3'
393
+ assert_includes output, '3 URLs vs 4 requests'
394
+ assert_includes output, '219 quota left'
395
+ assert_includes output, '/rate_limit: 1'
402
396
  assert_includes output, '/user/123: 1'
403
397
  assert_includes output, '/repos/foo/bar: 2'
404
398
  repo_index = output.index('/repos/foo/bar: 2')
@@ -539,19 +533,13 @@ class TestOcto < Fbe::Test
539
533
  def test_fetch_rate_limit_by_making_new_request
540
534
  WebMock.disable_net_connect!
541
535
  stub_request(:get, 'https://api.github.com/rate_limit').to_return(
542
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '321' } }
543
- ).to_return(
544
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '123' } }
545
- ).to_return(
546
- { body: '{}', headers: { 'X-RateLimit-Remaining' => '1' } }
547
- ).to_raise(StandardError.new('no more requests to https://api.github.com/rate_limit'))
536
+ { body: '{"rate":{"remaining":321}}', headers: { 'X-RateLimit-Remaining' => '321' } }
537
+ )
548
538
  loog = Loog::Buffer.new
549
539
  o = Fbe.octo(loog:, global: {}, options: Judges::Options.new)
550
540
  refute_predicate(o, :off_quota?)
551
541
  assert_match(/321 GitHub API quota left/, loog.to_s)
552
542
  o.print_trace!(all: true)
553
- assert_match(/123 quota left/, loog.to_s)
554
- assert_predicate(o, :off_quota?)
555
- assert_match(/1 GitHub API quota left/, loog.to_s)
543
+ assert_match(/321 quota left/, loog.to_s)
556
544
  end
557
545
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fbe
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.4
4
+ version: 0.23.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -363,6 +363,7 @@ files:
363
363
  - lib/fbe/just_one.rb
364
364
  - lib/fbe/middleware.rb
365
365
  - lib/fbe/middleware/formatter.rb
366
+ - lib/fbe/middleware/rate_limit.rb
366
367
  - lib/fbe/middleware/sqlite_store.rb
367
368
  - lib/fbe/middleware/trace.rb
368
369
  - lib/fbe/octo.rb
@@ -376,6 +377,7 @@ files:
376
377
  - renovate.json
377
378
  - rules/basic.fe
378
379
  - test/fbe/middleware/test_formatter.rb
380
+ - test/fbe/middleware/test_rate_limit.rb
379
381
  - test/fbe/middleware/test_sqlite_store.rb
380
382
  - test/fbe/middleware/test_trace.rb
381
383
  - test/fbe/test_award.rb