Introduction
Timeout::Error during external HTTP calls is a common production issue that can cascade into thread pool exhaustion and application-wide outages. When a downstream API becomes slow or unresponsive, every blocked thread holding an open connection can starve your web server of available workers.
Symptoms
Net::ReadTimeoutorFaraday::TimeoutErrorin logs- Puma/Passenger worker threads maxed out waiting for responses
- Cascading failures across multiple services
- Response times spike from milliseconds to 30+ seconds
- Connection pool exhausted:
could not obtain a database connection
Example error:
``
Net::ReadTimeout: Net::ReadTimeout with #<TCPSocket:(closed)>
from /path/to/net/http.rb:1635:in rbuf_fill'
from /path/to/net/http.rb:1612:in get'
from /path/to/faraday-net_http-2.1.0/lib/faraday/adapter/net_http.rb:112:in request_with_wrapped_block'
```
Common Causes
- Downstream API experiencing degraded performance or outage
- No timeout configured on HTTP client (defaults to infinite)
- Timeout set too high for production SLA requirements
- DNS resolution delays adding to total request time
- TLS handshake slow on cold connections
Step-by-Step Fix
- 1.Configure timeouts on Faraday client:
- 2.```ruby
- 3.Faraday.new(url: 'https://api.example.com') do |f|
- 4.f.request :retry, max: 2, interval: 0.05,
- 5.interval_randomness: 0.5, backoff_factor: 2,
- 6.exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
- 7.f.options.timeout = 5 # Open + read timeout in seconds
- 8.f.options.open_timeout = 3 # Connection establishment timeout
- 9.f.adapter :net_http
- 10.end
- 11.
` - 12.Configure timeouts on Net::HTTP directly:
- 13.```ruby
- 14.uri = URI('https://api.example.com/data')
- 15.http = Net::HTTP.new(uri.host, uri.port)
- 16.http.use_ssl = true
- 17.http.open_timeout = 3
- 18.http.read_timeout = 5
- 19.http.write_timeout = 5
response = http.get(uri.path) ```
- 1.Implement a circuit breaker pattern:
- 2.```ruby
- 3.require 'circuitbox'
class ExternalApi def self.call Circuitbox.circuit_breaker(:external_api, threshold: 5, # Failures before opening sleep_window: 30, # Seconds before trying again volume_threshold: 10 # Minimum requests before tripping ).run do http_client.get('/data') end end end ```
- 1.Add connection pooling to avoid TLS handshake overhead:
- 2.```ruby
- 3.require 'net-http-persistent'
class ApiClient def initialize @http = Net::HTTP::Persistent.new(name: 'api_client') @http.idle_timeout = 30 @http.max_requests_per_connection = 100 end
def get(path) uri = URI("https://api.example.com#{path}") @http.request(uri) end end ```
Prevention
- Always set explicit timeouts on HTTP clients (never rely on defaults)
- Use circuit breakers for all external service calls
- Monitor downstream API response time percentiles (p50, p95, p99)
- Implement request budgeting: total request time should be less than your SLA
- Add connection pooling for high-frequency API calls
- Use async HTTP calls (via
async-http) for non-blocking operations