Tenax
Resilient execution for unreliable operations. Tenax wraps any block — an HTTP call, a database query, a queue publish — in a configurable layer of retries, exponential backoff with jitter, and pluggable observability.
result = Tenax.run(:stripe) { Stripe::Charge.create(params) }
if result.success?
fulfill_order(result.value)
else
Rails.logger.error("payment failed", error: result.error, attempts: result.attempts)
endDesigned for Ruby services that need to survive transient failures without sprinkling bespoke retry logic across every call site.
Why Tenax?
If you've worked on any Ruby service that talks to a network, you've written this block:
attempts = 0
begin
attempts += 1
call_external_api
rescue Net::ReadTimeout => e
retry if attempts < 3
raise
endMultiplied across every API client, queue publisher, and database call, this scattered retry logic becomes a maintenance nightmare. Most implementations get the math wrong (no jitter, no cap, no observability) and fail under real load.
Tenax centralizes resilience policy into named profiles, applies AWS-recommended decorrelated jitter by default, and emits structured events for every attempt — so you can build dashboards and alerts around your retry behavior.
Installation
Add to your Gemfile:
gem "tenax"Then bundle install. Or install directly:
gem install tenaxRequires Ruby 3.2 or later.
Quick Start
Configure your profiles once at boot, typically in config/initializers/tenax.rb:
require "net/http"
Tenax.configure do |c|
c.profile :stripe do |p|
p.max_attempts 5
p.backoff :decorrelated_jitter, base: 0.2, cap: 5.0
p.retry_on Net::ReadTimeout, Net::OpenTimeout
end
c.profile :s3 do |p|
p.max_attempts 3
p.backoff :exponential, base: 0.5, cap: 30.0
p.retry_on Aws::Errors::ServiceError
end
endThen wrap any unreliable operation in Tenax.run:
result = Tenax.run(:stripe) { Stripe::Charge.create(amount: 1000, ...) }
result.success? # => true / false
result.value # block's return value (on success)
result.error # underlying exception (on failure)
result.attempts # number of attempts madeIf you prefer exception semantics, call value!:
charge = Tenax.run(:stripe) { Stripe::Charge.create(...) }.value!
# raises the underlying error if all attempts failedFeatures
- Multiple backoff strategies — constant, linear, exponential, decorrelated jitter (AWS-style)
- Per-profile configuration — different SLAs for different downstream services
-
Result objects — failure as structured data, not exceptions; opt into raising via
.value! - Pluggable instrumentation — emit retry events to StatsD, OpenTelemetry, ActiveSupport::Notifications, or your own sink
-
Pattern-matching support — Ruby 3
case/insyntax works on results - Zero runtime dependencies — pure Ruby, no transitive baggage
- Thread-safe — frozen configuration, immutable strategies, safe across concurrent retries
Backoff Strategies
| Strategy | Best for |
|---|---|
:constant |
Fast local retries; testing |
:linear |
Slow growth between attempts |
:exponential |
Predictable doubling; controlled environments |
:decorrelated_jitter |
Production HTTP calls against shared services (recommended default) |
p.backoff :constant, interval: 0.5
p.backoff :linear, base: 0.5, cap: 5.0
p.backoff :exponential, base: 0.5, cap: 30.0
p.backoff :decorrelated_jitter, base: 0.5, cap: 30.0:decorrelated_jitter implements the algorithm described in AWS's Exponential Backoff and Jitter and is the recommended default for any production retry against a shared remote service.
You can also pass a fully-constructed strategy or any object responding to #delay_for:
p.backoff Tenax::Retry::Backoff::Exponential.new(base: 0.1, cap: 2.0)
# Custom strategy
class MyBackoff
def delay_for(attempt, **) = attempt * 0.1
end
p.backoff MyBackoff.newWorking with Results
A Tenax::Result carries every fact about an execution:
result = Tenax.run(:stripe) { call_api }
result.success? # true / false
result.failure? # opposite of success?
result.value # block return value, or nil on failure
result.value! # block return value, or raises the error
result.error # underlying exception, or nil on success
result.attempts # number of attempts (>= 1)
result.duration_ms # total wall-clock time, in milliseconds
result.profile_name # the profile that ran thisThree idiomatic ways to handle results:
Branching:
result = Tenax.run(:stripe) { call_api }
if result.success?
process(result.value)
else
log_failure(result.error)
endCallbacks (chainable):
Tenax.run(:stripe) { call_api }
.on_success { |v| process(v) }
.on_failure { |e| log_failure(e) }Pattern matching (Ruby 3+):
case Tenax.run(:stripe) { call_api }
in { success: true, value: charge }
ship_order(charge)
in { success: false, error: Net::ReadTimeout }
queue_for_later
in { success: false, error: }
raise
endInstrumentation
Tenax emits structured events at every interesting moment. Wire any sink that responds to #emit(event_name, payload):
class StatsDSink
def initialize(client) = @client = client
def emit(event, payload)
case event
when :run_succeeded
@client.timing("tenax.run.duration", payload[:duration_ms],
tags: ["profile:#{payload[:profile_name]}"])
when :run_failed
@client.increment("tenax.run.failed",
tags: ["profile:#{payload[:profile_name]}",
"reason:#{payload[:reason]}"])
when :retry_scheduled
@client.histogram("tenax.retry.delay", payload[:delay_seconds])
end
end
end
Tenax.configure do |c|
c.instrumentation = StatsDSink.new($statsd)
# ... profiles
endEvent taxonomy:
| Event | Payload keys |
|---|---|
:run_started |
profile_name |
:attempt_started |
profile_name, attempt
|
:attempt_succeeded |
profile_name, attempt, duration_ms
|
:attempt_failed |
profile_name, attempt, error
|
:retry_scheduled |
profile_name, next_attempt, delay_seconds
|
:run_succeeded |
profile_name, attempts, duration_ms
|
:run_failed |
profile_name, attempts, duration_ms, error, reason
|
:instrumentation_error |
event, error, profile_name
|
Sink errors never crash the runner — Tenax wraps them and emits an :instrumentation_error event as a best-effort signal.
Configuration Reference
Tenax.configure do |c|
c.instrumentation = MySink.new # default: Tenax::Instrumentation::Null
c.profile :name do |p|
p.max_attempts 3 # default: 3
p.backoff :exponential, base: 0.5, cap: 30.0
p.retry_on StandardError # default: [StandardError]
end
endConfiguration is frozen on first Tenax.run call. To reconfigure (e.g., in tests), use Tenax.reset!.
Errors
Tenax raises a single hierarchy rooted at Tenax::Error:
-
Tenax::ConfigurationError— bad configurationTenax::UnknownProfileErrorTenax::InvalidOptionError
-
Tenax::ExecutionError— runtime failures (rare; usually returned as Result.failure)Tenax::RetryLimitExceededTenax::CircuitOpenError
-
Tenax::InstrumentationError— sink itself raised
Operational failures (block raises, retries exhaust) are returned as Result.failure rather than raised. Only programmer errors and explicit value! calls raise exceptions.
Comparison with Alternatives
| Library | Retry | Backoff | Result objects | Instrumentation |
|---|---|---|---|---|
retriable |
yes | exponential | no (raises) | hooks |
retries |
yes | exponential | no (raises) | no |
faraday-retry |
HTTP only | exponential | no (raises) | Faraday-specific |
| Tenax | yes | constant/linear/exp/decorrelated | yes | pluggable |
Tenax is best when:
- You want resilience across multiple kinds of operations (not just HTTP)
- You need observability into retry behavior
- You prefer structured failure handling over exception-based control flow
Versioning Policy
Tenax follows Semantic Versioning 2.0.0. The public API surface — methods, classes, and behaviors covered by semver — is documented above.
While Tenax is on 0.x, the API is considered stable in practice but
subject to refinement. Breaking changes will be released as minor version
bumps (0.1 → 0.2) with prominent changelog entries. Bug fixes go into
patches (0.1.0 → 0.1.1).
Internal classes (those marked @api private in YARD docs) and undocumented
behavior may change in any release.
Development
After checking out the repo:
bin/setup # install dependencies
bundle exec rspec # run the test suite
bin/console # interactive Ruby session with Tenax loadedTo install this gem locally: bundle exec rake install. To release a new version, update lib/tenax/version.rb and run bundle exec rake release.
Contributing
Bug reports and pull requests welcome on GitHub at https://github.com/sandcoder/tenax. This project is intended to be a safe, welcoming space for collaboration; contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.