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.
# 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)# config.ru
require_relative 'greeter'
run endpoint.appbundle exec falcon serve --bind http://localhost:9080restate deployments register http://localhost:9080
curl localhost:8080/Greeter/greet -H 'content-type: application/json' -d '"World"'
# → "Hello, World!"The SDK provides three service types, each with different durability and concurrency guarantees.
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
endInvoke: POST /MyService/my_handler
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
endYou can also use Restate.get/Restate.set directly — see State Operations.
Invoke: POST /Counter/my-counter/add (key is my-counter)
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
endInvoke:
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'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') { ... }
endAll operations that interact with Restate return durable results — if the handler crashes and retries, completed operations are replayed from the journal without re-executing.
Execute a side effect exactly once. The result is durably recorded — on retry, the block is skipped and the stored result is returned.
Important:
runblocks are for external/non-deterministic work (HTTP calls, database writes, random values, timestamps). Do not callRestate.*APIs inside arunblock — noRestate.get,Restate.set,Restate.sleep,Restate.service_call, etc. Those operations must happen in the handler body, outside ofrun.
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)
endBackground 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) }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
endOptions:
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.
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 namesAsync 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.awaitValues 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)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.
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 sendUnder the hood this delegates to Restate.service_call/Restate.object_call/etc. — the fluent API
is pure syntactic sugar with no behavior difference.
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).awaitDurableCallFuture 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 invocationDispatch 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 invocationAll 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 |
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 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.awaitPause 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.awaitThe 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)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.
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 = 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)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 finishesRestate.cancel_invocation(invocation_id)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
endHandler 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 declarativestate) for durable state, andThread.current(fiber-local) for per-invocation transient context.
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 typeThe input: and output: options accept:
- A type class (e.g.,
String,Integer,Dry::Structsubclass) — auto-resolves serde + JSON schema - A serde object (responds to
serialize/deserialize) — used directly - Omitted — defaults to
JsonSerdewith 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)By default, the service name is the unqualified class name. Override it:
class MyLongClassName < Restate::Service
service_name 'ShortName'
# Registered as "ShortName" in Restate
endHandlers 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']
endUse 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
endAll 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 |
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.ruMiddleware 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 namehandler.service_tag.name— service namehandler.service_tag.kind—"service","object", or"workflow"Restate.request.id— invocation IDRestate.request.headers— request headers
Middleware executes in registration order. Each wraps the next, forming an onion around the handler.
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.
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.
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::Structgets 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 whateverJSON.parsereturns — 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 toJsonSerde.
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
endSupported 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 |
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
You can also use primitive Ruby types for simple handlers:
handler :greet, input: String, output: String
handler :compute, input: Integer, output: IntegerThese 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.
When input: or output: is provided, the SDK resolves a serde in this order:
- Serde object — if it responds to
serializeanddeserialize, use it directly - Dry::Struct subclass — use
DryStructSerde - Primitive type (
String,Integer, etc.) — useJsonSerdewith type schema - Class with
.json_schema— useJsonSerdewith that schema - Fallback —
JsonSerdewith no schema
| Serde | Serialize | Deserialize | Use Case |
|---|---|---|---|
JsonSerde (default) |
JSON.generate |
JSON.parse |
Structured data |
BytesSerde |
Pass-through | Pass-through | Raw bytes |
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: MarshalSerdeRaise 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
endAny StandardError (other than TerminalError) triggers a retry of the entire invocation.
Restate automatically retries with exponential backoff.
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)
endThe 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.
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.
cd examples
bundle exec falcon serve --bind http://localhost:9080 -n 1bundle exec falcon serve --bind http://0.0.0.0:9080FROM 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"]# 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}'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: falserequire '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.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
endAll 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|
# ...
endmake test-harness # Requires Docker| 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 |
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:9080Restate.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.
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('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.
RunRetryPolicy intervals are in milliseconds:
RunRetryPolicy.new(initial_interval: 100, max_interval: 10_000) # msService/handler-level timeouts and retention are in seconds:
inactivity_timeout 300 # seconds
journal_retention 86_400 # secondsDeclaring 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).
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# 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)# 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.cancelendpoint.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)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)