aws-sdk-http-async
Async HTTP handler plugin for the AWS SDK for Ruby, built on async-http.
Why? Because Net::HTTP blocks fibers, defeating Falcon's concurrency model. For high-throughput AWS APIs (DynamoDB, STS, IAM, Lambda), we need an async-compatible transport to restore concurrent I/O without threads.
Installation
This gem uses async-http when an Async reactor is present (Falcon provides this). When no reactor is running, it falls back to Net::HTTP by default so CLI tools, tests, and console sessions work without extra setup.
Add to your Gemfile:
gem 'aws-sdk-http-async'
That's it.
Recommended AWS Client Initialization
Create AWS clients once per process and reuse them (singleton or dependency injection). This keeps connection pooling effective and avoids rebuilding clients for every request.
Singleton Repository Pattern
module Database
class DynamodbRepository
include Singleton
INIT_MONITOR = Monitor.new
def client
return @client if defined?(@client) && @client
INIT_MONITOR.synchronize do
return @client if defined?(@client) && @client
@client = Aws::DynamoDB::Client.new
end
end
end
end
Initializer shortcut:
# config/initializers/aws_clients.rb
DDB = Database::DynamodbRepository.instance unless defined?(DDB)
Usage:
DDB.client.list_tables
Dependency Injection Pattern
Alternative dependency injection example (preferred for testing if you need to swap clients per context):
class WorldDominationService
def initialize(dynamodb:)
@dynamodb = dynamodb
end
def list_tables
@dynamodb.list_tables
end
end
service = WorldDominationService.new(dynamodb: Aws::DynamoDB::Client.new)
File Handle Limits (EMFILE)
If you see Errno::EMFILE or "Too many open files", raise your file descriptor
limit.
Recommended bin/dev snippet:
if command -v ulimit >/dev/null; then
current_limit=$(ulimit -n)
if [ "$current_limit" -lt 4096 ]; then
echo "Warning: ulimit -n is $current_limit. Falcon/Propshaft may hit EMFILE. Trying to raise to 65536..."
ulimit -n 65536 || echo "Warning: failed to raise ulimit. Set it manually (ulimit -n 65536) before bin/dev."
fi
fi
You also might want to add the ENV ULIMIT_NOFILE=65536 to your Dockerfile.
and/or to your bin/docker-entrypoint:
if command -v ulimit >/dev/null; then
ulimit -n "${ULIMIT_NOFILE:-65536}" || echo "Warning: unable to raise ulimit -n"
fi
Advanced Configuration (Force Async Outside Falcon)
To force async execution for all Rake tasks, add this line at the very top of
your Rakefile, before any task definitions or Rails app loading (usually after
require_relative '../config/boot'):
require 'aws-sdk-http-async/rake'
Configuration
Fallback Behavior (No Reactor)
Fallback mode is configurable:
# Default: use Net::HTTP when no reactor exists
Aws.config[:async_http_fallback] = :net_http
# Run the async-http path inside a transient reactor
Aws.config[:async_http_fallback] = :sync
# Strict mode: raise if no reactor is running
Aws.config[:async_http_fallback] = :raise
You can also ENV set AWS_SDK_HTTP_ASYNC_FALLBACK=net_http|sync|raise.
Event stream operations (e.g., Transcribe/Bedrock streaming) always require an
Async reactor and will raise NoReactorError when none is running.
Sync vs Async
If you want the async-http path in rake/CLI/tests, wrap the code you run in
Sync:
Sync do
client = Aws::DynamoDB::Client.new
client.list_tables
end
Sync { } is the recommended wrapper outside Falcon. It is more efficient than
Async { } and returns the block’s value directly.
Configuration options inherited from Net::HTTP plugin
Supported options (inherited from Net::HTTP plugin):
http_open_timeouthttp_read_timeouthttp_idle_timeout(ignored; async-http manages internally)ssl_verify_peerssl_ca_storessl_ca_bundlessl_ca_directoryssl_certssl_keyhttp_proxy
Ignored Net::HTTP options:
http_continue_timeouthttp_wire_trace
SSL and certificates
If you pass file paths for ssl_cert / ssl_key, this gem loads them with
File.read, which is blocking I/O. For production, prefer pre‑loading at
boot and passing OpenSSL objects instead:
Aws::DynamoDB::Client.new(
ssl_cert: OpenSSL::X509::Certificate.new(File.read('/path/to/cert.pem')),
ssl_key: OpenSSL::PKey.read(File.read('/path/to/key.pem'))
)
Tip: Reuse OpenSSL certificate/key objects across client instances to keep cache keys stable.
Proxy Support Notes
http_proxy accepts a full URL, e.g.:
Aws::DynamoDB::Client.new(http_proxy: 'http://user:[email protected]:8080')
Limitations:
- Does not read
HTTP_PROXY/HTTPS_PROXY/NO_PROXYenv vars. - No PAC or proxy auto‑discovery.
- HTTPS uses CONNECT; proxy auth must be embedded in the URL.
- Proxy auth uses HTTP Basic based on
user:pass@in the proxy URL. - HTTPS proxies reuse the same SSL verification options as the target (CA store/bundle/dir).
- All requests for the client go through the configured proxy.
Plugin options
async_http_connection_limit(default: 10)async_http_force_accept_encoding(default: true)async_http_body_warn_bytes(default: 5_242_880)async_http_max_buffer_bytes(default: 5MB; raise if buffering exceeds this; set tonilor0for unlimited)async_http_header_timeout(default: nil; optional timeout for response headers, applied even for streaming request bodies)async_http_total_timeout(default: nil; total request deadline in seconds)async_http_idle_timeout(default: 30; used as endpoint timeout whenhttp_open_timeoutis not set; changing this at runtime requires clearing the client cache)async_http_max_cached_clients(default: 100; LRU eviction per reactor when exceeded)async_http_streaming_uploads(default: :auto; values: :auto, :force, :off)async_http_fallback(default: :net_http; values: :net_http, :sync, :raise)async_http_client_cache(default: nil, injectable cache for lifecycle control)
Timeout semantics
http_open_timeout applies to the connection phase. http_read_timeout is
enforced per read chunk, not as a total request deadline. For streaming
uploads, the request body is not wrapped by http_read_timeout to avoid
premature upload timeouts. Use async_http_header_timeout if you want a timeout
for waiting on response headers even when the request body is streaming. A
slow‑loris response that sends a byte before each timeout can remain open
indefinitely; add your own total deadline if needed. Use
async_http_total_timeout to enforce an overall deadline (upload + headers +
body).
Opt-out of auto-patching
If you want to load the handler without global patching:
require 'aws-sdk-http-async/core'
# Explicit plugin usage:
Aws::DynamoDB::Client.add_plugin(Async::Aws::HttpPlugin)
# or client = Aws::DynamoDB::Client.new(plugins: [Async::Aws::HttpPlugin])
If you explicitly add the plugin, only the clients you configure are affected.
For the global patcher (default auto‑patch), registration is retroactive for already‑loaded AWS service clients. For test isolation, you can undo it:
Async::Aws::Patcher.unpatch(:all)
Note: unpatch(:all) only removes the plugin from clients patched by the
patcher. If you define custom subclasses of AWS service clients, they will
inherit the plugin from their parent class and are not tracked for removal.
Note: patching scans existing classes at load time; require this gem early in boot for best results.
Cache injection
cache = Async::Aws::ClientCache.new
client = Aws::DynamoDB::Client.new(async_http_client_cache: cache)
Use cache injection only when you want to control or share the async‑http connection pool yourself. It caches async‑http client instances (connection pools), not AWS responses. Most apps should ignore this and let the gem manage its own internal cache. Inject a cache only when you need one of these:
- Deterministic shutdown in tests or short‑lived scripts (so servers don’t hang waiting for pooled connections to drain).
- One shared pool across many AWS clients to reduce sockets and improve reuse.
- Custom lifecycle hooks (e.g., you want to clear the pool on reload).
If you inject a cache, call clear!/close! during shutdown to close pooled
connections. For cross‑reactor apps, call it inside each reactor to ensure
clients are closed on their owning reactor. Calling clear! outside any
reactor will force‑close clients, which can interrupt in‑flight requests.
The cache is capped by async_http_max_cached_clients using per‑reactor LRU
eviction. Set it to nil or 0 to disable eviction.
Cold‑start behavior: the cache is not gated. If multiple fibers hit a totally cold cache for the same endpoint at the same time, they may build duplicate clients; the extra client is closed immediately. This trades a tiny one‑time overhead for simpler, more reliable concurrency.
Memory note: async_http_max_buffer_bytes is per request. For high‑concurrency
or memory‑constrained environments, consider lowering it further to avoid
aggregate spikes.
Test Notes
WebMock works for unit tests, but connection pooling can keep sockets open. Use
WebMock.disable_net_connect!(allow_localhost: true) and isolate tests that
rely on real IO.
Docker integration tests
RSpec Integration tests run against DynamoDB Local + MinIO, and proxy tests use tinyproxy + toxiproxy.
RSpec/rails test setup
No special setup is required. When no reactor is present, the gem uses Net::HTTP
by default. Use Sync { } if you want tests to exercise the async-http path.
The gem also supplies a simple async-rake script for testing async behavior in Rake
Current Limitations
- Streaming uploads (
:auto) only stream rewindable bodies with known size. Non‑rewindable or unknown‑size bodies are buffered for retry safety. Buffering is capped byasync_http_max_buffer_bytes(default: 5MB). :forcestreaming raises when retries are enabled for non‑rewindable bodies.- Event streams are delegated to the SDK's native HTTP/2 handler (Async clients only).
- Multipart uploads stream when the body is rewindable and size is known (File, StringIO, or explicit Content‑Length).
- Duplicate headers are merged with commas (Net::HTTP parity); Set‑Cookie and Set‑Cookie2 values are joined with "\n" to preserve cookie boundaries.
Next Steps
- Native event stream support on async-http HTTP/2 (non‑trivial; SDK’s native handler already works with HTTP/2)
- Smarter proxy support (env vars, PAC)
See md-docs/aws-sdk-http-async-rfc.md for current design notes.
Development
bundle install
# Do your development stuff.
bundle exec rake formatter
bundle exec rspec
bin/ci
Release (maintainers)
Quick reference:
VERSION=0.1.0
# Build + push (after version bump + changelog)
gem build aws-sdk-http-async.gemspec
gem push "aws-sdk-http-async-${VERSION}.gem"
# Tag + push
git tag -a "v${VERSION}" -m "Release v${VERSION}"
git push origin main --tags
GitHub release (optional, via gh):
gh release create "v${VERSION}" \
--title "v${VERSION}" \
--generate-notes \
--assets "aws-sdk-http-async-${VERSION}.gem"
Requirements
- Ruby 3.4+
- aws-sdk-core >= 3.241.0
- async-http >= 0.94.0
No backwards compatibility is maintained for older Ruby/AWS SDK versions.
Author
Author: Thomas Witt Github: https://github.com/thomaswitt/aws-sdk-http-async