Skip to content

Latest commit

 

History

History
1325 lines (991 loc) · 40.2 KB

File metadata and controls

1325 lines (991 loc) · 40.2 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(name)
    Restate.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(input)
    # 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
  state :count, default: 0    # Declarative state with auto-generated accessors

  handler def add(amount)
    self.count += amount       # Reads via Restate.get, writes via Restate.set
  end

  shared def get
    count                      # Returns 0 when unset (the default)
  end
end

You can also use Restate.get/Restate.set directly — see State Operations.

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(email)
    user_id = Restate.run_sync('create-account') { create_user(email) }
    Restate.set('status', 'waiting_for_approval')

    # Block until approve() is called — Restate.promise suspends the handler
    # and returns the resolved value when another handler calls resolve_promise.
    approval = Restate.promise('approval')
    Restate.set('status', 'active')
    { 'user_id' => user_id, 'approval' => approval }
  end

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

  handler def status
    Restate.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

All Restate operations are available as top-level module methods on Restate. Inside a handler, call Restate.run_sync, Restate.sleep, Restate.get, etc. directly:

handler def greet(name)
  Restate.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 (Restate.run)

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

Important: run blocks are for external/non-deterministic work (HTTP calls, database writes, random values, timestamps). Do not call Restate.* APIs inside a run block — no Restate.get, Restate.set, Restate.sleep, Restate.service_call, etc. Those operations must happen in the handler body, outside of run.

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 = Restate.run('step-name') { do_something() }
result = future.await

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

With retry policy:

policy = Restate::RunRetryPolicy.new(
  initial_interval: 100,     # milliseconds between retries
  max_attempts: 5,           # max retry count
  interval_factor: 2.0,      # exponential backoff multiplier
  max_interval: 10_000,      # milliseconds cap on retry interval
  max_duration: 60_000       # milliseconds total duration cap
)
# NOTE: RunRetryPolicy uses milliseconds. Service/handler-level timeouts
# (inactivity_timeout, abort_timeout, etc.) use seconds. See "Sharp Edges" below.

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

Terminal errors (non-retryable):

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

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 = Restate.run_sync('resize-image', background: true) { process_image(data) }

Declarative State

The state macro declares durable state entries on VirtualObject and Workflow classes. It generates getter, setter, and clear methods that delegate to the context automatically.

class Counter < Restate::VirtualObject
  state :count, default: 0

  handler def add(addend)
    self.count += addend     # getter reads Restate.get('count'), setter calls Restate.set('count', ...)
  end

  shared def get
    count                    # returns 0 when state is unset
  end

  handler def reset
    clear_count              # removes the state entry
  end
end

Options:

  • default: — value returned when the state entry hasn't been set (default: nil)
  • serde: — custom serializer/deserializer (default: JsonSerde)

Note: State names must differ from handler names, since both generate instance methods on the same class. If you need the same name, use Restate.get/Restate.set directly.

State Operations

You can also manage state explicitly via the Restate module methods. Available in VirtualObject and Workflow handlers.

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

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

future_a = Restate.get_async('key_a')
future_b = Restate.get_async('key_b')
keys_future = Restate.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:

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

Sleep

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

Fluent Call API (Recommended)

The fluent API reads like natural Ruby — call handlers directly on service classes:

# Durable calls (return DurableCallFuture)
result = Worker.call.process(task).await              # Service
result = Counter.call("my-key").add(5).await          # VirtualObject
result = UserSignup.call("user42").run(email).await   # Workflow

# Fire-and-forget sends (return SendHandle)
Worker.send!.process(task)                            # Service
Counter.send!("my-key").add(5)                        # VirtualObject
Worker.send!(delay: 60).process('cleanup')            # Delayed send

Under the hood this delegates to Restate.service_call/Restate.object_call/etc. — the fluent API is pure syntactic sugar with no behavior difference.

Explicit Calls

For full control over options (idempotency keys, custom headers, serde overrides), use the Restate module methods directly:

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

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

DurableCallFuture methods:

future = Restate.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 = Restate.service_send(MyService, :handler, arg)
handle = Restate.object_send(Counter, :add, 'my-key', 5)

# Delayed send (executes after 60 seconds)
handle = Restate.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 — both fluent and explicit — accept these keyword arguments:

# Fluent API — kwargs pass through to the underlying call
Worker.call.process(task, idempotency_key: 'unique-key').await
Counter.call("key").add(5, headers: { 'x-trace' => 'abc' }).await
Worker.send!.process(task, idempotency_key: 'dedup-key')

# Explicit API — same kwargs
Restate.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
)
Option Call Send Description
idempotency_key: yes yes Deduplication key for exactly-once semantics
headers: yes yes Custom headers forwarded to the target handler
input_serde: yes yes Override input serializer
output_serde: yes Override output serializer

Fan-Out / Fan-In

Launch multiple calls concurrently, then collect all results.

# Fan-out: launch calls
futures = tasks.map { |t| Restate.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 = Restate.service_call(ServiceA, :slow, arg)
future_b = Restate.service_call(ServiceB, :fast, arg)

completed, remaining = Restate.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 = Restate.awakeable

# Send the ID to an external system
Restate.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:

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

Signals (Inter-Invocation Messaging)

Named signals let one invocation push a value into another running invocation without going through an awakeable round-trip. The receiver waits on a name; the sender addresses the value by (invocation_id, name).

# Receiver: block until 'mySignal' is delivered to this invocation
value = Restate.signal('mySignal').await
# Sender: resolve the signal on a target invocation
Restate.resolve_signal(invocation_id, 'mySignal', 'hello')

# Or reject it as a terminal failure (raised on the receiver's await)
Restate.reject_signal(invocation_id, 'mySignal', 'boom', code: 500)

Unlike awakeables, the sender doesn't need an opaque id generated by the receiver — both sides agree on the signal name up front, so the receiver doesn't have to communicate anything back. Restate.signal(name) returns a DurableFuture and can be composed with Restate.wait_any.

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 = Restate.promise('approval')

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

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

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

Request Metadata

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

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

Attempt Finished Event

The attempt_finished_event on Restate.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 = Restate.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

Cancel Invocation

Restate.cancel_invocation(invocation_id)

Handler Registration

Class-Based DSL (Recommended)

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

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

Handler lifecycle: The SDK allocates a single instance of each service class and reuses it across all invocations. Do not store per-request state in instance variables — they will leak across invocations. Use Restate.get/Restate.set (or declarative state) for durable state, and Thread.current (fiber-local) for per-invocation transient context.

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

Handlers receive an optional input parameter with the deserialized request body:

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

handler def with_input(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(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

Middleware

Middleware wraps every handler invocation, following the Sidekiq middleware pattern. A middleware is a class with a call(handler, ctx) method that uses yield to invoke the next middleware or the handler itself.

class TimingMiddleware
  def call(handler, ctx)
    start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    result = yield
    duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
    puts "#{handler.service_tag.name}/#{handler.name} took #{duration}s"
    result
  end
end

endpoint = Restate.endpoint(MyService)
endpoint.use(TimingMiddleware)

Middleware with configuration:

class AuthMiddleware
  def initialize(api_key:)
    @api_key = api_key
  end

  def call(handler, ctx)
    raise Restate::TerminalError.new('unauthorized', status_code: 401) unless valid?(ctx)
    yield
  end
end

endpoint.use(AuthMiddleware, api_key: 'secret')

Available in call:

  • handler.name — handler method name
  • handler.service_tag.name — service name
  • handler.service_tag.kind"service", "object", or "workflow"
  • Restate.request.id — invocation ID
  • Restate.request.headers — request headers

Middleware executes in registration order. Each wraps the next, forming an onion around the handler.

Outbound (client) middleware

Outbound middleware wraps every outgoing service call and send, following the Sidekiq client middleware pattern. Register with use_outbound:

# A Current-style accessor for per-invocation context (fiber-scoped in Ruby 3.0+)
module Current
  module_function

  def tenant_id = Thread.current[:tenant_id]
  def tenant_id=(val) = Thread.current[:tenant_id] = val
end

class TenantOutboundMiddleware
  def call(_service, _handler, headers)
    headers['x-tenant-id'] = Current.tenant_id if Current.tenant_id
    yield
  end
end

endpoint.use_outbound(TenantOutboundMiddleware)

Available in call:

  • service — target service name (String)
  • handler — target handler name (String)
  • headers — mutable Hash; modify it to attach headers to the outgoing request

Use yield to continue the chain. Not yielding would skip the call.

Note: Restate automatically propagates inbound headers to outgoing calls. Outbound middleware is for injecting new headers that aren't on the original request (e.g., tenant IDs from fiber-local storage, authorization tokens for specific target services).

See middleware_example/ for a complete working example with real OpenTelemetry tracing and tenant isolation.

Built-in: Deadlock Detection

The SDK ships with deadlock detection middleware that catches re-entrant VirtualObject calls that would otherwise block forever.

Restate VirtualObjects serialize exclusive handler access per key. If handler A on VO key "x" calls handler B on the same VO key "x", the second call waits for the first — which never finishes because it's waiting for the second. The middleware detects this pattern and raises a DeadlockError (409) immediately instead of hanging.

endpoint = Restate.endpoint(MyVirtualObject)
endpoint.use(Restate::Middleware::DeadlockDetection::Inbound)
endpoint.use_outbound(Restate::Middleware::DeadlockDetection::Outbound)

The inbound side reads a held-locks header from the request and checks if the current exclusive handler targets an already-held key. The outbound side propagates the header to downstream calls and catches same-service deadlocks early.

See examples/deadlock_detection.rb for a complete example.


Typed Handlers

The input: and output: options on handler declarations control serialization and JSON Schema generation — they do not add runtime type validation on their own.

  • Dry::Struct gets full object instantiation: input JSON is deserialized into a struct instance with attribute validation, and JSON Schema is generated from the struct definition.
  • Primitive types (String, Integer, etc.) select the JSON serde and generate the corresponding JSON Schema ({type: 'string'}, etc.), but the value is still whatever JSON.parse returns — there is no runtime check that it matches the declared type.
  • Custom classes are used as-is if they respond to serialize/deserialize (a serde), or if they respond to .json_schema (schema-only). Otherwise they fall back to JsonSerde.

Using Dry::Struct

dry-struct is a popular typed struct library that 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(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

Dry::Struct types are auto-detected at handler registration time. When a handler declares input: MyRequest where MyRequest < Dry::Struct:

  • Input JSON is deserialized into a struct instance (not a raw Hash) — this is runtime typing
  • JSON Schema is generated from the struct definition and published via Restate discovery
  • Output is serialized via to_h + JSON

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. Note: this does not validate at runtime that the input is actually a String or Integer — it only controls schema metadata and serde selection.

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. Dry::Struct subclass — use DryStructSerde
  3. Primitive type (String, Integer, etc.) — use JsonSerde with type schema
  4. Class with .json_schema — use JsonSerde with that schema
  5. 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
  Restate.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 = Restate.service_call(Other, :handler, arg).await
rescue => e
  handle_error(e)
end

# GOOD — catch only what you mean
begin
  result = Restate.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.

Since all Restate operations are called as Restate.* module methods, code completion works automatically without any YARD annotations.


HTTP Client

The SDK ships an HTTP client for invoking Restate services from outside the Restate runtime (e.g., from a web controller, a script, or tests). It uses the Restate ingress HTTP API.

require 'restate'

client = Restate::Client.new(ingress_url: "http://localhost:8080")

# Stateless service
result = client.service(Greeter).greet("World")
result = client.service("Greeter").greet("World")   # string name also works

# Keyed virtual object
result = client.object(Counter, "my-key").add(5)
result = client.object(Counter, "my-key").get(nil)

# Workflow
result = client.workflow(UserSignup, "user42").run("user@example.com")

With custom headers (e.g., authentication):

client = Restate::Client.new(
  ingress_url: "http://localhost:8080",
  ingress_headers: { "Authorization" => "Bearer token123" }
)

Note: The client is for external invocation only. Inside a handler, use the fluent call API or Restate.service_call — these are durable and survive crashes.


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 Restate.run, Restate.run_sync, background: true, RunRetryPolicy, TerminalError
virtual_objects.rb Declarative state, handler vs shared, state_keys, clear_all
workflow.rb Declarative state, promises, signals
service_communication.rb Fluent call API, fan-out/fan-in, wait_any, awakeables
typed_handlers.rb input:/output: with Dry::Struct, JSON Schema generation
service_configuration.rb Service-level config: timeouts, retention, retry policy, lazy state
deadlock_detection.rb Built-in deadlock detection middleware for VirtualObjects
middleware_example/ Real OpenTelemetry tracing + tenant isolation middleware (self-contained)

Run any example:

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

Sharp Edges / Non-Obvious Rules

run blocks are not general handler code

Restate.run blocks are for external, non-deterministic work (HTTP calls, database writes, Time.now, rand). Do not call any Restate.* API inside a run block. State reads, sleeps, service calls, awakeables — all of these must happen in the handler body, outside run.

Instance variables leak across invocations

The SDK allocates a single instance of each service class. Instance variables persist across invocations. Use Restate.get/Restate.set for durable state, and Thread.current (fiber-local in Ruby 3.0+) for per-invocation transient context. Never store request-specific data in @ivars.

Restate.promise blocks

Restate.promise('name') suspends the handler until another handler calls Restate.resolve_promise('name', value). It returns the resolved value, not a future. This is intentional — promises are a coordination primitive for workflows.

Duration units differ

RunRetryPolicy intervals are in milliseconds:

RunRetryPolicy.new(initial_interval: 100, max_interval: 10_000)  # ms

Service/handler-level timeouts and retention are in seconds:

inactivity_timeout 300     # seconds
journal_retention 86_400   # seconds

input: / output: is schema + serde, not runtime validation

Declaring handler :greet, input: String generates JSON Schema metadata and selects the JSON serde. It does not validate at runtime that the parsed value is a String. Only Dry::Struct provides real runtime typing (attribute validation on instantiation).


Complete API Quick Reference

Service Types

class MyService < Restate::Service
  handler def method(arg)
    # Use Restate.* methods for all operations
  end
end

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

class MyWorkflow < Restate::Workflow
  state :status, default: 'pending'              # Declarative state
  main def run(arg)                              # Runs once per key
  end
  handler def query                              # Shared handler
  end
end

Context Methods

# Declarative state (VirtualObject / Workflow)
state :name, default: nil, serde: nil  # class-level macro
self.name / self.name= / clear_name   # generated instance methods

# Explicit state (VirtualObject / Workflow)
Restate.get(name) -> value | nil
Restate.get_async(name) -> DurableFuture
Restate.set(name, value)
Restate.clear(name)
Restate.clear_all
Restate.state_keys -> Array[String]
Restate.state_keys_async -> DurableFuture

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

# Fluent service calls (recommended)
MyService.call.handler(arg) -> DurableCallFuture
MyObject.call("key").handler(arg) -> DurableCallFuture
MyWorkflow.call("key").handler(arg) -> DurableCallFuture

# Fluent fire-and-forget
MyService.send!.handler(arg) -> SendHandle
MyObject.send!("key").handler(arg) -> SendHandle
MyService.send!(delay: 60).handler(arg) -> SendHandle

# Explicit service calls
Restate.service_call(svc, handler, arg) -> DurableCallFuture
Restate.object_call(svc, handler, key, arg) -> DurableCallFuture
Restate.workflow_call(svc, handler, key, arg) -> DurableCallFuture

# Explicit fire-and-forget
Restate.service_send(svc, handler, arg, delay: nil) -> SendHandle
Restate.object_send(svc, handler, key, arg, delay: nil) -> SendHandle
Restate.workflow_send(svc, handler, key, arg, delay: nil) -> SendHandle

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

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

# Futures
Restate.wait_any(*futures) -> [completed, remaining]

# Metadata
Restate.request -> Request{id, headers, body}
Restate.request.attempt_finished_event -> AttemptFinishedEvent
Restate.key -> String

# Cancellation
Restate.cancel_invocation(invocation_id)

Future Methods

# DurableFuture (from Restate.run, Restate.sleep)
future.await -> value
future.completed? -> bool

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

# SendHandle (from Restate.service_send, etc.)
handle.invocation_id -> String
handle.cancel

Middleware

endpoint.use(MyMiddleware)            # Inbound (server) middleware
endpoint.use(MyMiddleware, arg: val)  # With constructor args
endpoint.use_outbound(MyOutbound)     # Outbound (client) middleware

# Built-in: deadlock detection for VirtualObjects
endpoint.use(Restate::Middleware::DeadlockDetection::Inbound)
endpoint.use_outbound(Restate::Middleware::DeadlockDetection::Outbound)

HTTP Client (External Invocation)

client = Restate::Client.new(ingress_url: "http://localhost:8080")
client.service(Greeter).greet("World")
client.object(Counter, "key").add(5)
client.workflow(UserSignup, "key").run(email)