Table of contents

What is the proper way to handle rate limiting in Ruby web scraping?

Rate limiting is a crucial aspect of responsible web scraping that helps prevent overwhelming target servers and avoid getting blocked. In Ruby, there are several effective strategies to implement proper rate limiting that balance scraping efficiency with respectful server interaction.

Understanding Rate Limiting

Rate limiting controls the frequency of requests sent to a server within a specific time period. Most websites implement rate limiting to protect their infrastructure from abuse and ensure fair resource allocation among users. When scraping, exceeding these limits can result in:

  • HTTP 429 (Too Many Requests) errors
  • IP address blocking
  • CAPTCHA challenges
  • Complete access denial

Basic Sleep-Based Rate Limiting

The simplest approach to rate limiting in Ruby is using sleep to introduce delays between requests:

require 'net/http'
require 'uri'

class BasicScraper
  def initialize(delay: 1.0)
    @delay = delay
  end

  def fetch_url(url)
    uri = URI(url)
    response = Net::HTTP.get_response(uri)

    # Add delay after each request
    sleep(@delay)

    response
  end

  def scrape_urls(urls)
    results = []

    urls.each do |url|
      puts "Fetching: #{url}"
      response = fetch_url(url)
      results << process_response(response)
    end

    results
  end

  private

  def process_response(response)
    case response.code.to_i
    when 200
      response.body
    when 429
      puts "Rate limited! Consider increasing delay."
      nil
    else
      puts "Error: #{response.code}"
      nil
    end
  end
end

# Usage
scraper = BasicScraper.new(delay: 2.0)
urls = ['https://example.com/page1', 'https://example.com/page2']
results = scraper.scrape_urls(urls)

Advanced Rate Limiting with Token Bucket Algorithm

For more sophisticated rate limiting, implement a token bucket algorithm:

class TokenBucket
  def initialize(capacity:, refill_rate:)
    @capacity = capacity
    @tokens = capacity
    @refill_rate = refill_rate
    @last_refill = Time.now
    @mutex = Mutex.new
  end

  def consume(tokens = 1)
    @mutex.synchronize do
      refill_tokens

      if @tokens >= tokens
        @tokens -= tokens
        true
      else
        false
      end
    end
  end

  def wait_for_token
    until consume(1)
      sleep(0.1)
    end
  end

  private

  def refill_tokens
    now = Time.now
    time_passed = now - @last_refill
    tokens_to_add = time_passed * @refill_rate

    @tokens = [@tokens + tokens_to_add, @capacity].min
    @last_refill = now
  end
end

class RateLimitedScraper
  def initialize(requests_per_second: 1.0)
    @bucket = TokenBucket.new(
      capacity: 10,
      refill_rate: requests_per_second
    )
  end

  def fetch_with_rate_limit(url)
    @bucket.wait_for_token

    uri = URI(url)
    Net::HTTP.get_response(uri)
  end
end

# Usage
scraper = RateLimitedScraper.new(requests_per_second: 0.5)
response = scraper.fetch_with_rate_limit('https://example.com')

Exponential Backoff for Error Handling

Implement exponential backoff to handle rate limiting errors gracefully:

require 'net/http'
require 'json'

class ExponentialBackoffScraper
  MAX_RETRIES = 5
  BASE_DELAY = 1

  def fetch_with_backoff(url)
    retries = 0

    begin
      response = make_request(url)

      case response.code.to_i
      when 200
        return response
      when 429, 503, 502, 504
        raise RateLimitError, "Rate limited or server error: #{response.code}"
      else
        raise StandardError, "HTTP error: #{response.code}"
      end

    rescue RateLimitError => e
      retries += 1

      if retries <= MAX_RETRIES
        delay = calculate_delay(retries, response)
        puts "Rate limited. Retrying in #{delay} seconds (attempt #{retries}/#{MAX_RETRIES})"
        sleep(delay)
        retry
      else
        puts "Max retries exceeded for #{url}"
        raise e
      end
    end
  end

  private

  def make_request(url)
    uri = URI(url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = uri.scheme == 'https'

    request = Net::HTTP::Get.new(uri)
    request['User-Agent'] = 'Mozilla/5.0 (compatible; RubyBot/1.0)'

    http.request(request)
  end

  def calculate_delay(attempt, response = nil)
    # Check for Retry-After header
    if response&.key?('retry-after')
      return response['retry-after'].to_i
    end

    # Exponential backoff with jitter
    base_delay = BASE_DELAY * (2 ** (attempt - 1))
    jitter = rand(0.1..0.3) * base_delay

    base_delay + jitter
  end

  class RateLimitError < StandardError; end
end

Using Queue-Based Rate Limiting

For concurrent scraping scenarios, implement queue-based rate limiting:

require 'thread'
require 'net/http'

class QueuedScraper
  def initialize(workers: 3, delay: 1.0)
    @workers = workers
    @delay = delay
    @queue = Queue.new
    @results = Queue.new
    @threads = []
  end

  def scrape_urls(urls)
    # Add URLs to queue
    urls.each { |url| @queue << url }

    # Start worker threads
    start_workers

    # Wait for completion and collect results
    wait_for_completion(urls.length)
  end

  private

  def start_workers
    @workers.times do
      @threads << Thread.new do
        worker_loop
      end
    end
  end

  def worker_loop
    loop do
      begin
        url = @queue.pop(true) # Non-blocking pop
        result = fetch_url_with_rate_limit(url)
        @results << { url: url, result: result }
      rescue ThreadError
        # Queue is empty
        break
      end
    end
  end

  def fetch_url_with_rate_limit(url)
    # Rate limiting per worker
    sleep(@delay)

    uri = URI(url)
    response = Net::HTTP.get_response(uri)

    handle_response(response)
  end

  def handle_response(response)
    case response.code.to_i
    when 200
      response.body
    when 429
      # Could implement additional backoff here
      sleep(@delay * 2)
      nil
    else
      nil
    end
  end

  def wait_for_completion(expected_count)
    results = []

    expected_count.times do
      results << @results.pop
    end

    @threads.each(&:join)
    results
  end
end

# Usage
scraper = QueuedScraper.new(workers: 2, delay: 1.5)
urls = (1..10).map { |i| "https://example.com/page#{i}" }
results = scraper.scrape_urls(urls)

Implementing Adaptive Rate Limiting

Create an adaptive system that adjusts based on server responses:

class AdaptiveScraper
  def initialize
    @base_delay = 1.0
    @current_delay = @base_delay
    @success_count = 0
    @error_count = 0
    @adjustment_threshold = 5
  end

  def fetch_adaptive(url)
    sleep(@current_delay)

    response = make_request(url)
    adjust_rate_based_on_response(response)

    response
  end

  private

  def adjust_rate_based_on_response(response)
    case response.code.to_i
    when 200
      @success_count += 1
      @error_count = 0

      # Decrease delay after successful requests
      if @success_count >= @adjustment_threshold
        @current_delay = [@current_delay * 0.9, @base_delay * 0.5].max
        @success_count = 0
        puts "Decreased delay to #{@current_delay}"
      end

    when 429, 503
      @error_count += 1
      @success_count = 0

      # Increase delay after rate limiting
      @current_delay *= 2
      puts "Increased delay to #{@current_delay} due to #{response.code}"

    end
  end

  def make_request(url)
    uri = URI(url)
    Net::HTTP.get_response(uri)
  end
end

Rate Limiting with Popular Ruby Gems

Using HTTParty with Rate Limiting

require 'httparty'

class HTTPartyRateLimited
  include HTTParty

  def initialize(delay: 1.0)
    @delay = delay
    @last_request_time = Time.now - delay
  end

  def get_with_rate_limit(url, options = {})
    wait_if_needed

    response = self.class.get(url, options)
    @last_request_time = Time.now

    handle_rate_limiting(response, url, options)
  end

  private

  def wait_if_needed
    time_since_last = Time.now - @last_request_time

    if time_since_last < @delay
      sleep_time = @delay - time_since_last
      sleep(sleep_time)
    end
  end

  def handle_rate_limiting(response, url, options)
    if response.code == 429
      retry_after = response.headers['retry-after']&.to_i || (@delay * 2)

      puts "Rate limited. Waiting #{retry_after} seconds..."
      sleep(retry_after)

      # Retry the request
      return get_with_rate_limit(url, options)
    end

    response
  end
end

# Usage
scraper = HTTPartyRateLimited.new(delay: 2.0)
response = scraper.get_with_rate_limit('https://api.example.com/data')

Best Practices for Rate Limiting

1. Respect robots.txt and Rate Limiting Headers

def check_rate_limit_headers(response)
  headers = response.to_hash

  if headers['x-ratelimit-remaining']
    remaining = headers['x-ratelimit-remaining'].first.to_i

    if remaining < 10
      reset_time = headers['x-ratelimit-reset']&.first&.to_i
      wait_time = reset_time ? reset_time - Time.now.to_i : 60

      puts "Rate limit nearly exceeded. Waiting #{wait_time} seconds."
      sleep(wait_time) if wait_time > 0
    end
  end
end

2. Monitor and Log Rate Limiting Events

require 'logger'

class MonitoredScraper
  def initialize
    @logger = Logger.new('scraper.log')
    @rate_limit_events = 0
  end

  def fetch_with_monitoring(url)
    start_time = Time.now

    begin
      response = make_request(url)

      if response.code.to_i == 429
        @rate_limit_events += 1
        @logger.warn "Rate limited for #{url}. Total events: #{@rate_limit_events}"
      end

      duration = Time.now - start_time
      @logger.info "Fetched #{url} in #{duration}s (#{response.code})"

      response
    rescue => e
      @logger.error "Error fetching #{url}: #{e.message}"
      raise
    end
  end
end

3. Use Configuration for Different Environments

class ConfigurableScraper
  def initialize(config = {})
    @config = default_config.merge(config)
  end

  def default_config
    {
      delay: ENV.fetch('SCRAPER_DELAY', 1.0).to_f,
      max_retries: ENV.fetch('SCRAPER_MAX_RETRIES', 3).to_i,
      user_agent: ENV.fetch('SCRAPER_USER_AGENT', 'RubyBot/1.0'),
      timeout: ENV.fetch('SCRAPER_TIMEOUT', 30).to_i
    }
  end

  def fetch_url(url)
    sleep(@config[:delay])

    # Make request with configured parameters
    make_request_with_config(url)
  end
end

Rate Limiting in Production Environments

When deploying Ruby scrapers in production, consider these additional strategies:

Using Redis for Distributed Rate Limiting

require 'redis'

class DistributedRateLimiter
  def initialize(redis_url: 'redis://localhost:6379')
    @redis = Redis.new(url: redis_url)
    @window_size = 60 # 1 minute window
  end

  def allow_request?(key, limit)
    current_time = Time.now.to_i
    window_start = current_time - @window_size

    # Remove old entries
    @redis.zremrangebyscore(key, 0, window_start)

    # Count current requests
    current_requests = @redis.zcard(key)

    if current_requests < limit
      # Add current request
      @redis.zadd(key, current_time, "#{current_time}-#{rand(1000)}")
      @redis.expire(key, @window_size)
      true
    else
      false
    end
  end
end

class ProductionScraper
  def initialize
    @rate_limiter = DistributedRateLimiter.new
  end

  def fetch_with_distributed_limiting(url)
    domain = URI(url).host

    until @rate_limiter.allow_request?("scraper:#{domain}", 10)
      puts "Rate limit exceeded for #{domain}. Waiting..."
      sleep(1)
    end

    make_request(url)
  end
end

Handling Multiple Domains with Different Limits

class MultiDomainScraper
  def initialize
    @domain_limits = {
      'api.example.com' => { delay: 2.0, max_concurrent: 1 },
      'data.example.com' => { delay: 0.5, max_concurrent: 3 },
      'default' => { delay: 1.0, max_concurrent: 2 }
    }
    @domain_queues = {}
  end

  def fetch_url(url)
    domain = URI(url).host
    config = @domain_limits[domain] || @domain_limits['default']

    get_domain_queue(domain, config).push(url)
  end

  private

  def get_domain_queue(domain, config)
    @domain_queues[domain] ||= DomainQueue.new(config)
  end
end

Testing Rate Limiting Implementation

Ensure your rate limiting works correctly with proper testing:

require 'rspec'
require 'webmock'

RSpec.describe 'Rate Limiting' do
  include WebMock::API

  before do
    WebMock.enable!
  end

  after do
    WebMock.disable!
  end

  it 'respects rate limits and retries after 429 errors' do
    stub_request(:get, 'https://example.com')
      .to_return(status: 429, headers: { 'Retry-After' => '2' })
      .then
      .to_return(status: 200, body: 'Success')

    scraper = ExponentialBackoffScraper.new

    start_time = Time.now
    response = scraper.fetch_with_backoff('https://example.com')
    end_time = Time.now

    expect(response.code.to_i).to eq(200)
    expect(end_time - start_time).to be >= 2
  end

  it 'limits requests per second correctly' do
    stub_request(:get, 'https://example.com')
      .to_return(status: 200, body: 'OK')

    scraper = RateLimitedScraper.new(requests_per_second: 2.0)

    start_time = Time.now

    5.times do
      scraper.fetch_with_rate_limit('https://example.com')
    end

    end_time = Time.now
    duration = end_time - start_time

    # Should take at least 2 seconds for 5 requests at 2 req/sec
    expect(duration).to be >= 2.0
  end
end

Conclusion

Proper rate limiting in Ruby web scraping involves multiple strategies working together. Start with basic sleep-based delays, then implement more sophisticated approaches like token buckets or exponential backoff based on your specific needs. Always monitor server responses, respect rate limiting headers, and adapt your approach based on the target website's behavior.

Remember that rate limiting is not just about avoiding blocks—it's about being a responsible web citizen and ensuring your scraping activities don't negatively impact the websites you're accessing. When dealing with complex scraping scenarios that require precise timing control, consider using professional web scraping APIs that handle rate limiting automatically while providing reliable access to web content.

For advanced scenarios involving JavaScript-heavy sites, you might also need to consider how to handle timeouts effectively when combining rate limiting with browser automation tools. Additionally, when working with concurrent scraping operations, understanding how to run multiple pages in parallel can help you design more efficient rate-limited systems.

Try WebScraping.AI for Your Web Scraping Needs

Looking for a powerful web scraping solution? WebScraping.AI provides an LLM-powered API that combines Chromium JavaScript rendering with rotating proxies for reliable data extraction.

Key Features:

  • AI-powered extraction: Ask questions about web pages or extract structured data fields
  • JavaScript rendering: Full Chromium browser support for dynamic content
  • Rotating proxies: Datacenter and residential proxies from multiple countries
  • Easy integration: Simple REST API with SDKs for Python, Ruby, PHP, and more
  • Reliable & scalable: Built for developers who need consistent results

Getting Started:

Get page content with AI analysis:

curl "https://api.webscraping.ai/ai/question?url=https://example.com&question=What is the main topic?&api_key=YOUR_API_KEY"

Extract structured data:

curl "https://api.webscraping.ai/ai/fields?url=https://example.com&fields[title]=Page title&fields[price]=Product price&api_key=YOUR_API_KEY"

Try in request builder

Related Questions

Get Started Now

WebScraping.AI provides rotating proxies, Chromium rendering and built-in HTML parser for web scraping
Icon