Project

tenax

0.0
The project is in a healthy, maintained state
Tenax wraps unreliable operations (HTTP calls, database queries, queue publishes) in a configurable resilience layer: retries with pluggable backoff strategies, error classification, optional circuit breaking, and built-in instrumentation. Designed for Ruby services that need to survive transient failures without bespoke retry logic in every call site.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

Tenax

Gem Version CI License: MIT

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)
end

Designed 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
end

Multiplied 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 tenax

Requires 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
end

Then 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 made

If you prefer exception semantics, call value!:

charge = Tenax.run(:stripe) { Stripe::Charge.create(...) }.value!
# raises the underlying error if all attempts failed

Features

  • 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/in syntax 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.new

Working 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 this

Three idiomatic ways to handle results:

Branching:

result = Tenax.run(:stripe) { call_api }
if result.success?
  process(result.value)
else
  log_failure(result.error)
end

Callbacks (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
end

Instrumentation

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
end

Event 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
end

Configuration 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 configuration
    • Tenax::UnknownProfileError
    • Tenax::InvalidOptionError
  • Tenax::ExecutionError — runtime failures (rare; usually returned as Result.failure)
    • Tenax::RetryLimitExceeded
    • Tenax::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 loaded

To 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.