hungrytable 0.0.8 → 1.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +299 -22
  3. data/RELEASE_NOTES.md +16 -0
  4. data/lib/hungrytable/api_health_monitor.rb +106 -0
  5. data/lib/hungrytable/circuit_breaker.rb +177 -0
  6. data/lib/hungrytable/config.rb +38 -16
  7. data/lib/hungrytable/enhanced_errors.rb +127 -0
  8. data/lib/hungrytable/errors.rb +36 -0
  9. data/lib/hungrytable/get_request.rb +17 -6
  10. data/lib/hungrytable/post_request.rb +17 -6
  11. data/lib/hungrytable/request.rb +42 -5
  12. data/lib/hungrytable/request_extensions.rb +17 -4
  13. data/lib/hungrytable/request_header.rb +25 -25
  14. data/lib/hungrytable/reservation_cancel.rb +19 -5
  15. data/lib/hungrytable/reservation_make.rb +30 -9
  16. data/lib/hungrytable/reservation_status.rb +56 -0
  17. data/lib/hungrytable/restaurant.rb +43 -31
  18. data/lib/hungrytable/restaurant_search.rb +63 -34
  19. data/lib/hungrytable/restaurant_slotlock.rb +32 -10
  20. data/lib/hungrytable/version.rb +3 -1
  21. data/lib/hungrytable.rb +117 -65
  22. metadata +43 -179
  23. data/.gitignore +0 -6
  24. data/.rvmrc +0 -1
  25. data/Gemfile +0 -5
  26. data/Guardfile +0 -16
  27. data/Rakefile +0 -8
  28. data/hungrytable.gemspec +0 -37
  29. data/test/restaurant_get_details_result.json +0 -6
  30. data/test/restaurant_search_result.json +0 -7
  31. data/test/test_helper.rb +0 -18
  32. data/test/unit/config_test.rb +0 -43
  33. data/test/unit/get_request_test.rb +0 -0
  34. data/test/unit/hungrytable/user_test.rb +0 -28
  35. data/test/unit/post_request_test.rb +0 -0
  36. data/test/unit/request_test.rb +0 -0
  37. data/test/unit/reservation_cancel_test.rb +0 -0
  38. data/test/unit/reservation_make_test.rb +0 -0
  39. data/test/unit/restaurant_search_test.rb +0 -0
  40. data/test/unit/restaurant_slotlock_test.rb +0 -0
  41. data/test/unit/restaurant_test.rb +0 -39
  42. data/test/user_login_result.json +0 -6
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 03c04cacdda93b1d21d8a26fff6cc52759d2a69e3f8c37e15718c9c356ca5cc1
4
+ data.tar.gz: 5d90da3b15d1c81049375ee5d105cf0c50b58eab5d67562a1db50400d042e87a
5
+ SHA512:
6
+ metadata.gz: 8bf655f9a26eab925b1e703a5b48d6cc43de861446106c63728a3000ed29a4665d2c118e542e1511c4f90fec47a7a5e14c54913a0be87d4eefaa12a15bebe56e
7
+ data.tar.gz: ebaa8f601e1959e7ae933e0d8cbf8d4ee559af9c94321990da1b9c17bdfb8b2a8f075203dc2efb7e8ce4272792fc9c4b75f19eed534e6febffc34cc072406517
data/README.md CHANGED
@@ -1,54 +1,331 @@
1
1
  # Hungrytable
2
2
 
3
- The purpose of this gem is to interact with the [OpenTable](http://www.opentable.com) REST API.
3
+ [![CI](https://github.com/dchapman1988/hungrytable/actions/workflows/ci.yml/badge.svg)](https://github.com/dchapman1988/hungrytable/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/dchapman1988/hungrytable/graph/badge.svg)](https://codecov.io/gh/dchapman1988/hungrytable)
5
+ [![Ruby Version](https://img.shields.io/badge/ruby-3.0%2B-red.svg)](https://www.ruby-lang.org/)
6
+ [![Gem Version](https://badge.fury.io/rb/hungrytable.svg)](https://badge.fury.io/rb/hungrytable)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ Ruby client for the OpenTable REST API. Supports restaurant search, availability lookup, reservations, and booking management.
10
+
11
+ ## Features
12
+
13
+ - Fetch restaurant details
14
+ - Search available reservation times
15
+ - Lock time slots before booking
16
+ - Create and cancel reservations
17
+ - Check reservation status
18
+ - Requires Ruby 3.0+
19
+ - No `method_missing` - all methods explicitly defined
20
+ - Error classes for different failure types
4
21
 
5
22
  ## Installation
6
23
 
7
24
  Add this line to your application's Gemfile:
8
25
 
9
- gem 'hungrytable'
26
+ ```ruby
27
+ gem 'hungrytable'
28
+ ```
10
29
 
11
30
  And then execute:
12
31
 
13
- $ bundle
32
+ ```bash
33
+ $ bundle install
34
+ ```
14
35
 
15
36
  Or install it yourself as:
16
37
 
17
- $ gem install hungrytable
38
+ ```bash
39
+ $ gem install hungrytable
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ ### Environment Variables
45
+
46
+ Set the following environment variables (recommended for production):
47
+
48
+ ```bash
49
+ export OT_PARTNER_ID=<YOUR OPENTABLE PARTNER ID>
50
+ export OT_OAUTH_KEY=<YOUR OPENTABLE OAUTH KEY>
51
+ export OT_OAUTH_SECRET=<YOUR OPENTABLE OAUTH SECRET KEY>
52
+ ```
53
+
54
+ ### Programmatic Configuration
55
+
56
+ Alternatively, configure programmatically in your code:
57
+
58
+ ```ruby
59
+ Hungrytable.configure do |config|
60
+ config.partner_id = 'your_partner_id'
61
+ config.oauth_key = 'your_oauth_key'
62
+ config.oauth_secret = 'your_oauth_secret'
63
+ end
64
+ ```
18
65
 
19
66
  ## Usage
20
67
 
21
- You need to set some environment variable for this gem to work properly:
68
+ ### 1. Get Restaurant Details
69
+
70
+ ```ruby
71
+ restaurant = Hungrytable::Restaurant.new(82591)
72
+
73
+ if restaurant.valid?
74
+ puts restaurant.restaurant_name
75
+ puts restaurant.address
76
+ puts restaurant.phone
77
+ puts restaurant.primary_food_type
78
+ puts restaurant.price_range
79
+ else
80
+ puts "Error: #{restaurant.error_message}"
81
+ end
82
+ ```
83
+
84
+ Available restaurant attributes:
85
+ - `restaurant_name`, `restaurant_ID`
86
+ - `address`, `city`, `state`, `postal_code`
87
+ - `phone`, `url`
88
+ - `latitude`, `longitude`
89
+ - `neighborhood_name`, `metro_name`
90
+ - `primary_food_type`, `price_range`
91
+ - `restaurant_description`
92
+ - `parking`, `parking_details`
93
+ - `image_link`
94
+
95
+ ### 2. Search for Availability
96
+
97
+ ```ruby
98
+ restaurant = Hungrytable::Restaurant.new(82591)
99
+
100
+ # Search for a table for 4 people, 5 days from now at 7pm
101
+ search_time = 5.days.from_now.change(hour: 19, min: 0)
102
+ search = Hungrytable::RestaurantSearch.new(
103
+ restaurant,
104
+ date_time: search_time,
105
+ party_size: 4
106
+ )
107
+
108
+ if search.valid?
109
+ puts "Exact time: #{search.exact_time}" if search.exact_time
110
+ puts "Early time: #{search.early_time}" if search.early_time
111
+ puts "Later time: #{search.later_time}" if search.later_time
112
+ puts "Best time: #{search.ideal_time}"
113
+ else
114
+ puts "No availability: #{search.error_message}"
115
+ end
116
+ ```
117
+
118
+ ### 3. Lock a Time Slot
119
+
120
+ ```ruby
121
+ slotlock = Hungrytable::RestaurantSlotlock.new(search)
122
+
123
+ if slotlock.successful?
124
+ puts "Slot locked! ID: #{slotlock.slotlock_id}"
125
+ else
126
+ puts "Failed to lock slot: #{slotlock.errors}"
127
+ end
128
+ ```
129
+
130
+ ### 4. Make a Reservation
131
+
132
+ ```ruby
133
+ reservation = Hungrytable::ReservationMake.new(
134
+ slotlock,
135
+ email_address: 'john.doe@example.com',
136
+ firstname: 'John',
137
+ lastname: 'Doe',
138
+ phone: '5551234567',
139
+ specialinstructions: 'Window seat please'
140
+ )
141
+
142
+ if reservation.successful?
143
+ puts "Reservation confirmed! Number: #{reservation.confirmation_number}"
144
+ else
145
+ puts "Reservation failed: #{reservation.error_message}"
146
+ end
147
+ ```
148
+
149
+ ### 5. Check Reservation Status
150
+
151
+ ```ruby
152
+ status = Hungrytable::ReservationStatus.new(
153
+ restaurant_id: 82591,
154
+ confirmation_number: 'ABC123XYZ'
155
+ )
156
+
157
+ if status.successful?
158
+ puts "Status: #{status.status}"
159
+ details = status.reservation_details
160
+ puts "Restaurant: #{details[:restaurant_name]}"
161
+ puts "Date/Time: #{details[:date_time]}"
162
+ puts "Party Size: #{details[:party_size]}"
163
+ end
164
+ ```
165
+
166
+ ### 6. Cancel a Reservation
167
+
168
+ ```ruby
169
+ cancel = Hungrytable::ReservationCancel.new(
170
+ email_address: 'john.doe@example.com',
171
+ confirmation_number: 'ABC123XYZ',
172
+ restaurant_id: 82591
173
+ )
22
174
 
23
- Observe:
175
+ if cancel.successful?
176
+ puts "Reservation cancelled successfully"
177
+ else
178
+ puts "Cancellation failed: #{cancel.error_message}"
179
+ end
180
+ ```
24
181
 
25
- # In ~/.bashrc
26
- export OT_PARTNER_ID=<YOUR OPENTABLE PARTNER ID>
27
- export OT_PARTNER_AUTH=<YOUR OPENTABLE PARTNER AUTH> # For XML feed only...
28
- export OT_OAUTH_KEY=<YOUR OPENTABLE OAUTH KEY>
29
- export OT_OAUTH_SECRET=<YOUR OPENTABLE OAUTH SECRET KEY>
182
+ ### Complete Example: Full Reservation Flow
30
183
 
31
- ## Example Run
184
+ ```ruby
185
+ require 'hungrytable'
32
186
 
33
- $ restaurant = Hungrytable::Restaurant.new(82591)
187
+ # Configure
188
+ Hungrytable.configure do |config|
189
+ config.partner_id = ENV['OT_PARTNER_ID']
190
+ config.oauth_key = ENV['OT_OAUTH_KEY']
191
+ config.oauth_secret = ENV['OT_OAUTH_SECRET']
192
+ end
34
193
 
35
- > #<Hungrytable::Restaurant:0x0000000032e4098 ... >
194
+ # 1. Get restaurant details
195
+ restaurant = Hungrytable::Restaurant.new(82591)
196
+ raise "Restaurant not found" unless restaurant.valid?
36
197
 
37
- $ search = Hungrytable::RestaurantSearch.new(restaurant, {date_time: 5.days.from_now, party_size: 3})
198
+ puts "Booking at: #{restaurant.restaurant_name}"
38
199
 
39
- > #<Hungrytable::RestaurantSearch:0x00000003143388 ... >
200
+ # 2. Search for availability
201
+ search_time = 3.days.from_now.change(hour: 19, min: 0)
202
+ search = Hungrytable::RestaurantSearch.new(
203
+ restaurant,
204
+ date_time: search_time,
205
+ party_size: 2
206
+ )
40
207
 
41
- $ slotlock = Hungrytable::RestaurantSlotlock.new(search)
208
+ raise "No availability" unless search.valid?
42
209
 
43
- > #<Hungrytable::RestaurantSlotlock:0x00000002973a68 ... >
210
+ puts "Found time slot: #{search.ideal_time}"
44
211
 
45
- $ reservation = Hungrytable::ReservationMake.new(slotlock, {email_address: 'foo@bar.com', firstname: 'Mike', lastname: 'Jones', phone: '2813308004'})
212
+ # 3. Lock the slot
213
+ slotlock = Hungrytable::RestaurantSlotlock.new(search)
214
+ raise "Could not lock slot: #{slotlock.errors}" unless slotlock.successful?
215
+
216
+ # 4. Make the reservation
217
+ reservation = Hungrytable::ReservationMake.new(
218
+ slotlock,
219
+ email_address: 'diner@example.com',
220
+ firstname: 'Jane',
221
+ lastname: 'Smith',
222
+ phone: '5551234567'
223
+ )
224
+
225
+ if reservation.successful?
226
+ puts "Success! Confirmation: #{reservation.confirmation_number}"
227
+ else
228
+ puts "Failed: #{reservation.error_message}"
229
+ end
230
+ ```
231
+
232
+ ## Error Handling
233
+
234
+ Available error classes:
235
+
236
+ ```ruby
237
+ begin
238
+ restaurant = Hungrytable::Restaurant.new(invalid_id)
239
+ rescue Hungrytable::ConfigurationError => e
240
+ puts "Configuration error: #{e.message}"
241
+ rescue Hungrytable::HTTPError => e
242
+ puts "HTTP error: #{e.message}"
243
+ rescue Hungrytable::APIError => e
244
+ puts "API error #{e.error_code}: #{e.error_message}"
245
+ rescue Hungrytable::MissingRequiredFieldError => e
246
+ puts "Missing required field: #{e.message}"
247
+ end
248
+ ```
249
+
250
+ Error hierarchy:
251
+ - `Hungrytable::Error` (base class)
252
+ - `ConfigurationError` - Missing or invalid configuration
253
+ - `HTTPError` - HTTP-level errors
254
+ - `NotFoundError` - 404 errors
255
+ - `UnauthorizedError` - 401 authentication errors
256
+ - `ServerError` - 5xx server errors
257
+ - `APIError` - OpenTable API errors
258
+ - `ValidationError` - Input validation errors
259
+ - `MissingRequiredFieldError` - Missing required parameters
260
+
261
+ ## Development
262
+
263
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.
264
+
265
+ ### Running Tests
266
+
267
+ ```bash
268
+ bundle exec rake test
269
+ ```
270
+
271
+ ### Running RuboCop
272
+
273
+ ```bash
274
+ bundle exec rubocop
275
+ ```
276
+
277
+ ### Auto-fixing RuboCop Issues
278
+
279
+ ```bash
280
+ bundle exec rubocop -A
281
+ ```
282
+
283
+ ## Requirements
284
+
285
+ - Ruby >= 3.0.0
286
+ - OpenTable Partner API credentials
287
+
288
+ ## Dependencies
289
+
290
+ Runtime:
291
+ - `activesupport` (>= 6.0)
292
+ - `http` (~> 5.0)
293
+ - `oauth` (~> 1.1)
294
+
295
+ Development:
296
+ - `minitest` (~> 5.20) - Testing framework
297
+ - `webmock` (~> 3.20) - HTTP mocking for tests
298
+ - `rubocop` (~> 1.60) - Code quality and style
299
+ - `mocha` (~> 2.1) - Mocking and stubbing
46
300
 
47
301
 
48
302
  ## Contributing
49
303
 
50
- 1. Fork it
304
+ 1. Fork it (https://github.com/dchapman1988/hungrytable/fork)
51
305
  2. Create your feature branch (`git checkout -b my-new-feature`)
52
- 3. Commit your changes (`git commit -am 'Added some feature'`)
306
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
53
307
  4. Push to the branch (`git push origin my-new-feature`)
54
- 5. Create new Pull Request
308
+ 5. Create a new Pull Request
309
+
310
+ Please ensure:
311
+ - All tests pass (`bundle exec rake test`)
312
+ - RuboCop is clean (`bundle exec rubocop`)
313
+ - New features have tests
314
+ - Code follows existing style
315
+
316
+ ## License
317
+
318
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
319
+
320
+ ## Maintainers
321
+
322
+ **Active:**
323
+ - David Chapman ([@dchapman1988](https://github.com/dchapman1988))
324
+
325
+ **Inactive:**
326
+ - Nicholas Fine ([@yrgoldteeth](https://github.com/yrgoldteeth))
327
+ - Ryan T. Hosford ([@rthbound](https://github.com/rthbound))
328
+
329
+ ## Changelog
330
+
331
+ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for version history.
data/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Hungrytable - Ruby API Client for the OpenTable REST API
2
2
 
3
+ ## v1.0.0 (2025-01-XX)
4
+
5
+ ### Breaking Changes
6
+ - Requires Ruby 3.0+
7
+ - Replaced `curb` with `http` gem
8
+ - Replaced `method_missing` with explicit method definitions
9
+
10
+ ### Changes
11
+ - Fixed deprecated `URI.encode`/`URI.decode` → `CGI.escape`/`CGI.unescape`
12
+ - Added `oauth` gem for OAuth 1.0
13
+ - RuboCop clean, Ruby 3.0+ code style
14
+ - Added 152 tests
15
+ - Added GitHub Actions CI
16
+
17
+ ---
18
+
3
19
  ## v0.0.1 (05/11/2012)
4
20
 
5
21
  * Get relevant details about an individual restaurant.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hungrytable
4
+ # Monitors API health and detects potential deprecation or degradation
5
+ class ApiHealthMonitor
6
+ class << self
7
+ # Check if API is healthy
8
+ # @return [Hash] health status
9
+ def health_check
10
+ {
11
+ timestamp: Time.now.utc.iso8601,
12
+ status: check_status,
13
+ circuit_breaker: CircuitBreaker.stats,
14
+ endpoint: Config.base_url,
15
+ warnings: warnings
16
+ }
17
+ end
18
+
19
+ # Perform a lightweight health check
20
+ # @return [Symbol] :healthy, :degraded, or :down
21
+ def check_status
22
+ return :down if CircuitBreaker.open?
23
+
24
+ begin
25
+ # Try a simple request
26
+ restaurant = Restaurant.new(82591)
27
+ restaurant.valid? ? :healthy : :degraded
28
+ rescue UnauthorizedError
29
+ :unauthorized
30
+ rescue ServerError, HTTPError
31
+ :down
32
+ rescue StandardError
33
+ :unknown
34
+ end
35
+ end
36
+
37
+ # Get warnings about potential issues
38
+ # @return [Array<Hash>] warnings
39
+ def warnings
40
+ warnings = []
41
+
42
+ if CircuitBreaker.open?
43
+ warnings << {
44
+ level: :critical,
45
+ message: 'Circuit breaker is OPEN - API appears to be down',
46
+ action: 'Wait for circuit breaker timeout or contact OpenTable support'
47
+ }
48
+ end
49
+
50
+ if check_oauth_version_deprecated?
51
+ warnings << {
52
+ level: :warning,
53
+ message: 'Using OAuth 1.0 which may be deprecated',
54
+ action: 'Consider migrating to modern OpenTable API (OAuth 2.0)'
55
+ }
56
+ end
57
+
58
+ if check_legacy_endpoint?
59
+ warnings << {
60
+ level: :warning,
61
+ message: 'Using legacy otapi_v3.ashx endpoint (2012 vintage)',
62
+ action: 'Verify endpoint is still supported with OpenTable'
63
+ }
64
+ end
65
+
66
+ warnings
67
+ end
68
+
69
+ # Log API health to stdout or logger
70
+ # @param logger [Logger] optional logger
71
+ def log_health(logger = nil)
72
+ health = health_check
73
+ output = logger || $stdout
74
+
75
+ case health[:status]
76
+ when :healthy
77
+ output.puts "[Hungrytable] API Status: HEALTHY"
78
+ when :degraded
79
+ output.puts "[Hungrytable] API Status: DEGRADED - Some endpoints may be failing"
80
+ when :down
81
+ output.puts "[Hungrytable] API Status: DOWN - API is not responding"
82
+ when :unauthorized
83
+ output.puts "[Hungrytable] API Status: UNAUTHORIZED - Check credentials"
84
+ else
85
+ output.puts "[Hungrytable] API Status: UNKNOWN"
86
+ end
87
+
88
+ health[:warnings].each do |warning|
89
+ output.puts "[Hungrytable] [#{warning[:level].to_s.upcase}] #{warning[:message]}"
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def check_oauth_version_deprecated?
96
+ # OAuth 1.0 is considered legacy
97
+ true
98
+ end
99
+
100
+ def check_legacy_endpoint?
101
+ # otapi_v3.ashx is from 2012
102
+ Config.base_url.include?('otapi_v3.ashx')
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hungrytable
4
+ # Circuit Breaker pattern to prevent cascading failures when API is down
5
+ #
6
+ # States:
7
+ # - CLOSED: Normal operation, requests pass through
8
+ # - OPEN: Too many failures, requests fail immediately
9
+ # - HALF_OPEN: Testing if service recovered
10
+ #
11
+ # Usage:
12
+ # Hungrytable::CircuitBreaker.call do
13
+ # # Make API request
14
+ # end
15
+ class CircuitBreaker
16
+ class CircuitOpenError < Error
17
+ def initialize(message = nil)
18
+ super(message || 'Circuit breaker is OPEN. API appears to be down. Try again later.')
19
+ end
20
+ end
21
+
22
+ class << self
23
+ # Execute a block with circuit breaker protection
24
+ # @yield Block to execute
25
+ # @raise [CircuitOpenError] if circuit is open
26
+ # @return Result of the block
27
+ def call(&block)
28
+ instance.call(&block)
29
+ end
30
+
31
+ # Check if circuit is open
32
+ # @return [Boolean]
33
+ def open?
34
+ instance.open?
35
+ end
36
+
37
+ # Get current state
38
+ # @return [Symbol] :closed, :open, or :half_open
39
+ def state
40
+ instance.state
41
+ end
42
+
43
+ # Get circuit breaker statistics
44
+ # @return [Hash] statistics
45
+ def stats
46
+ instance.stats
47
+ end
48
+
49
+ # Reset the circuit breaker
50
+ def reset!
51
+ instance.reset!
52
+ end
53
+
54
+ # Configure circuit breaker
55
+ # @param failure_threshold [Integer] Number of failures before opening
56
+ # @param timeout [Integer] Seconds to wait before entering half-open state
57
+ # @param success_threshold [Integer] Successes needed in half-open to close
58
+ def configure(failure_threshold: 5, timeout: 60, success_threshold: 2)
59
+ instance.configure(
60
+ failure_threshold: failure_threshold,
61
+ timeout: timeout,
62
+ success_threshold: success_threshold
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def instance
69
+ @instance ||= new
70
+ end
71
+ end
72
+
73
+ attr_reader :state, :failure_count, :success_count, :last_failure_time
74
+
75
+ def initialize
76
+ @state = :closed
77
+ @failure_count = 0
78
+ @success_count = 0
79
+ @last_failure_time = nil
80
+ @mutex = Mutex.new
81
+
82
+ # Default configuration
83
+ @config = {
84
+ failure_threshold: 5, # Open after 5 failures
85
+ timeout: 60, # Try again after 60 seconds
86
+ success_threshold: 2 # Close after 2 successes in half-open
87
+ }
88
+ end
89
+
90
+ def configure(failure_threshold: nil, timeout: nil, success_threshold: nil)
91
+ @mutex.synchronize do
92
+ @config[:failure_threshold] = failure_threshold if failure_threshold
93
+ @config[:timeout] = timeout if timeout
94
+ @config[:success_threshold] = success_threshold if success_threshold
95
+ end
96
+ end
97
+
98
+ def call
99
+ @mutex.synchronize do
100
+ check_state_transition
101
+
102
+ if @state == :open
103
+ raise CircuitOpenError
104
+ end
105
+ end
106
+
107
+ begin
108
+ result = yield
109
+ record_success
110
+ result
111
+ rescue UnauthorizedError, ServerError, HTTPError => e
112
+ record_failure
113
+ raise e
114
+ end
115
+ end
116
+
117
+ def open?
118
+ @state == :open
119
+ end
120
+
121
+ def stats
122
+ {
123
+ state: @state,
124
+ failure_count: @failure_count,
125
+ success_count: @success_count,
126
+ last_failure_time: @last_failure_time,
127
+ config: @config
128
+ }
129
+ end
130
+
131
+ def reset!
132
+ @mutex.synchronize do
133
+ @state = :closed
134
+ @failure_count = 0
135
+ @success_count = 0
136
+ @last_failure_time = nil
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def check_state_transition
143
+ return unless @state == :open
144
+
145
+ if Time.now - @last_failure_time >= @config[:timeout]
146
+ @state = :half_open
147
+ @success_count = 0
148
+ end
149
+ end
150
+
151
+ def record_success
152
+ @mutex.synchronize do
153
+ @failure_count = 0
154
+
155
+ if @state == :half_open
156
+ @success_count += 1
157
+ if @success_count >= @config[:success_threshold]
158
+ @state = :closed
159
+ @success_count = 0
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ def record_failure
166
+ @mutex.synchronize do
167
+ @failure_count += 1
168
+ @last_failure_time = Time.now
169
+ @success_count = 0
170
+
171
+ if @failure_count >= @config[:failure_threshold]
172
+ @state = :open
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end