Class: Pikuri::Tool::Search::RateLimiter
- Inherits:
-
Object
- Object
- Pikuri::Tool::Search::RateLimiter
- Defined in:
- lib/pikuri/tool/search/rate_limiter.rb
Overview
Thread-safe pacing + circuit-breaker wrapper for a search provider.
#call { ... } enforces a minimum interval between block invocations
(sleeping if the previous was too recent) and watches for
Engines::Unavailable: on one, a cooldown deadline is recorded and
further calls within the window raise Engines::Unavailable immediately
without running the block — so a rate-limited or bot-blocked provider
isn't hammered with retries.
Uses wall-clock Time.now, not monotonic: the intervals (1s–5min) are well above any NTP step, and Time.now keeps tests fakeable with Timecop.
Sharing
P_shared_locked, and sharing is mandatory: a limiter must be scoped
to the resource it protects, which here is one egress IP and one API
key's quota — never one agent. Hence each provider holds its limiter in
a class-level constant, so every agent in the VM queues behind the same
one. Per-agent limiters would let ten agents fire ten simultaneous
requests from one IP and earn exactly the ban this class exists to
avoid. (Contrast Agent::Listener::RateLimited, which paces one
renderer's repaints and is therefore per listener chain.)
Two consequences of that scope, both correct and both surprising:
- It is a global queue, not just a pacer. The mutex is held across
the block — the HTTP call included — so ten agents searching at once
serialize, with no timeout and no queue position.
cancellable:covers the pacing wait, which is what the lock holder is doing; a caller still parked inMutex#synchronizewaiting to acquire cannot observe its own cancel. It does still benefit, because every agent that cancels releases early and drains the queue faster. Making the acquisition itself interruptible means pollingMutex#try_lockwith explicit +lock+/+unlock+ — deliberately not built for a three-concurrent-agent case nothing has yet. - The circuit breaker is global too. One agent tripping the provider's soft-block puts every other agent into the same cooldown. Right, because the block is IP-wide and the others were blocked anyway — but from the host's side nine agents lose a provider for something the tenth did.
Thread-safe.
Instance Method Summary collapse
-
#call(cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ Object
Run the block subject to the throttle and cooldown rules, with the mutex held (only one block runs at a time per limiter).
-
#initialize(min_interval:, cooldown:) ⇒ RateLimiter
constructor
A new instance of RateLimiter.
Constructor Details
#initialize(min_interval:, cooldown:) ⇒ RateLimiter
Returns a new instance of RateLimiter.
58 59 60 61 62 63 64 |
# File 'lib/pikuri/tool/search/rate_limiter.rb', line 58 def initialize(min_interval:, cooldown:) @min_interval = min_interval @cooldown = cooldown @mutex = Mutex.new @last_call_at = nil @cooldown_until = nil end |
Instance Method Details
#call(cancellable: Pikuri::Agent::Control::Cancellable::NEVER) ⇒ Object
Run the block subject to the throttle and cooldown rules, with the mutex held (only one block runs at a time per limiter). If it raises Engines::Unavailable, cooldown is armed and the exception re-raised; any other exception bubbles up without arming cooldown.
The pacing wait happens through cancellable, so the calling agent
can abandon it on a user cancel. It is a per-call argument rather than
constructor state because one limiter serves every agent in the VM
(see == Sharing) while a token belongs to one of them — and it is
always a real token, defaulting to
Agent::Control::Cancellable::NEVER rather than nil, so
there is one wait path instead of two.
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/pikuri/tool/search/rate_limiter.rb', line 89 def call(cancellable: Pikuri::Agent::Control::Cancellable::NEVER) @mutex.synchronize do now = Time.now if @cooldown_until && now < @cooldown_until remaining = (@cooldown_until - now).ceil raise Engines::Unavailable, "rate-limiter cooldown active for another #{remaining}s" end if @last_call_at elapsed = now - @last_call_at cancellable.sleep(@min_interval - elapsed) if elapsed < @min_interval end @last_call_at = Time.now begin yield rescue Engines::Unavailable @cooldown_until = Time.now + @cooldown raise end end end |