sidekiq-fusebox 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: edd6428d9481a00060ac2bae28d6028d10c6339202c1e90ca47ae3a72e71bf53
4
+ data.tar.gz: 19e43a6860a8150defa625048e39ec41b685cdbae9a4532a4413c2f221a697e6
5
+ SHA512:
6
+ metadata.gz: 6564ada11b64db6068f7e07189f7858bbde43e279e5d3c5e08222706ee17fedef867d5172e3516f409d359aaa357fbc8938c34e2a8dc266dd3ae16af0cb3c61f
7
+ data.tar.gz: c14f4d2d72d36e5ac8bf58986a09ad36c71da93b4fb7b8a6662d4341b6eb0ac2f1bfd57a6a276253e8f10b311386a90730e5294717d13a774bb3ee489f15f681
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ ## [Unreleased]
2
+
3
+ - Add a Sidekiq Web UI tab ("Fusebox") listing every known target's circuit
4
+ state, with a manual "Force close" action.
5
+ - Add `Circuit.known_targets`, `Circuit.snapshot_all`, `Circuit#snapshot`,
6
+ and `Circuit#force_close!` to support it.
7
+
8
+ ## [0.1.0] - 2026-09-22
9
+
10
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 TODO: Write your name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Sidekiq::Fusebox
2
+
3
+ A fuse box has one fuse per circuit — overload the microwave and its fuse
4
+ blows, but the fridge keeps running. `sidekiq-fusebox` brings that to
5
+ Sidekiq: an independent circuit breaker **per target**, so one flaky
6
+ downstream service fails fast without starving jobs bound for unrelated
7
+ targets.
8
+
9
+ Point it at any Sidekiq worker that talks to multiple external
10
+ systems — ERPs, payment gateways, webhooks, third-party APIs. When Brightpearl
11
+ starts timing out, only jobs targeting Brightpearl trip; jobs for NetSuite,
12
+ SAP, or anything else keep processing normally on the same worker threads.
13
+
14
+ ## Why not just retry?
15
+
16
+ Sidekiq's built-in retries handle *transient* failures well. They don't
17
+ handle *sustained* outages: if a downstream service is down for 20 minutes,
18
+ every job aimed at it will still make a full-timeout API call, fail, and
19
+ retry — burning worker threads the whole time, and starving unrelated jobs
20
+ that share the same process. Fusebox adds a fast-fail layer in front of that:
21
+ once a target has failed enough times, jobs for that target raise
22
+ immediately, with **no external call**, until the target proves itself
23
+ healthy again.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ bundle add sidekiq-fusebox
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Register the middleware and configure how to extract a "target" from each
34
+ job:
35
+
36
+ ```ruby
37
+ Sidekiq.configure_server do |config|
38
+ config.server_middleware do |chain|
39
+ chain.add Sidekiq::Fusebox::Middleware
40
+ end
41
+ end
42
+
43
+ Sidekiq::Fusebox.configure do |c|
44
+ # Required: how to derive the circuit's key from a job.
45
+ # Defaults to the worker class name if you don't set this.
46
+ c.extract_target = ->(_worker, job_payload) { job_payload["args"].first["integration"] }
47
+
48
+ # Only these exceptions count as a failure for the circuit. Narrow this to
49
+ # your integration's actual error classes — a broad StandardError rescue
50
+ # will also count your own bugs (e.g. a NoMethodError) as "the target is
51
+ # down", which is not what you want.
52
+ c.trip_on = [Faraday::TimeoutError, Faraday::ConnectionFailed]
53
+
54
+ c.failure_threshold = 5 # failures before the circuit opens
55
+ c.failure_window = 60 # seconds; failures older than this don't count
56
+ c.cooldown_period = 30 # seconds the circuit stays fully open
57
+ c.probe_lock_ttl = 30 # safety TTL on the single half-open probe
58
+
59
+ c.on_open = ->(target) { Rails.logger.warn("fusebox: circuit open for #{target}") }
60
+ c.on_close = ->(target) { Rails.logger.info("fusebox: circuit closed for #{target}") }
61
+ c.on_reject = ->(target, _job) { StatsD.increment("fusebox.rejected", tags: ["target:#{target}"]) }
62
+ end
63
+ ```
64
+
65
+ ```ruby
66
+ class IntegrationSyncWorker
67
+ include Sidekiq::Job
68
+
69
+ def perform(order_id, integration:)
70
+ Integrations.for(integration).sync_order(order_id)
71
+ end
72
+ end
73
+ ```
74
+
75
+ ## How it works
76
+
77
+ Each target moves through three states, tracked in Redis:
78
+
79
+ - **closed** — healthy. Jobs run normally.
80
+ - **open** — `failure_threshold` failures happened within `failure_window`.
81
+ Every job for this target raises `Sidekiq::Fusebox::CircuitOpenError`
82
+ immediately, without being called, for `cooldown_period` seconds.
83
+ - **half-open** — the cooldown has elapsed. Exactly one job is let through as
84
+ a probe (via a Redis `SET NX` lock, so concurrent workers don't all hammer
85
+ the target at once). If it succeeds, the circuit closes. If it fails, the
86
+ circuit reopens for a fresh `cooldown_period`.
87
+
88
+ Because rejection raises `CircuitOpenError`, Sidekiq's own retry mechanism
89
+ schedules the retry with its normal backoff — Fusebox doesn't re-implement
90
+ scheduling. **Trade-off:** a rejection still consumes one of the job's
91
+ configured retry attempts. If you run targets with tight `retry:` counts,
92
+ account for open-circuit rejections when choosing that number, or rescue
93
+ `CircuitOpenError` specifically in a `sidekiq_retry_in` block to control its
94
+ backoff independently of your other failure types.
95
+
96
+ ## Web UI
97
+
98
+ Fusebox adds a "Fusebox" tab to the Sidekiq Web UI listing every target
99
+ that's ever failed, its current state, failure count, and (while open) how
100
+ long until the next half-open probe — with a "Force close" button for manual
101
+ overrides. It's not loaded by `require "sidekiq/fusebox"`, since it pulls in
102
+ `sidekiq/web`; require it wherever you mount `Sidekiq::Web`:
103
+
104
+ ```ruby
105
+ # config.ru, or config/routes.rb in Rails
106
+ require "sidekiq/web"
107
+ require "sidekiq/fusebox/web"
108
+
109
+ run Sidekiq::Web # or: mount Sidekiq::Web => "/sidekiq"
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ bin/setup
116
+ bundle exec rake # runs rspec + rubocop
117
+ ```
118
+
119
+ The spec suite stubs `Sidekiq.redis` with an in-memory fake
120
+ (`spec/support/fake_redis.rb`), so it doesn't need a real Redis server.
121
+
122
+ ## Contributing
123
+
124
+ Bug reports and pull requests are welcome at
125
+ https://github.com/bhawsartanmay/sidekiq-fusebox.
126
+
127
+ ## License
128
+
129
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Fusebox
5
+ # A single per-target circuit, backed by three Redis keys namespaced
6
+ # under a hash tag so they always land on the same Redis Cluster slot,
7
+ # plus a global set that remembers every target that has ever failed
8
+ # (used by the Web UI to know what to list).
9
+ #
10
+ # fusebox:{target}:failures - rolling failure counter
11
+ # fusebox:{target}:opened_at - float timestamp, present only while open/half-open
12
+ # fusebox:{target}:probe_lock - claims the single half-open probe attempt
13
+ # fusebox:targets - set of every target that has ever failed
14
+ class Circuit
15
+ TARGETS_KEY = "fusebox:targets"
16
+
17
+ Snapshot = Struct.new(:target, :state, :failure_count, :opened_at, :cooldown_remaining, :probing,
18
+ keyword_init: true)
19
+
20
+ class << self
21
+ def known_targets(config)
22
+ with_redis(config) { |conn| conn.call("SMEMBERS", TARGETS_KEY) }.sort
23
+ end
24
+
25
+ def snapshot_all(config)
26
+ known_targets(config).map { |target| new(target, config).snapshot }
27
+ end
28
+
29
+ def with_redis(config, &block)
30
+ config.redis_pool ? config.redis_pool.with(&block) : Sidekiq.redis(&block)
31
+ end
32
+ end
33
+
34
+ def initialize(target, config)
35
+ @target = target
36
+ @config = config
37
+ end
38
+
39
+ # true -> caller may proceed (circuit closed, or this job won the probe)
40
+ # false -> caller must not proceed (circuit open, or another job already probing)
41
+ def allow_request?
42
+ case state
43
+ when :closed then true
44
+ when :open then false
45
+ when :half_open then acquire_probe_lock
46
+ end
47
+ end
48
+
49
+ def record_success
50
+ removed = redis { |conn| conn.call("DEL", failures_key, opened_at_key, probe_lock_key) }
51
+ @config.on_close&.call(@target) if removed.to_i.positive?
52
+ end
53
+ alias force_close! record_success
54
+
55
+ def record_failure
56
+ redis { |conn| conn.call("SADD", TARGETS_KEY, @target) }
57
+ count = redis { |conn| conn.call("INCR", failures_key) }
58
+ redis { |conn| conn.call("EXPIRE", failures_key, @config.failure_window) }
59
+ return if count < @config.failure_threshold
60
+
61
+ open_circuit!
62
+ end
63
+
64
+ def state
65
+ derive_state(redis { |conn| conn.call("GET", opened_at_key) })
66
+ end
67
+
68
+ # A read-only view of this circuit for display purposes (e.g. the Web UI).
69
+ def snapshot
70
+ opened_at = redis { |conn| conn.call("GET", opened_at_key) }
71
+ current_state = derive_state(opened_at)
72
+
73
+ Snapshot.new(
74
+ target: @target,
75
+ state: current_state,
76
+ failure_count: failure_count,
77
+ opened_at: opened_at ? Time.at(opened_at.to_f) : nil,
78
+ cooldown_remaining: cooldown_remaining(current_state, opened_at),
79
+ probing: probing?
80
+ )
81
+ end
82
+
83
+ private
84
+
85
+ def derive_state(opened_at)
86
+ return :closed if opened_at.nil?
87
+
88
+ (Time.now.to_f - opened_at.to_f) < @config.cooldown_period ? :open : :half_open
89
+ end
90
+
91
+ def cooldown_remaining(current_state, opened_at)
92
+ return 0 unless current_state == :open
93
+
94
+ elapsed = Time.now.to_f - opened_at.to_f
95
+ (@config.cooldown_period - elapsed).round
96
+ end
97
+
98
+ def failure_count
99
+ (redis { |conn| conn.call("GET", failures_key) } || 0).to_i
100
+ end
101
+
102
+ def probing?
103
+ !redis { |conn| conn.call("GET", probe_lock_key) }.nil?
104
+ end
105
+
106
+ # The PX TTL is a safety net so the key never lingers forever if
107
+ # record_success is never reached (worker killed mid-job, deploy, etc).
108
+ def open_circuit!
109
+ redis do |conn|
110
+ conn.call("SET", opened_at_key, Time.now.to_f.to_s, "PX", millis(@config.cooldown_period * 2))
111
+ conn.call("DEL", probe_lock_key)
112
+ end
113
+ @config.on_open&.call(@target)
114
+ end
115
+
116
+ def acquire_probe_lock
117
+ redis { |conn| conn.call("SET", probe_lock_key, "1", "NX", "PX", millis(@config.probe_lock_ttl)) } == "OK"
118
+ end
119
+
120
+ def millis(seconds) = (seconds * 1000).round
121
+
122
+ def redis(&block) = self.class.with_redis(@config, &block)
123
+
124
+ def failures_key = "fusebox:{#{@target}}:failures"
125
+ def opened_at_key = "fusebox:{#{@target}}:opened_at"
126
+ def probe_lock_key = "fusebox:{#{@target}}:probe_lock"
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Fusebox
5
+ # Holds the global defaults for every circuit. Override via
6
+ # Sidekiq::Fusebox.configure.
7
+ class Configuration
8
+ attr_accessor :failure_threshold, :failure_window, :cooldown_period,
9
+ :probe_lock_ttl, :extract_target, :trip_on,
10
+ :on_open, :on_close, :on_reject, :redis_pool
11
+
12
+ def initialize
13
+ @failure_threshold = 5
14
+ @failure_window = 60
15
+ @cooldown_period = 30
16
+ @probe_lock_ttl = 30
17
+ @trip_on = [StandardError]
18
+ @extract_target = ->(_worker, job_payload) { job_payload["class"] }
19
+ @on_open = nil
20
+ @on_close = nil
21
+ @on_reject = nil
22
+ @redis_pool = nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Fusebox
5
+ # Sidekiq server middleware: skips the job (fails fast, no external call)
6
+ # while the target's circuit is open, and feeds success/failure back into
7
+ # that target's circuit otherwise.
8
+ class Middleware
9
+ include Sidekiq::ServerMiddleware if defined?(Sidekiq::ServerMiddleware)
10
+
11
+ def call(job_instance, job_payload, _queue, &block)
12
+ config = Sidekiq::Fusebox.configuration
13
+ target = config.extract_target.call(job_instance, job_payload)
14
+ return block.call if target.nil?
15
+
16
+ circuit = Circuit.new(target, config)
17
+ reject!(config, target, job_payload) unless circuit.allow_request?
18
+
19
+ run(circuit, config, &block)
20
+ end
21
+
22
+ private
23
+
24
+ def reject!(config, target, job_payload)
25
+ config.on_reject&.call(target, job_payload)
26
+ raise CircuitOpenError, "sidekiq-fusebox: circuit open for target=#{target.inspect}"
27
+ end
28
+
29
+ def run(circuit, config)
30
+ result = yield
31
+ circuit.record_success
32
+ result
33
+ rescue *config.trip_on => e
34
+ circuit.record_failure
35
+ raise e
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ module Fusebox
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,48 @@
1
+ <section>
2
+ <header>
3
+ <h1>Fusebox Circuits</h1>
4
+ </header>
5
+
6
+ <div class="table_container">
7
+ <table>
8
+ <thead>
9
+ <tr>
10
+ <th>Target</th>
11
+ <th>State</th>
12
+ <th>Failures</th>
13
+ <th>Opened At</th>
14
+ <th>Cooldown Left</th>
15
+ <th>Probe</th>
16
+ <th>Actions</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <% if @targets.empty? %>
21
+ <tr>
22
+ <td colspan="7">No targets have failed yet.</td>
23
+ </tr>
24
+ <% end %>
25
+ <% @targets.each do |t| %>
26
+ <tr>
27
+ <td><%= h t.target %></td>
28
+ <td>
29
+ <% label_class = {open: "label-danger", half_open: "label-warning", closed: "label-success"}.fetch(t.state) %>
30
+ <span class="label <%= label_class %>"><%= t.state.to_s.upcase.tr("_", "-") %></span>
31
+ </td>
32
+ <td class="num"><%= t.failure_count %></td>
33
+ <td><%= t.opened_at ? relative_time(t.opened_at) : "-" %></td>
34
+ <td class="num"><%= t.state == :open ? "#{t.cooldown_remaining}s" : "-" %></td>
35
+ <td><%= t.probing ? "probing now" : "-" %></td>
36
+ <td>
37
+ <form action="<%= root_path %>fusebox/<%= CGI.escape(t.target) %>/close" method="post">
38
+ <%= csrf_tag %>
39
+ <input class="btn btn-danger" type="submit" value="Force close"
40
+ data-confirm="Force-close the circuit for <%= h t.target %>?">
41
+ </form>
42
+ </td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ </div>
48
+ </section>
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq/web"
4
+ require "sidekiq/fusebox"
5
+
6
+ module Sidekiq
7
+ module Fusebox
8
+ # Adds a "Fusebox" tab to the Sidekiq Web UI listing every known target's
9
+ # circuit state, with a manual "Force close" action for ops overrides.
10
+ #
11
+ # Not required by "sidekiq/fusebox" itself. Wherever you mount Sidekiq::Web
12
+ # (typically config.ru, or config/routes.rb in Rails):
13
+ #
14
+ # require "sidekiq/web"
15
+ # require "sidekiq/fusebox/web"
16
+ #
17
+ module Web
18
+ VIEWS = File.expand_path("web/views", __dir__)
19
+
20
+ def self.registered(app)
21
+ app.get "/fusebox" do
22
+ @targets = Sidekiq::Fusebox::Circuit.snapshot_all(Sidekiq::Fusebox.configuration)
23
+ erb(File.read(File.join(VIEWS, "index.html.erb")))
24
+ end
25
+
26
+ app.post "/fusebox/:target/close" do
27
+ target = route_params(:target)
28
+ Sidekiq::Fusebox::Circuit.new(target, Sidekiq::Fusebox.configuration).force_close!
29
+ redirect "#{root_path}fusebox"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ if Sidekiq::Web.respond_to?(:configure)
37
+ Sidekiq::Web.configure do |cfg|
38
+ cfg.register(Sidekiq::Fusebox::Web, name: "fusebox", tab: "Fusebox", index: "fusebox")
39
+ end
40
+ else
41
+ # Older Sidekiq (< 8.0) had no Config#register; wire the tab up directly.
42
+ Sidekiq::Fusebox::Web.registered(Sidekiq::Web::Application)
43
+ Sidekiq::Web.tabs["Fusebox"] = "fusebox"
44
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+
5
+ require_relative "fusebox/version"
6
+ require_relative "fusebox/configuration"
7
+ require_relative "fusebox/circuit"
8
+ require_relative "fusebox/middleware"
9
+
10
+ module Sidekiq
11
+ # A Sidekiq server middleware that trips an independent circuit breaker per
12
+ # target (extracted from job args), so one flaky downstream service fails
13
+ # fast without starving jobs bound for unrelated targets. See README.md.
14
+ module Fusebox
15
+ class Error < StandardError; end
16
+ class CircuitOpenError < Error; end
17
+
18
+ class << self
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,6 @@
1
+ module Sidekiq
2
+ module Fusebox
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-fusebox
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tanmay Bhawsar
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sidekiq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ description: A Sidekiq server middleware that trips an independent circuit breaker
33
+ per target (extracted from job args, e.g. an integration or hostname), so one flaky
34
+ downstream service fails fast without starving jobs bound for unrelated targets.
35
+ email:
36
+ - tanmay.bhawsar@joshsoftware.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - Rakefile
45
+ - lib/sidekiq/fusebox.rb
46
+ - lib/sidekiq/fusebox/circuit.rb
47
+ - lib/sidekiq/fusebox/configuration.rb
48
+ - lib/sidekiq/fusebox/middleware.rb
49
+ - lib/sidekiq/fusebox/version.rb
50
+ - lib/sidekiq/fusebox/web.rb
51
+ - lib/sidekiq/fusebox/web/views/index.html.erb
52
+ - sig/sidekiq/fusebox.rbs
53
+ homepage: https://github.com/bhawsartanmay/sidekiq-fusebox
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/bhawsartanmay/sidekiq-fusebox
58
+ source_code_uri: https://github.com/bhawsartanmay/sidekiq-fusebox.git
59
+ changelog_uri: https://github.com/bhawsartanmay/sidekiq-fusebox/blob/main/CHANGELOG.md
60
+ bug_tracker_uri: https://github.com/bhawsartanmay/sidekiq-fusebox/issues
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 3.2.0
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.10
76
+ specification_version: 4
77
+ summary: Per-target circuit breaker middleware for Sidekiq.
78
+ test_files: []