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:
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
Full script + README + mock test server on GitHub: ruby-devops-toolkit/api-health-check
Prerequisites
- Ruby ≥ 2.7 (tested on 3.0.2) — uses only
net/http,uri,json,optparse,timeout, andtimefrom the standard library, no gems. - Network access from the box running the script to whatever endpoints you’re checking.
Full Script (for reference)
#!/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
Step-by-Step Walkthrough
Four pieces do all the work:
EndpointChecker#callperforms one HTTP GET viaNet::HTTP, checks the
status code and an optional body regex, and retries with exponential backoff
(backoff_base * 2**attempt) up to--retriestimes before returning a
CheckResultstruct.run_checksbuilds the bounded worker pool: aQueueof endpoints feeds
N workerThreads, each loopingEndpointCheckercalls until the queue is
empty, pushing results onto a second, thread-safeQueue.- The CLI layer accepts either
--config endpoints.jsonfor a fleet or a single
--urlfor 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.
Example Output
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/--backoffto your environment’s normal
blip duration if this is noisy. - HTTPS endpoints fail TLS verification against an internal CA —
Net::HTTPuses the system default trust store; extend
EndpointChecker#perform_requestto sethttp.ca_file/http.cert_store
beforehttp.request. - High
--concurrencydoesn’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 withProcess.forkinstead.
Extending It
- Add a
webhook_urlper 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 Ndaemon mode for a sidecar container instead of a cron-triggered
run. - Feed
resultsinto this repo’sprometheus-exporter/script for a
continuously scraped uptime metric instead of a point-in-time report.