Skip to content

Latest commit

 

History

History
1164 lines (877 loc) · 32.6 KB

File metadata and controls

1164 lines (877 loc) · 32.6 KB

Restate Ruby SDK — User Guide

Build resilient applications with durable execution in Ruby. The Restate Ruby SDK lets you write handlers that survive crashes, retries, and infrastructure failures — with the simplicity of ordinary Ruby code.


Quick Start

1. Define a Service

# greeter.rb
require 'restate'

class Greeter < Restate::Service
  handler def greet(ctx, name)
    ctx.run_sync('build-greeting') { "Hello, #{name}!" }
  end
end

endpoint = Restate.endpoint(Greeter)

2. Create a Rackup File

# config.ru
require_relative 'greeter'
run endpoint.app

3. Run with Falcon

bundle exec falcon serve --bind http://localhost:9080

4. Register with Restate and Invoke

restate deployments register http://localhost:9080
curl localhost:8080/Greeter/greet -H 'content-type: application/json' -d '"World"'
# → "Hello, World!"

Service Types

The SDK provides three service types, each with different durability and concurrency guarantees.

Service (Stateless)

Stateless handlers that can be invoked by name. Each invocation is independent.

class MyService < Restate::Service
  handler def my_handler(ctx, input)
    # ctx is the Restate context; input is the deserialized JSON body
    # return value is serialized as the JSON response
    { 'result' => input }
  end
end

Invoke: POST /MyService/my_handler

VirtualObject (Keyed, Stateful)

Each virtual object instance is identified by a key and has durable K/V state scoped to that key.

class Counter < Restate::VirtualObject
  # Exclusive handler — one invocation at a time per key.
  handler def add(ctx, amount)
    current = ctx.get('count') || 0
    ctx.set('count', current + amount)
    current + amount
  end

  # Shared handler — concurrent access allowed (read-only).
  shared def get(ctx)
    ctx.get('count') || 0
  end
end

Invoke: POST /Counter/my-counter/add (key is my-counter)

Workflow (Durable, Run-Once)

A workflow's main handler runs exactly once per key. Shared handlers let external callers query state and send signals.

class UserSignup < Restate::Workflow
  main def run(ctx, email)
    user_id = ctx.run_sync('create-account') { create_user(email) }
    ctx.set('status', 'waiting_for_approval')

    # Block until approve() is called
    approval = ctx.promise('approval')
    ctx.set('status', 'active')
    { 'user_id' => user_id, 'approval' => approval }
  end

  handler def approve(ctx, reason)
    ctx.resolve_promise('approval', reason)
  end

  handler def status(ctx)
    ctx.get('status') || 'unknown'
  end
end

Invoke:

curl localhost:8080/UserSignup/user42/run -d '"user@example.com"'
curl localhost:8080/UserSignup/user42/approve -d '"approved by admin"'
curl localhost:8080/UserSignup/user42/status -d 'null'

Context API Reference

The context object provides access to all Restate operations. It is passed as the first argument to every handler:

handler def greet(ctx, name)      # ctx is always the first parameter
  ctx.run_sync('step') { ... }
end

All operations that interact with Restate return durable results — if the handler crashes and retries, completed operations are replayed from the journal without re-executing.

Durable Execution (ctx.run)

Execute a side effect exactly once. The result is durably recorded — on retry, the block is skipped and the stored result is returned.

run returns a DurableFuture; call .await to get the result. Use run_sync to get the value directly:

# Returns a future — useful for fan-out (see below)
future = ctx.run('step-name') { do_something() }
result = future.await

# Returns the value directly — convenient for sequential steps
result = ctx.run_sync('step-name') { do_something() }

With retry policy:

policy = Restate::RunRetryPolicy.new(
  initial_interval: 100,     # ms between retries
  max_attempts: 5,           # max retry count
  interval_factor: 2.0,      # exponential backoff multiplier
  max_interval: 10_000,      # ms cap on retry interval
  max_duration: 60_000       # ms total duration cap
)

result = ctx.run_sync('flaky-call', retry_policy: policy) { call_external_api() }

Terminal errors (non-retryable):

ctx.run_sync('validate') do
  raise Restate::TerminalError.new('invalid input', status_code: 400)
end

Background thread (background: true):

Background thread pool (background: true):

With Async and Ruby 3.1+, the Fiber Scheduler automatically intercepts most blocking I/O (Net::HTTP, TCPSocket, file I/O, etc.) and yields the fiber — so run already handles I/O-bound work without blocking the event loop.

Pass background: true only for CPU-heavy native extensions that release the GVL (e.g., image processing, crypto). The block runs in a shared thread pool (default 8 workers, configurable via RESTATE_BACKGROUND_POOL_SIZE):

result = ctx.run_sync('resize-image', background: true) { process_image(data) }

State Operations

Available in VirtualObject and Workflow handlers.

value = ctx.get('key')              # Read state (nil if absent)
ctx.set('key', value)               # Write state
ctx.clear('key')                    # Delete one key
ctx.clear_all                       # Delete all keys
keys = ctx.state_keys               # List all key names

Async variants — return a DurableFuture instead of blocking, useful for fan-out:

future_a = ctx.get_async('key_a')
future_b = ctx.get_async('key_b')
keys_future = ctx.state_keys_async

# Await results (fetches happen concurrently)
val_a = future_a.await
val_b = future_b.await
keys = keys_future.await

Values are JSON-serialized by default. Pass serde: for custom serialization:

ctx.get('key', serde: Restate::BytesSerde)
ctx.get_async('key', serde: Restate::BytesSerde)
ctx.set('key', raw_bytes, serde: Restate::BytesSerde)

Sleep

ctx.sleep(5.0).await                # Sleep for 5 seconds (durable timer)

The timer survives crashes — if the handler restarts, it resumes waiting for the remaining time.

Service Communication

Synchronous Calls

Call another handler and await the result. The call is durable — if the caller crashes, Restate delivers the result when the caller retries.

# Typed call (resolves serdes from target handler registration)
result = ctx.service_call(MyService, :my_handler, arg).await
result = ctx.object_call(Counter, :add, 'my-key', 5).await
result = ctx.workflow_call(UserSignup, :run, 'user42', email).await

# String-based call (uses JsonSerde)
result = ctx.service_call('MyService', 'my_handler', arg).await

DurableCallFuture methods:

future = ctx.service_call(MyService, :handler, arg)
result = future.await                # Block until result
id = future.invocation_id            # Get invocation ID
future.cancel                        # Cancel the remote invocation

Fire-and-Forget Sends

Dispatch a call without waiting for the result.

handle = ctx.service_send(MyService, :handler, arg)
handle = ctx.object_send(Counter, :add, 'my-key', 5)

# Delayed send (executes after 60 seconds)
handle = ctx.service_send(MyService, :handler, arg, delay: 60.0)

SendHandle methods:

id = handle.invocation_id            # Get invocation ID
handle.cancel                        # Cancel the invocation

Call Options

All call/send methods accept these keyword arguments:

ctx.service_call(
  MyService, :handler, arg,
  idempotency_key: 'unique-key',     # Deduplication key
  headers: { 'x-custom' => 'val' },  # Custom headers
  input_serde: MyCustomSerde,        # Override input serializer
  output_serde: MyCustomSerde        # Override output serializer
)

Fan-Out / Fan-In

Launch multiple calls concurrently, then collect all results.

# Fan-out: launch calls
futures = tasks.map { |t| ctx.service_call(Worker, :process, t) }

# Fan-in: await all
results = futures.map(&:await)

Wait Any (Racing Futures)

Wait for the first future to complete out of several.

future_a = ctx.service_call(ServiceA, :slow, arg)
future_b = ctx.service_call(ServiceB, :fast, arg)

completed, remaining = ctx.wait_any(future_a, future_b)
winner = completed.first.await

Awakeables (External Callbacks)

Pause a handler until an external system calls back via Restate's API.

# In your handler: create an awakeable
awakeable_id, future = ctx.awakeable

# Send the ID to an external system
ctx.run_sync('notify') { send_to_external_system(awakeable_id) }

# Block until the external system resolves it
result = future.await

The external system resolves the awakeable via Restate's HTTP API:

curl -X POST http://restate:8080/restate/awakeables/$AWAKEABLE_ID/resolve \
  -H 'content-type: application/json' -d '"callback data"'

From another handler:

ctx.resolve_awakeable(awakeable_id, payload)
ctx.reject_awakeable(awakeable_id, 'reason', code: 500)

Promises (Workflow Only)

Durable promises allow communication between a workflow's main handler and its signal handlers.

# In main handler: block until promise is resolved
value = ctx.promise('approval')

# In signal handler: resolve the promise
ctx.resolve_promise('approval', value)

# Non-blocking peek (returns nil if not yet resolved)
value = ctx.peek_promise('approval')

# Reject a promise
ctx.reject_promise('approval', 'denied', code: 400)

Request Metadata

request = ctx.request
request.id         # Invocation ID (String)
request.headers    # Request headers (Hash)
request.body       # Raw input bytes (String)

key = ctx.key      # Object/workflow key (String)

Attempt Finished Event

The attempt_finished_event on ctx.request signals when the current attempt is about to finish (e.g., the connection is closing). This is useful for long-running handlers that need to perform cleanup or flush work before the attempt ends.

event = ctx.request.attempt_finished_event
event.set?    # Non-blocking check: has the attempt finished? (true/false)
event.wait    # Blocks the current fiber until the attempt finishes

Accessing the Context

The context is passed as the first argument to every handler. For nested helper methods that don't have ctx in scope, you can use the fiber-local accessors:

class OrderService < Restate::Service
  handler def process(ctx, order)
    validate(order)
    fulfill(order)
  end

  private

  def validate(order)
    # Fiber-local accessor — works from any method within the handler's fiber
    ctx = Restate.current_context
    ctx.run_sync('validate') { check_inventory(order) }
  end

  def fulfill(order)
    ctx = Restate.current_context
    ctx.run_sync('fulfill') { ship_order(order) }
  end
end

The following fiber-local accessors are available, each returning the appropriately-typed context:

Accessor Returns Use in
Restate.current_context Context Any handler
Restate.current_object_context ObjectContext VirtualObject exclusive handlers (full state)
Restate.current_shared_context ObjectSharedContext VirtualObject shared handlers (read-only state)
Restate.current_workflow_context WorkflowContext Workflow main handler (full state + promises)
Restate.current_shared_workflow_context WorkflowSharedContext Workflow shared handlers (read-only state + promises)

Shared contexts (ObjectSharedContext, WorkflowSharedContext) expose get and state_keys but NOT set, clear, or clear_all — shared handlers have read-only access to state.

Runtime validation: Calling the wrong accessor for your handler type (e.g., Restate.current_object_context from a Service handler) raises an error. Calling any accessor outside a handler also raises.

Implementation: These use fiber-local storage (Thread.current[:key], which is fiber-scoped in Ruby). The context is set automatically when a handler begins and cleared when it returns.

Cancel Invocation

ctx.cancel_invocation(invocation_id)

Handler Registration

Class-Based DSL (Recommended)

class MyService < Restate::Service
  # Inline decorator style
  handler def greet(ctx, name)
    "Hello, #{name}!"
  end

  # With options
  handler :process, input: String, output: Hash
  def process(ctx, input)
    { 'result' => input.upcase }
  end
end

Handler Options

handler :my_handler,
  input: String,                       # Type or serde for input (generates JSON schema)
  output: Hash,                        # Type or serde for output (generates JSON schema)
  accept: 'application/json',         # Input content type
  content_type: 'application/json'    # Output content type

The input: and output: options accept:

  1. A type class (e.g., String, Integer, Dry::Struct subclass) — auto-resolves serde + JSON schema
  2. A serde object (responds to serialize/deserialize) — used directly
  3. Omitted — defaults to JsonSerde with no schema

Handlers also accept configuration options that control Restate server behavior:

handler :process,
  input: String, output: String,
  description: 'Process a task',              # Human-readable description
  metadata: { 'team' => 'backend' },          # Arbitrary key-value metadata
  inactivity_timeout: 300,                    # Seconds before Restate considers handler inactive
  abort_timeout: 60,                          # Seconds before Restate aborts a stuck handler
  journal_retention: 86_400,                  # Seconds to retain the journal (1 day)
  idempotency_retention: 3600,                # Seconds to retain idempotency keys (1 hour)
  ingress_private: true,                      # Hide from public ingress
  enable_lazy_state: true,                    # Fetch state on demand (VirtualObject/Workflow)
  invocation_retry_policy: {                  # Custom retry policy
    initial_interval: 0.1,                    #   First retry after 100ms
    max_interval: 30,                         #   Cap retry interval at 30s
    max_attempts: 10,                         #   Max 10 attempts
    exponentiation_factor: 2.0,               #   Double interval each retry
    on_max_attempts: :kill                     #   Kill invocation on exhaustion (:pause or :kill)
  }

For workflow main handlers, there is an additional option:

main :run,
  workflow_completion_retention: 86_400       # Seconds to retain workflow completion (1 day)

Custom Service Name

By default, the service name is the unqualified class name. Override it:

class MyLongClassName < Restate::Service
  service_name 'ShortName'
  # Registered as "ShortName" in Restate
end

Handler Arity

Every handler receives ctx as its first parameter. An optional second parameter receives the deserialized input:

handler def no_input(ctx)              # Called with null/empty body
  'ok'
end

handler def with_input(ctx, data)      # data = deserialized JSON body
  data['name']
end

Service Configuration

Use class-level DSL methods to set defaults for the entire service. These are reported to the Restate server via the discovery protocol and control server-side behavior.

class OrderProcessor < Restate::VirtualObject
  # Documentation
  description 'Processes customer orders'
  metadata 'team' => 'commerce', 'tier' => 'critical'

  # Timeouts
  inactivity_timeout 300          # Seconds before Restate considers a handler inactive
  abort_timeout 60                # Seconds before Restate aborts a stuck handler

  # Retention
  journal_retention 86_400        # Seconds to retain the journal (1 day)
  idempotency_retention 3600      # Seconds to retain idempotency keys (1 hour)

  # Access control
  ingress_private                 # Hide from public ingress

  # State loading
  enable_lazy_state               # Fetch state on demand instead of pre-loading

  # Retry policy for handler invocations
  invocation_retry_policy initial_interval: 0.1,
                          max_interval: 30,
                          max_attempts: 10,
                          exponentiation_factor: 2.0,
                          on_max_attempts: :kill

  handler def process(ctx, order)
    # ...
  end
end

All time values are in seconds. All options are optional — when omitted, the Restate server uses its built-in defaults.

Handler-level options override service-level defaults for individual handlers.

Option Service Handler Description
description yes yes Human-readable documentation
metadata yes yes Arbitrary key-value pairs
inactivity_timeout yes yes Seconds before handler is considered inactive
abort_timeout yes yes Seconds before a stuck handler is aborted
journal_retention yes yes Seconds to retain the invocation journal
idempotency_retention yes yes Seconds to retain idempotency keys
ingress_private yes yes Hide from public ingress
enable_lazy_state yes yes Fetch state on demand (VirtualObject/Workflow)
invocation_retry_policy yes yes Custom retry policy for handler invocations
workflow_completion_retention main only Seconds to retain workflow completion

Endpoint Configuration

The endpoint binds services and creates the Rack application.

# Bind multiple services
endpoint = Restate.endpoint(Greeter, Counter, UserSignup)

# Or bind incrementally
endpoint = Restate.endpoint
endpoint.bind(Greeter)
endpoint.bind(Counter, UserSignup)

# Force protocol mode (auto-detected by default)
endpoint.streaming_protocol           # Force bidirectional streaming
endpoint.request_response_protocol    # Force request/response

# Add identity verification keys
endpoint.identity_key('publickeyv1_...')

# Get the Rack app
run endpoint.app  # In config.ru

Typed Handlers

The input: and output: options on handler declarations let you use typed structs for handler I/O. The SDK automatically deserializes input JSON into struct instances and generates JSON Schema for Restate's discovery protocol.

Two struct libraries are supported out of the box — pick whichever fits your project:

Using T::Struct (Sorbet)

If you already use Sorbet, T::Struct gives you full type safety and IDE support with no extra dependencies.

require 'restate'

class GreetingRequest < T::Struct
  const :name, String
  const :greeting, T.nilable(String)
end

class Greeter < Restate::Service
  handler :greet, input: GreetingRequest, output: String
  def greet(ctx, request)
    # request is a GreetingRequest instance, not a raw Hash
    greeting = request.greeting || "Hello"
    "#{greeting}, #{request.name}!"
  end
end

The SDK introspects T::Struct props to generate JSON Schema. Serialization uses T::Struct#serialize and .from_hash.

Supported Sorbet type mappings:

Sorbet type JSON Schema
String {type: 'string'}
Integer {type: 'integer'}
Float {type: 'number'}
T::Boolean {type: 'boolean'}
T.nilable(String) {anyOf: [{type: 'string'}, {type: 'null'}]}
T::Array[String] {type: 'array', items: {type: 'string'}}
T::Hash[String, Integer] {type: 'object'}
Nested T::Struct Recursive object schema

Using Dry::Struct

dry-struct is a popular typed struct library that works without Sorbet. Add it as an optional dependency:

gem 'dry-struct'
require 'restate'
require 'dry-struct'

module Types
  include Dry.Types()
end

class GreetingRequest < Dry::Struct
  attribute :name, Types::String
  attribute? :greeting, Types::String    # optional attribute
end

class Greeter < Restate::Service
  handler :greet, input: GreetingRequest, output: String
  def greet(ctx, request)
    # request is a GreetingRequest instance, not a raw Hash
    greeting = request.greeting || "Hello"
    "#{greeting}, #{request.name}!"
  end
end

Supported dry-types mappings:

dry-types JSON Schema
Types::String {type: 'string'}
Types::Integer {type: 'integer'}
Types::Float {type: 'number'}
Types::Bool {type: 'boolean'}
Types::Integer.optional {anyOf: [{type: 'integer'}, {type: 'null'}]}
Types::Array.of(Types::String) {type: 'array', items: {type: 'string'}}
Nested Dry::Struct Recursive object schema

How It Works

Both struct types are auto-detected at runtime — no configuration needed. When a handler declares input: MyRequest:

  • Input JSON is deserialized into a struct instance (not a raw Hash)
  • JSON Schema is generated from the struct definition and published via Restate discovery
  • Output is serialized based on the output: type

Primitive Types

You can also use primitive Ruby types for simple handlers:

handler :greet, input: String, output: String
handler :compute, input: Integer, output: Integer

These generate the corresponding JSON Schema ({type: 'string'}, {type: 'integer'}, etc.) and use standard JSON serialization.

Serde Resolution Order

When input: or output: is provided, the SDK resolves a serde in this order:

  1. Serde object — if it responds to serialize and deserialize, use it directly
  2. T::Struct subclass — use TStructSerde (Sorbet native)
  3. Dry::Struct subclass — use DryStructSerde
  4. Primitive type (String, Integer, etc.) — use JsonSerde with type schema
  5. Class with .json_schema — use JsonSerde with that schema
  6. FallbackJsonSerde with no schema

Serialization

Built-in Serdes

Serde Serialize Deserialize Use Case
JsonSerde (default) JSON.generate JSON.parse Structured data
BytesSerde Pass-through Pass-through Raw bytes

Custom Serde

Implement a module with serialize and deserialize:

module MarshalSerde
  def self.serialize(obj)
    Marshal.dump(obj).b
  end

  def self.deserialize(buf)
    Marshal.load(buf)  # rubocop:disable Security/MarshalLoad
  end
end

# Use in handler registration
handler :process, input: MarshalSerde, output: MarshalSerde

Error Handling

TerminalError

Raise TerminalError to fail a handler permanently (no retries).

raise Restate::TerminalError.new('not found', status_code: 404)

Terminal errors propagate through service calls:

begin
  ctx.service_call(OtherService, :handler, arg).await
rescue Restate::TerminalError => e
  e.message       # Error message
  e.status_code   # HTTP status code
end

Transient Errors

Any StandardError (other than TerminalError) triggers a retry of the entire invocation. Restate automatically retries with exponential backoff.

Important: Avoid Bare Rescue

Do not use bare rescue => e in handlers — it catches internal SDK control flow exceptions (SuspendedError, InternalError) and breaks the durability protocol.

# BAD — catches SuspendedError
begin
  result = ctx.service_call(Other, :handler, arg).await
rescue => e
  handle_error(e)
end

# GOOD — catch only what you mean
begin
  result = ctx.service_call(Other, :handler, arg).await
rescue Restate::TerminalError => e
  handle_error(e)
end

IDE Code Completion

Ruby LSP (Recommended)

The SDK works out of the box with Ruby LSP in VSCode. Install the Ruby LSP extension and you'll get code completion, hover docs, and go-to-definition for all Restate types — no extra setup needed.

Add YARD @param tags to your handlers for full context completion:

class Greeter < Restate::Service
  # @param ctx [Restate::Context]
  handler def greet(ctx, name)
    ctx.run_sync('step') { "Hello, #{name}!" }
  end
end

Context types by service type:

Service type Handler kind Context type
Service handler Restate::Context
VirtualObject handler (exclusive) Restate::ObjectContext
VirtualObject shared Restate::ObjectSharedContext
Workflow main Restate::WorkflowContext
Workflow handler (shared) Restate::WorkflowSharedContext

Sorbet + Tapioca (Optional)

For full static type checking, the SDK ships RBI files inside the gem and a Tapioca DSL compiler that generates typed handler signatures.

1. Add Sorbet and Tapioca to your Gemfile:

group :development do
  gem 'sorbet', require: false
  gem 'tapioca', require: false
end

2. Generate type information:

bundle install
bundle exec tapioca gems    # Generate RBI for all gems (one-time)
bundle exec tapioca dsl     # Generate typed handler signatures

This creates RBI files under sorbet/rbi/. For example, given:

class Counter < Restate::VirtualObject
  handler def add(ctx, addend)
    old = ctx.get('count') || 0
    ctx.set('count', old + addend)
  end

  shared def get(ctx)
    ctx.get('count') || 0
  end
end

Tapioca generates:

# sorbet/rbi/dsl/counter.rbi (auto-generated, do not edit)
class Counter
  sig { params(ctx: Restate::ObjectContext, input: T.untyped).returns(T.untyped) }
  def add(ctx, input); end

  sig { params(ctx: Restate::ObjectSharedContext).returns(T.untyped) }
  def get(ctx); end
end

Run tapioca dsl again whenever you add or rename handlers. Commit the generated sorbet/rbi/ files to version control so the whole team benefits.


Running

Development

cd examples
bundle exec falcon serve --bind http://localhost:9080 -n 1

Production

bundle exec falcon serve --bind http://0.0.0.0:9080

Docker

FROM ruby:3.3-slim-bookworm
RUN apt-get update && apt-get install -y build-essential curl clang \
    && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install && bundle exec rake compile
COPY . .
CMD ["bundle", "exec", "falcon", "serve", "--bind", "http://0.0.0.0:9080"]

Register with Restate

# Using Restate CLI
restate deployments register http://localhost:9080

# Using admin API directly
curl http://localhost:9070/deployments \
  -H 'content-type: application/json' \
  -d '{"uri": "http://localhost:9080"}'

# Force re-register after code changes
curl http://localhost:9070/deployments \
  -H 'content-type: application/json' \
  -d '{"uri": "http://localhost:9080", "force": true}'

Testing

The SDK ships a test harness that starts a real Restate server via Docker, serves your services on a local Falcon server, and registers them automatically. No external setup is needed — just Docker.

Opt-in with require 'restate/testing'. Add testcontainers-core to your Gemfile:

gem 'testcontainers-core', require: false

Block-Based (Recommended)

require 'restate/testing'

Restate::Testing.start(Greeter, Counter) do |env|
  # env.ingress_url  => "http://localhost:32771"
  # env.admin_url    => "http://localhost:32772"

  uri = URI("#{env.ingress_url}/Greeter/greet")
  request = Net::HTTP::Post.new(uri)
  request['Content-Type'] = 'application/json'
  request.body = '"World"'
  response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
  puts response.body  # => "Hello, World!"
end
# Container and server are automatically cleaned up.

Manual Lifecycle (for RSpec hooks)

require 'restate/testing'

RSpec.describe 'my services' do
  before(:all) do
    @harness = Restate::Testing::RestateTestHarness.new(Greeter, Counter)
    @harness.start
  end

  after(:all) do
    @harness&.stop
  end

  it 'greets' do
    uri = URI("#{@harness.ingress_url}/Greeter/greet")
    request = Net::HTTP::Post.new(uri)
    request['Content-Type'] = 'application/json'
    request.body = '"World"'
    response = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(request) }
    expect(JSON.parse(response.body)).to eq('Hello, World!')
  end
end

Configuration Options

All options are keyword arguments on both start and RestateTestHarness.new:

Option Default Description
restate_image: "docker.io/restatedev/restate:latest" Docker image for Restate server
always_replay: false Force replay on every suspension point (useful for catching non-determinism bugs)
disable_retries: false Disable Restate retry policy
Restate::Testing.start(MyService, always_replay: true, disable_retries: true) do |env|
  # ...
end

Running Harness Tests

make test-harness  # Requires Docker

URL Patterns

Service Type URL Pattern Example
Service /ServiceName/handler /Greeter/greet
VirtualObject /ObjectName/key/handler /Counter/my-counter/add
Workflow /WorkflowName/key/handler /UserSignup/user42/run

Examples

The examples/ directory contains runnable examples:

File Shows
greeter.rb Hello World: simplest stateless service
durable_execution.rb ctx.run, ctx.run_sync, background: true, RunRetryPolicy, TerminalError
virtual_objects.rb State ops, handler vs shared, state_keys, clear_all
workflow.rb Promises, signals, workflow state
service_communication.rb Calls, sends, fan-out/fan-in, wait_any, awakeables
typed_handlers.rb input:/output: with Dry::Struct, JSON Schema generation
typed_handlers_sorbet.rb input:/output: with T::Struct (Sorbet), JSON Schema generation
service_configuration.rb Service-level config: timeouts, retention, retry policy, lazy state

Run any example:

cd examples
bundle exec falcon serve --bind http://localhost:9080
restate deployments register http://localhost:9080

Complete API Quick Reference

Service Types

class MyService < Restate::Service
  handler def method(ctx, arg)
    # ctx is always the first parameter
  end
end

class MyObject < Restate::VirtualObject
  handler def exclusive_method(ctx, arg)         # One at a time per key
  end
  shared def concurrent_method(ctx)              # Many readers
  end
end

class MyWorkflow < Restate::Workflow
  main def run(ctx, arg)                         # Runs once per key
  end
  handler def query(ctx)                         # Shared handler
  end
end

Context Methods

# State (VirtualObject / Workflow)
ctx.get(name)  value | nil
ctx.get_async(name)  DurableFuture
ctx.set(name, value)
ctx.clear(name)
ctx.clear_all
ctx.state_keys  Array[String]
ctx.state_keys_async  DurableFuture

# Durable execution
ctx.run(name, background: false) { block }  DurableFuture
ctx.run_sync(name, background: false) { block }  value   # run + await
ctx.sleep(seconds)  DurableFuture

# Service calls
ctx.service_call(svc, handler, arg)  DurableCallFuture
ctx.object_call(svc, handler, key, arg)  DurableCallFuture
ctx.workflow_call(svc, handler, key, arg)  DurableCallFuture

# Fire-and-forget
ctx.service_send(svc, handler, arg, delay: nil)  SendHandle
ctx.object_send(svc, handler, key, arg, delay: nil)  SendHandle
ctx.workflow_send(svc, handler, key, arg, delay: nil)  SendHandle

# Awakeables
ctx.awakeable  [id, DurableFuture]
ctx.resolve_awakeable(id, payload)
ctx.reject_awakeable(id, message, code: 500)

# Promises (Workflow only)
ctx.promise(name)  value           # Blocks until resolved
ctx.peek_promise(name)  value | nil
ctx.resolve_promise(name, payload)
ctx.reject_promise(name, message, code: 500)

# Futures
ctx.wait_any(*futures)  [completed, remaining]

# Metadata
ctx.request  Request{id, headers, body}
ctx.request.attempt_finished_event  AttemptFinishedEvent
ctx.key  String

# Cancellation
ctx.cancel_invocation(invocation_id)

Fiber-Local Context Accessors

Restate.current_context                  # → Context (any handler)
Restate.current_object_context           # → ObjectContext (exclusive — full state)
Restate.current_shared_context           # → ObjectSharedContext (shared — read-only state)
Restate.current_workflow_context         # → WorkflowContext (main — full state + promises)
Restate.current_shared_workflow_context  # → WorkflowSharedContext (shared — read-only + promises)

Future Methods

# DurableFuture (from ctx.run, ctx.sleep)
future.await  value
future.completed?  bool

# DurableCallFuture (from ctx.service_call, etc.)
future.await  value
future.completed?  bool
future.invocation_id  String
future.cancel

# SendHandle (from ctx.service_send, etc.)
handle.invocation_id  String
handle.cancel