Module: InstagramConnect::RateLimiter

Defined in:
lib/instagram_connect/rate_limiter.rb

Overview

Keeps calls inside Meta's per-account ceilings.

Meta publishes several limits at once and exhausting any of them throttles the whole app for hours, so the cost of being slightly conservative is far lower than the cost of being slightly wrong.

Defined Under Namespace

Classes: Outcome

Constant Summary collapse

BUCKETS =

Meta's documented per-account limits. The graph default is the floor of the 4800 × impressions formula: impressions are not knowable from the messaging API, so the floor is what is safe to assume until the usage headers say otherwise.

{
  graph: { limit: 4800, window: 86_400 },
  conversations: { limit: 2, window: 1 },
  send_text: { limit: 100, window: 1 },
  send_media: { limit: 10, window: 1 },
  private_reply: { limit: 750, window: 3600 },
  publish: { limit: 50, window: 86_400 }
}.freeze

Class Method Summary collapse

Class Method Details

.acquire(account:, bucket:, now: Time.current) ⇒ Object

Claims one call from a bucket. Returns an Outcome saying whether to proceed and, if not, how long to wait — callers re-enqueue rather than sleep, since a worker asleep on a rate limit is a worker not delivering anything else.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/instagram_connect/rate_limiter.rb', line 31

def acquire(account:, bucket:, now: Time.current)
  spec = BUCKETS.fetch(bucket.to_sym)
  row = budget_for(, bucket, spec, now)

  return Outcome.new(allowed: false, retry_after: (row.blocked_until - now).ceil) if row.blocked?

  claimed = ApiBudget.where(id: row.id).where("used < limit_value")
                     .update_all("used = used + 1, updated_at = #{ApiBudget.connection.quote(now)}")

  if claimed == 1
    Outcome.new(allowed: true, retry_after: nil)
  else
    Outcome.new(allowed: false, retry_after: (row.window_end - now).ceil.clamp(1, spec[:window]))
  end
end

.block!(account:, bucket:, seconds:, now: Time.current) ⇒ Object

Called when Meta returns an explicit retry_after: one 429 pauses every job for that account, rather than each one having to discover it.



65
66
67
68
# File 'lib/instagram_connect/rate_limiter.rb', line 65

def block!(account:, bucket:, seconds:, now: Time.current)
  spec = BUCKETS.fetch(bucket.to_sym)
  budget_for(, bucket, spec, now).update!(blocked_until: now + seconds.to_i)
end

.budget_for(account, bucket, spec, now) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/instagram_connect/rate_limiter.rb', line 70

def budget_for(, bucket, spec, now)
  start = window_start(now, spec[:window])
  ApiBudget.find_or_create_by!(
    account_id: .id, bucket: bucket.to_s, window_start: start
  ) do |row|
    row.window_seconds = spec[:window]
    row.limit_value = spec[:limit]
  end
rescue ActiveRecord::RecordNotUnique
  ApiBudget.find_by!(account_id: .id, bucket: bucket.to_s, window_start: start)
end

.observe(account:, bucket:, usage:, now: Time.current) ⇒ Object

Records what Meta reported after a call. Its own numbers beat any local arithmetic, so a warning halves the remaining allowance and a critical reading blocks the account outright until the stated recovery time.



50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/instagram_connect/rate_limiter.rb', line 50

def observe(account:, bucket:, usage:, now: Time.current)
  return if usage.nil?

  spec = BUCKETS.fetch(bucket.to_sym)
  row = budget_for(, bucket, spec, now)

  if usage.critical?
    row.update!(blocked_until: now + (usage.retry_after || spec[:window]))
  elsif usage.warning?
    row.update!(limit_value: [ row.limit_value / 2, row.used + 1 ].max)
  end
end

.window_start(now, seconds) ⇒ Object

Fixed windows aligned to the epoch, so every process agrees on where a window starts without having to coordinate.



84
85
86
# File 'lib/instagram_connect/rate_limiter.rb', line 84

def window_start(now, seconds)
  Time.at((now.to_i / seconds) * seconds).utc
end