the shed // ruby for devops

A health check that only ever curls one URL from your laptop tells you nothing about a fleet. This tutorial builds a concurrent, retrying, backoff-aware API health checker in pure Ruby — no gems, no framework — that reports healthy, degraded, or down with an exit code cron and CI already understand.

Step through the build below:




api_health_check.rb

A transient 503 during a rolling deploy is not the same problem as a service that has been down for ten minutes, but a health check that just does curl url; echo $? treats them identically — and either pages someone at 3am for a blip, or worse, hides a real outage behind a script that only checks once and gives up.

This script checks a whole list of endpoints concurrently, retries failures with exponential backoff before giving up, and reports three distinct outcomes: healthy (passed first try), degraded (passed, but needed a retry), and down (failed every retry). That distinction is the difference between a noisy pager and a useful one.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# api_health_check.rb
#
# Concurrently polls a list of HTTP(S) endpoints, checks status code /
# latency / an optional response-body pattern, retries failures with
# exponential backoff, and reports pass/fail per endpoint. Built entirely on
# Ruby's stdlib (Net::HTTP + Thread) -- no gems, so it drops onto any box
# with Ruby installed and works from cron, CI, or a Nagios-style check.
#
# Endpoints are described in a small JSON config, e.g.:
#
#   [
#     { "name": "web-app",  "url": "https://example.com/healthz", "expect_status": 200 },
#     { "name": "internal", "url": "http://10.0.0.5:9000/status", "expect_status": 200,
#       "expect_body": "\"ok\":\\s*true", "timeout": 3 }
#   ]
#
# Usage:
#   ruby api_health_check.rb --config endpoints.json
#   ruby api_health_check.rb --config endpoints.json --json
#   ruby api_health_check.rb --config endpoints.json --retries 3 --concurrency 10
#   ruby api_health_check.rb --url https://example.com/healthz   # quick one-off check
#
# Exit codes (cron/CI friendly):
#   0 - all endpoints healthy
#   1 - at least one endpoint degraded (succeeded only after retrying)
#   2 - at least one endpoint down (failed all retries)
require 'net/http'
require 'uri'
require 'json'
require 'optparse'
require 'timeout'
require 'time'
CheckResult = Struct.new(:name, :url, :status, :ok, :latency_ms, :attempts, :error, keyword_init: true) do
  def to_h
    {
      name: name, url: url, status: status, ok: ok,
      latency_ms: latency_ms, attempts: attempts, error: error
    }
  end
end
class EndpointChecker
  def initialize(endpoint, retries:, backoff_base:)
    @endpoint = endpoint
    @retries = retries
    @backoff_base = backoff_base
  end
  # Performs the check, retrying on failure with exponential backoff
  # (backoff_base * 2**attempt seconds). Returns a CheckResult.
  def call
    name = @endpoint['name'] || @endpoint['url']
    url = @endpoint['url']
    expect_status = @endpoint['expect_status'] || 200
    expect_body = @endpoint['expect_body']
    timeout = @endpoint['timeout'] || 5
    attempts = 0
    last_error = nil
    (@retries + 1).times do |i|
      attempts += 1
      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      begin
        status, body = perform_request(url, timeout)
        latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
        if status == expect_status && (expect_body.nil? || body =~ Regexp.new(expect_body))
          return CheckResult.new(
            name: name, url: url, status: status, ok: true,
            latency_ms: latency_ms, attempts: attempts, error: nil
          )
        end
        last_error = "expected status #{expect_status}" \
                     "#{expect_body ? " and body matching /#{expect_body}/" : ''}, got status #{status}"
      rescue StandardError => e
        last_error = "#{e.class}: #{e.message}"
      end
      sleep(@backoff_base * (2**i)) if i < @retries
    end
    CheckResult.new(
      name: name, url: url, status: nil, ok: false,
      latency_ms: nil, attempts: attempts, error: last_error
    )
  end
  private
  def perform_request(url_string, timeout)
    uri = URI.parse(url_string)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')
    http.open_timeout = timeout
    http.read_timeout = timeout
    request = Net::HTTP::Get.new(uri.request_uri.empty? ? '/' : uri.request_uri)
    response = http.request(request)
    [response.code.to_i, response.body.to_s]
  end
end
def parse_options(argv)
  opts = {
    config: nil, url: nil, retries: 2, backoff_base: 0.5,
    concurrency: 8, json: false
  }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby api_health_check.rb [options]'
    o.on('--config FILE', 'JSON file describing endpoints to check') { |v| opts[:config] = v }
    o.on('--url URL', 'Quick one-off check of a single URL (expects HTTP 200)') { |v| opts[:url] = v }
    o.on('--retries N', Integer, 'Retries per endpoint before marking down (default: 2)') { |v| opts[:retries] = v }
    o.on('--backoff SECONDS', Float, 'Base backoff in seconds; doubles each retry (default: 0.5)') { |v| opts[:backoff_base] = v }
    o.on('--concurrency N', Integer, 'Max endpoints checked in parallel (default: 8)') { |v| opts[:concurrency] = v }
    o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
    o.on('-h', '--help', 'Show this help') do
      puts o
      exit 0
    end
  end
  parser.parse!(argv)
  opts
end
def load_endpoints(opts)
  return [{ 'name' => opts[:url], 'url' => opts[:url] }] if opts[:url]
  raise ArgumentError, 'Provide --config FILE or --url URL' unless opts[:config]
  raise ArgumentError, "Config not found: #{opts[:config]}" unless File.readable?(opts[:config])
  JSON.parse(File.read(opts[:config]))
end
# Runs checks concurrently using a bounded thread pool (a simple work queue
# fed to N worker threads), so a config with 200 endpoints doesn't spawn 200
# live sockets at once.
def run_checks(endpoints, opts)
  queue = Queue.new
  endpoints.each { |e| queue << e }
  results = Queue.new
  workers = Array.new([opts[:concurrency], endpoints.size].min.clamp(1, Float::INFINITY).to_i) do
    Thread.new do
      until queue.empty?
        endpoint = begin
          queue.pop(true)
        rescue ThreadError
          nil
        end
        next unless endpoint
        checker = EndpointChecker.new(endpoint, retries: opts[:retries], backoff_base: opts[:backoff_base])
        results << checker.call
      end
    end
  end
  workers.each(&:join)
  out = []
  out << results.pop until results.empty?
  out
end
def print_text_report(results)
  puts "api_health_check: #{results.size} endpoint(s) checked"
  puts '-' * 72
  results.each do |r|
    icon = r.ok ? 'OK  ' : 'DOWN'
    detail = r.ok ? "status=#{r.status} latency=#{r.latency_ms}ms attempts=#{r.attempts}" : "error=#{r.error} attempts=#{r.attempts}"
    puts "[#{icon}] #{r.name.ljust(20)} #{detail}"
  end
end
if __FILE__ == $PROGRAM_NAME
  begin
    options = parse_options(ARGV)
    endpoints = load_endpoints(options)
  rescue ArgumentError, JSON::ParserError => e
    warn "Error: #{e.message}"
    exit 3
  end
  results = run_checks(endpoints, options)
  if options[:json]
    puts JSON.pretty_generate(
      checked_at: Time.now.utc.iso8601,
      endpoint_count: results.size,
      results: results.map(&:to_h)
    )
  else
    print_text_report(results)
  end
  down = results.count { |r| !r.ok }
  degraded = results.count { |r| r.ok && r.attempts > 1 }
  exit(down.positive? ? 2 : degraded.positive? ? 1 : 0)
end

The concurrency model is a bounded worker pool, not “spawn one thread per endpoint.” A Queue holds every endpoint to check, and min(--concurrency, endpoint count) threads pop work off it until it’s empty. A config with 200 endpoints and --concurrency 8 never opens more than 8 sockets at once — important both for being a polite network citizen and for not exhausting file descriptors on the checking box itself.

Retries live entirely inside EndpointChecker#call, not in the worker loop. Each worker thread calls one checker per endpoint and that checker owns its own retry loop with sleep(backoff_base * 2**i) between attempts — so a slow, retrying endpoint doesn’t block other threads from picking up new work, it only blocks itself.

The healthy/degraded/down distinction falls straight out of the data already being collected: ok == true with attempts == 1 is healthy, ok == true with attempts > 1 is degraded, and ok == false after every retry is down. No separate state machine — just three fields on the result struct and a comparison at report time.

api_health_check: 4 endpoint(s) checked
------------------------------------------------------------------------
[OK  ] web-healthy          status=200 latency=9.0ms attempts=1
[DOWN] bad-content          error=expected status 200 and body matching /"ok":\s*true/, got status 200 attempts=3
[DOWN] svc-down             error=expected status 200, got status 500 attempts=3
[OK  ] api-flaky            status=200 latency=1.4ms attempts=3

Get the code

Full script + README + mock test server on GitHub: ruby-devops-toolkit/api-health-check

prerequisites

Prerequisites

  • Ruby ≥ 2.7 (tested on 3.0.2) — uses only net/http, uri, json, optparse, timeout, and time from the standard library, no gems.
  • Network access from the box running the script to whatever endpoints you’re checking.
api-health-check worker pool architecture diagram

A bounded Queue + N worker threads, each owning its own retry/backoff loop
the full script

Full Script (for reference)

api_health_check.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# api_health_check.rb
#
# Concurrently polls a list of HTTP(S) endpoints, checks status code /
# latency / an optional response-body pattern, retries failures with
# exponential backoff, and reports pass/fail per endpoint. Built entirely on
# Ruby's stdlib (Net::HTTP + Thread) -- no gems, so it drops onto any box
# with Ruby installed and works from cron, CI, or a Nagios-style check.
#
# Endpoints are described in a small JSON config, e.g.:
#
#   [
#     { "name": "web-app",  "url": "https://example.com/healthz", "expect_status": 200 },
#     { "name": "internal", "url": "http://10.0.0.5:9000/status", "expect_status": 200,
#       "expect_body": "\"ok\":\\s*true", "timeout": 3 }
#   ]
#
# Usage:
#   ruby api_health_check.rb --config endpoints.json
#   ruby api_health_check.rb --config endpoints.json --json
#   ruby api_health_check.rb --config endpoints.json --retries 3 --concurrency 10
#   ruby api_health_check.rb --url https://example.com/healthz   # quick one-off check
#
# Exit codes (cron/CI friendly):
#   0 - all endpoints healthy
#   1 - at least one endpoint degraded (succeeded only after retrying)
#   2 - at least one endpoint down (failed all retries)
require 'net/http'
require 'uri'
require 'json'
require 'optparse'
require 'timeout'
require 'time'
CheckResult = Struct.new(:name, :url, :status, :ok, :latency_ms, :attempts, :error, keyword_init: true) do
  def to_h
    {
      name: name, url: url, status: status, ok: ok,
      latency_ms: latency_ms, attempts: attempts, error: error
    }
  end
end
class EndpointChecker
  def initialize(endpoint, retries:, backoff_base:)
    @endpoint = endpoint
    @retries = retries
    @backoff_base = backoff_base
  end
  # Performs the check, retrying on failure with exponential backoff
  # (backoff_base * 2**attempt seconds). Returns a CheckResult.
  def call
    name = @endpoint['name'] || @endpoint['url']
    url = @endpoint['url']
    expect_status = @endpoint['expect_status'] || 200
    expect_body = @endpoint['expect_body']
    timeout = @endpoint['timeout'] || 5
    attempts = 0
    last_error = nil
    (@retries + 1).times do |i|
      attempts += 1
      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      begin
        status, body = perform_request(url, timeout)
        latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
        if status == expect_status && (expect_body.nil? || body =~ Regexp.new(expect_body))
          return CheckResult.new(
            name: name, url: url, status: status, ok: true,
            latency_ms: latency_ms, attempts: attempts, error: nil
          )
        end
        last_error = "expected status #{expect_status}" \
                     "#{expect_body ? " and body matching /#{expect_body}/" : ''}, got status #{status}"
      rescue StandardError => e
        last_error = "#{e.class}: #{e.message}"
      end
      sleep(@backoff_base * (2**i)) if i < @retries
    end
    CheckResult.new(
      name: name, url: url, status: nil, ok: false,
      latency_ms: nil, attempts: attempts, error: last_error
    )
  end
  private
  def perform_request(url_string, timeout)
    uri = URI.parse(url_string)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')
    http.open_timeout = timeout
    http.read_timeout = timeout
    request = Net::HTTP::Get.new(uri.request_uri.empty? ? '/' : uri.request_uri)
    response = http.request(request)
    [response.code.to_i, response.body.to_s]
  end
end
def parse_options(argv)
  opts = {
    config: nil, url: nil, retries: 2, backoff_base: 0.5,
    concurrency: 8, json: false
  }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby api_health_check.rb [options]'
    o.on('--config FILE', 'JSON file describing endpoints to check') { |v| opts[:config] = v }
    o.on('--url URL', 'Quick one-off check of a single URL (expects HTTP 200)') { |v| opts[:url] = v }
    o.on('--retries N', Integer, 'Retries per endpoint before marking down (default: 2)') { |v| opts[:retries] = v }
    o.on('--backoff SECONDS', Float, 'Base backoff in seconds; doubles each retry (default: 0.5)') { |v| opts[:backoff_base] = v }
    o.on('--concurrency N', Integer, 'Max endpoints checked in parallel (default: 8)') { |v| opts[:concurrency] = v }
    o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
    o.on('-h', '--help', 'Show this help') do
      puts o
      exit 0
    end
  end
  parser.parse!(argv)
  opts
end
def load_endpoints(opts)
  return [{ 'name' => opts[:url], 'url' => opts[:url] }] if opts[:url]
  raise ArgumentError, 'Provide --config FILE or --url URL' unless opts[:config]
  raise ArgumentError, "Config not found: #{opts[:config]}" unless File.readable?(opts[:config])
  JSON.parse(File.read(opts[:config]))
end
# Runs checks concurrently using a bounded thread pool (a simple work queue
# fed to N worker threads), so a config with 200 endpoints doesn't spawn 200
# live sockets at once.
def run_checks(endpoints, opts)
  queue = Queue.new
  endpoints.each { |e| queue << e }
  results = Queue.new
  workers = Array.new([opts[:concurrency], endpoints.size].min.clamp(1, Float::INFINITY).to_i) do
    Thread.new do
      until queue.empty?
        endpoint = begin
          queue.pop(true)
        rescue ThreadError
          nil
        end
        next unless endpoint
        checker = EndpointChecker.new(endpoint, retries: opts[:retries], backoff_base: opts[:backoff_base])
        results << checker.call
      end
    end
  end
  workers.each(&:join)
  out = []
  out << results.pop until results.empty?
  out
end
def print_text_report(results)
  puts "api_health_check: #{results.size} endpoint(s) checked"
  puts '-' * 72
  results.each do |r|
    icon = r.ok ? 'OK  ' : 'DOWN'
    detail = r.ok ? "status=#{r.status} latency=#{r.latency_ms}ms attempts=#{r.attempts}" : "error=#{r.error} attempts=#{r.attempts}"
    puts "[#{icon}] #{r.name.ljust(20)} #{detail}"
  end
end
if __FILE__ == $PROGRAM_NAME
  begin
    options = parse_options(ARGV)
    endpoints = load_endpoints(options)
  rescue ArgumentError, JSON::ParserError => e
    warn "Error: #{e.message}"
    exit 3
  end
  results = run_checks(endpoints, options)
  if options[:json]
    puts JSON.pretty_generate(
      checked_at: Time.now.utc.iso8601,
      endpoint_count: results.size,
      results: results.map(&:to_h)
    )
  else
    print_text_report(results)
  end
  down = results.count { |r| !r.ok }
  degraded = results.count { |r| r.ok && r.attempts > 1 }
  exit(down.positive? ? 2 : degraded.positive? ? 1 : 0)
end
how it works

Step-by-Step Walkthrough

Four pieces do all the work:

  • EndpointChecker#call performs one HTTP GET via Net::HTTP, checks the
    status code and an optional body regex, and retries with exponential backoff
    (backoff_base * 2**attempt) up to --retries times before returning a
    CheckResult struct.
  • run_checks builds the bounded worker pool: a Queue of endpoints feeds
    N worker Threads, each looping EndpointChecker calls until the queue is
    empty, pushing results onto a second, thread-safe Queue.
  • The CLI layer accepts either --config endpoints.json for a fleet or a single
    --url for a quick manual check, so the same script works as a one-liner smoke test and
    as a scheduled fleet-wide job.
  • Exit code derivation mirrors user-account-audit‘s pattern from earlier in this
    series: 2 if anything is down, 1 if anything is merely degraded, 0 if everything passed clean on the
    first try.
exponential backoff retry timeline diagram

Two failures, two backoff sleeps, then a success reported as degraded — not healthy, not down
example output

Example Output

api_health_check.rb –config endpoints.json –retries 2 –backoff 0.2
api_health_check: 4 endpoint(s) checked
————————————————————————
[OK ] web-healthy status=200 latency=9.0ms attempts=1
[DOWN] bad-content error=expected status 200 and body matching /"ok":\s*true/, got status 200 attempts=3
[DOWN] svc-down error=expected status 200, got status 500 attempts=3
[OK ] api-flaky status=200 latency=1.4ms attempts=3
troubleshooting

Troubleshooting

  • Every check reports DOWN immediately with a connection error — confirm the
    box running the script actually has network access to the target (firewall, VPN, security group).
    The error string includes the raw exception class (e.g. Errno::ECONNREFUSED), which is
    usually enough to diagnose without extra logging.
  • Everything shows degraded even though the service looks fine — a common
    cause is a load balancer briefly returning 503s during a deploy; the retry/backoff is doing exactly
    what it’s supposed to. Tune --retries/--backoff to your environment’s normal
    blip duration if this is noisy.
  • HTTPS endpoints fail TLS verification against an internal CA
    Net::HTTP uses the system default trust store; extend
    EndpointChecker#perform_request to set http.ca_file/http.cert_store
    before http.request.
  • High --concurrency doesn’t seem to help — MRI’s GIL releases
    during I/O wait, so threads remain effective for this I/O-bound workload; if you need more than
    threads give you, batch with Process.fork instead.
extending it

Extending It

  • Add a webhook_url per endpoint and POST an alert on a healthy→down transition,
    using a small state file on disk between runs to detect the flip and avoid re-alerting every run.
  • Support POST/custom headers/auth by extending the JSON config schema and
    perform_request.
  • Add a --interval N daemon mode for a sidecar container instead of a cron-triggered
    run.
  • Feed results into this repo’s prometheus-exporter/ script for a
    continuously scraped uptime metric instead of a point-in-time report.