Restate.Sdk.Testing.Containers
0.3.0
dotnet add package Restate.Sdk.Testing.Containers --version 0.3.0
NuGet\Install-Package Restate.Sdk.Testing.Containers -Version 0.3.0
<PackageReference Include="Restate.Sdk.Testing.Containers" Version="0.3.0" />
<PackageVersion Include="Restate.Sdk.Testing.Containers" Version="0.3.0" />
<PackageReference Include="Restate.Sdk.Testing.Containers" />
paket add Restate.Sdk.Testing.Containers --version 0.3.0
#r "nuget: Restate.Sdk.Testing.Containers, 0.3.0"
#:package Restate.Sdk.Testing.Containers@0.3.0
#addin nuget:?package=Restate.Sdk.Testing.Containers&version=0.3.0
#tool nuget:?package=Restate.Sdk.Testing.Containers&version=0.3.0
Restate .NET SDK
Pre-release -- Under active development. APIs may change between releases.
This is a community-driven project, not an official Restate SDK. Built by reverse-engineering the Java, TypeScript, and Go SDKs. For official SDKs, see github.com/restatedev.
Restate is a system for easily building resilient applications using distributed durable async/await. This repository contains a .NET SDK for writing services that run on the Restate runtime.
Community
- Join our online community for help, sharing feedback and talking to the community.
- Check out our documentation to get quickly started!
- Follow us on Twitter for staying up to date.
- Create a GitHub issue for requesting a new feature or reporting a problem.
- Visit the Restate GitHub org for official SDKs and other repositories.
Using the SDK
Prerequisites
.NET 10.0 SDK or later
-
Alternatively, use the included Docker Compose file:
docker compose up -d
Install
dotnet add package Restate.Sdk
Optional packages:
dotnet add package Restate.Sdk.Testing # Mock contexts for unit testing
dotnet add package Restate.Sdk.Testing.Containers # Testcontainers integration harness
dotnet add package Restate.Sdk.Lambda # AWS Lambda adapter
The Roslyn source generator is bundled with
Restate.Sdk-- typed clients and service definitions are generated automatically at compile time. No additional packages needed.
Quick Start
Define a service and host it:
using Restate.Sdk;
using Restate.Sdk.Hosting;
[Service]
public class GreeterService
{
[Handler]
public async Task<string> Greet(Context ctx, string name)
{
// Side effect: journaled and replayed on retries
var greeting = await ctx.Run("build-greeting",
() => $"Hello, {name}!");
return greeting;
}
}
await RestateHost.CreateBuilder()
.AddService<GreeterService>()
.Build()
.RunAsync();
Start the service and register it with Restate:
dotnet run
restate deployments register http://localhost:9080
Invoke the service:
curl -X POST http://localhost:8080/GreeterService/Greet \
-H 'content-type: application/json' \
-d '"World"'
Service Types
Restate supports three service types, each with different consistency and state guarantees.
Stateless Service
No state. Multiple invocations run concurrently.
[Service]
public class EmailService
{
[Handler]
public async Task<bool> SendEmail(Context ctx, EmailRequest request)
{
return await ctx.Run("send-email", async () =>
{
await emailClient.SendAsync(request.To, request.Subject, request.Body);
return true;
});
}
}
Virtual Object
Keyed entities with exclusive state access. Only one [Handler] runs at a time per key.
[SharedHandler] methods can run concurrently with read-only state access.
[VirtualObject]
public class Counter
{
private static readonly StateKey<int> Count = new("count");
[Handler]
public async Task<int> Add(ObjectContext ctx, int delta)
{
var current = await ctx.Get(Count);
var next = current + delta;
ctx.Set(Count, next);
return next;
}
[SharedHandler]
public async Task<int> Get(SharedObjectContext ctx)
=> await ctx.Get(Count);
[Handler]
public Task Reset(ObjectContext ctx)
{
ctx.ClearAll();
return Task.CompletedTask;
}
}
Workflow
Long-running durable workflows with state and awakeables for external signaling. The Run handler
executes exactly once per workflow ID. Workflow promises (ctx.Promise<T>()) are also available
for signaling between handlers.
[Workflow]
public class SignupWorkflow
{
private static readonly StateKey<string> Status = new("status");
[Handler]
public async Task<bool> Run(WorkflowContext ctx, SignupRequest request)
{
ctx.Set(Status, "creating-account");
var accountId = await ctx.Run("create-account",
() => AccountService.Create(request.Email, request.Name));
ctx.Set(Status, "awaiting-verification");
// Awakeable: a durable promise resolved by an external system.
// Pass awakeable.Id to the external system; await awakeable.Value to block.
var awakeable = ctx.Awakeable<string>();
await ctx.Run("send-verification-email",
() =>
{
EmailService.SendVerification(request.Email, awakeable.Id);
return Task.CompletedTask;
});
// Workflow suspends here until the external system resolves the awakeable
await awakeable.Value;
ctx.Set(Status, "completed");
return true;
}
[SharedHandler]
public async Task<string> GetStatus(SharedWorkflowContext ctx)
=> await ctx.Get(Status) ?? "unknown";
}
Durable Building Blocks
The Context object provides durable operations that are automatically journaled and replayed:
// Side effects (journaled, replayed on retries)
var result = await ctx.Run("name", async () => await FetchDataAsync());
var value = await ctx.Run("name", () => ComputeSync());
// Side effects with retry policy (custom backoff per operation)
var data = await ctx.Run("fetch", async () => await FetchDataAsync(),
RetryPolicy.FixedAttempts(5));
var computed = await ctx.Run("compute", () => ComputeSync(),
RetryPolicy.Default);
await ctx.Run("fire-and-forget", async () => await NotifyAsync(),
new RetryPolicy
{
InitialDelay = TimeSpan.FromSeconds(1),
ExponentiationFactor = 3.0,
MaxDelay = TimeSpan.FromSeconds(30),
MaxAttempts = 10,
MaxDuration = TimeSpan.FromMinutes(5)
});
// Service-to-service calls (retried, exactly-once)
var response = await ctx.Call<string>("GreeterService", "Greet", "Alice");
var count = await ctx.Call<int>("CounterObject", "my-key", "Add", 1);
// Calls with idempotency key (exactly-once deduplication)
var txnId = await ctx.Call<string>("PaymentService", "Charge", request,
CallOptions.WithIdempotencyKey("order-123"));
// Calls in a concurrency scope, optionally narrowed by a limit key
var quote = await ctx.Call<string>("PricingService", "Quote", request,
CallOptions.WithScope("tenant-a", "customer-7"));
var pricing = ctx.CallFuture<string>("PricingService", "Quote", request,
CallOptions.WithScope("tenant-a"));
// One-way sends (fire-and-forget, returns InvocationHandle for tracking)
InvocationHandle handle = await ctx.Send("EmailService", "SendEmail", request);
await ctx.Send("ReminderService", "Remind", data, delay: TimeSpan.FromHours(1));
await ctx.Send("EmailService", "SendEmail", request,
SendOptions.WithScope("tenant-a", "customer-7"));
// Cancel a running invocation
await ctx.CancelInvocation("inv-id-to-cancel");
// Durable timers (survive restarts)
await ctx.Sleep(TimeSpan.FromMinutes(5));
// Non-blocking timer (returns a future for use with combinators)
var timer = ctx.Timer(TimeSpan.FromMinutes(5));
// Awakeables (promises resolved by external systems)
var awakeable = ctx.Awakeable<string>();
// pass awakeable.Id to external system, then:
var payload = await awakeable.Value;
// Signals (resolved by name against this invocation handle, compose with combinators)
var approval = ctx.Signal<string>("approval");
var decision = await approval.GetResult();
// Resolve or reject a signal on another invocation through its handle
InvocationHandle target = await ctx.Send("ReviewService", "Review", request);
await target.ResolveSignal(ctx, "approval", "granted");
await target.RejectSignal(ctx, "approval", "not approved");
// Non-blocking futures and combinators
var f1 = ctx.RunAsync<int>("a", () => Task.FromResult(1));
var f2 = ctx.RunAsync<int>("b", () => Task.FromResult(2));
var results = await ctx.All(f1, f2); // wait for all
var winner = await ctx.Race(f1, f2); // first to complete
// Replay-safe random
var id = ctx.Random.NextGuid();
var n = ctx.Random.Next(1, 100);
// Replay-safe console (silent during replay)
ctx.Console.Log("processing...");
// Durable timestamp
var now = await ctx.Now();
// Context properties
var invocationId = ctx.InvocationId; // unique ID for this invocation
var headers = ctx.Headers; // request headers
CancellationToken ct = ctx.Aborted; // fires when invocation is cancelled
Scope and limit key
New in 0.3.0.
A scope is a named server-side concurrency limit: invocations sent into it run under the limit
configured for that scope on the server. A limit key narrows that limit further, to the
invocations inside the scope sharing the same key (one tenant, one customer, one device). Both are
optional, both are set through CallOptions / SendOptions, and a limit key without a scope is
rejected — the server only honours one inside a scope.
They are available on calls, call futures, and sends, in both the untyped and typed forms, on the generated typed clients, and on the ingress client:
// Generated typed clients take the same options
var client = ctx.ServiceClient<IPricingServiceClient>();
var quote = await client.QuoteAsync(request, CallOptions.WithScope("tenant-a", "customer-7"));
var sender = ctx.ServiceSendClient<IEmailServiceSendClient>(SendOptions.WithScope("tenant-a"));
await sender.SendEmailSend(request);
Signals
New in 0.3.0.
A signal is a durable value delivered to a running invocation from outside it. The handler awaits it by name and suspends until it is completed; whoever holds the invocation handle resolves or rejects it. Unlike an awakeable, a named signal needs no id passed around -- the name is the rendezvous point.
[Workflow]
public class ReviewWorkflow
{
[Handler]
public async Task<string> Run(WorkflowContext ctx, ReviewRequest request)
{
// Suspends here until someone completes the signal named "approval"
var decision = await ctx.Signal<string>("approval").GetResult();
return decision;
}
}
The completing side addresses the target invocation through the handle its send returned:
InvocationHandle target = await ctx.Send("ReviewWorkflow", request.Id, "Run", request);
// Resolve the signal the target awaits by name...
await target.ResolveSignal(ctx, "approval", "granted");
// ...or reject it: the awaiting handler fails with the reason
await target.RejectSignal(ctx, "approval", "not approved");
// Unnamed signals (ctx.Signal<T>()) are addressed by index instead -- the first is 17
await ctx.ResolveSignal(target.InvocationId, 17, "granted");
Signals are durable futures, so they compose with All, Race, Any and AllSettled like any
other. From outside the runtime, RestateClient.ResolveSignal and RestateClient.RejectSignal
complete a signal by id -- the id an invocation hands out with ctx.Awakeable<T>().Id.
Error Handling
Restate automatically retries failed handlers. To signal a non-retryable failure (validation errors,
business rule violations), throw a TerminalException:
// Non-retryable error -- Restate will NOT retry this invocation
throw new TerminalException("Order not found", 404);
// All other exceptions are retried automatically with exponential backoff
ASP.NET Core Integration
For applications that need full dependency injection:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRestate(opts =>
{
opts.AddService<GreeterService>();
opts.AddVirtualObject<CounterObject>();
opts.AddWorkflow<SignupWorkflow>();
});
builder.Services.AddScoped<IEmailClient, SmtpEmailClient>();
var app = builder.Build();
app.MapRestate();
await app.RunAsync();
Request Identity Verification
Restate signs the requests it sends to your endpoint. Configure the publickeyv1_... keys printed by
restate-server on startup, and every request to /invoke and /discover must then carry a valid
x-restate-jwt-v1 signature; unsigned or mis-signed requests are rejected with 401 Unauthorized.
With no keys configured nothing is verified, which is what local development wants.
await RestateHost.CreateBuilder()
.AddService<GreeterService>()
.WithIdentityKeys("publickeyv1_F6EnyGehkp5cx8JQcSajjYv282Y2N9zYx7BfNi69gSPh")
.Build()
.RunAsync();
The same keys are configurable on RestateOptions (AddRestate(opts => opts.WithIdentityKeys(...)))
and on RestateLambdaHandler. See the request identity guide.
Telemetry
The SDK exposes an ActivitySource and a Meter, both named Restate.Sdk. Every invocation gets a
span, and the meter records restate.sdk.invocations, restate.sdk.invocation.duration and
restate.sdk.journal.replayed_commands. Per-operation activities for Run, Call and Sleep are
off by default -- turn them on through RestateTelemetryOptions:
await RestateHost.CreateBuilder()
.AddService<GreeterService>()
.ConfigureTelemetry(telemetry => telemetry.EnableOperationActivities = true)
.Build()
.RunAsync();
ctx.Logger is replay-aware: it logs while the handler runs for real and stays silent while the
journal replays. See the telemetry guide.
AWS Lambda
Deploy handlers as Lambda functions using the Restate.Sdk.Lambda package:
using Restate.Sdk;
public class Handler : RestateLambdaHandler
{
public override void Register()
{
Bind<GreeterService>();
Bind<CounterObject>();
}
}
Configure the Lambda function handler as YourAssembly::YourNamespace.Handler::FunctionHandler.
NativeAOT
For ahead-of-time compiled deployments with minimal startup time and memory footprint, use
BuildAot with the source-generated registration:
using Restate.Sdk.Hosting;
await RestateHost.CreateBuilder()
.AddService<GreeterService>()
.BuildAot() // Slim Kestrel host, no reflection
.RunAsync();
Publish as a self-contained NativeAOT binary:
dotnet publish -c Release -r linux-x64
The source generator emits AddRestateGenerated() which registers all service definitions
and JSON serializer contexts without reflection. See the
NativeAotGreeter sample for a complete working example.
Tip: Set
<PublishAot>true</PublishAot>in your.csprojto enable AOT compilation. The SDK's source generator handles all trimming and serialization concerns automatically.
Testing
The Restate.Sdk.Testing package provides mock contexts for unit testing handlers without
a running Restate server:
using Restate.Sdk.Testing;
var ctx = new MockContext();
var service = new GreeterService();
var result = await service.Greet(ctx, "Alice");
Assert.Equal("Hello, Alice!", result);
Mock contexts are available for every context type:
| Mock Class | For |
|---|---|
MockContext |
Stateless services |
MockObjectContext |
Virtual object exclusive handlers |
MockSharedObjectContext |
Virtual object shared handlers |
MockWorkflowContext |
Workflow run handlers |
MockSharedWorkflowContext |
Workflow shared handlers |
Mock context features:
// Deterministic time
var ctx = new MockContext();
ctx.CurrentTime = new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero);
var now = await ctx.Now(); // returns the configured time
// Setup call results
ctx.SetupCall<string>("GreeterService", "Greet", "Hello!");
// Setup call failures
ctx.SetupCallFailure("GreeterService", "Greet", new TerminalException("fail", 500));
// Register typed clients
ctx.RegisterClient<IGreeterServiceClient>(myMockClient);
// Verify recorded calls, sends, and sleeps
Assert.Single(ctx.Calls);
Assert.Equal("GreeterService", ctx.Calls[0].Service);
// Verify idempotency keys on recorded calls
Assert.Equal("my-key", ctx.Calls[0].IdempotencyKey);
// Verify cancellations
Assert.Single(ctx.Cancellations);
Assert.Equal("inv-123", ctx.Cancellations[0]);
Integration testing with Testcontainers
Restate.Sdk.Testing.Containers runs handlers against a real Restate server in Docker.
RestateTestHarness.StartAsync hosts the endpoint, starts the pinned
docker.io/restatedev/restate:1.7 container, registers the deployment and exposes an ingress client:
using Restate.Sdk.Testing.Containers;
await using var harness = await RestateTestHarness.StartAsync(
builder => builder.AddService<GreeterService>());
var greeting = await harness.Client
.Service("GreeterService")
.Call<string>("Greet", "World");
Assert.Equal("Hello, World!", greeting);
Reach for the mock contexts when the handler's own logic is under test, and for the harness when the runtime's behaviour is -- retries, suspension, state surviving across invocations. See the Testcontainers guide.
Interfaces
Context interfaces (IContext, IObjectContext, etc.) are available for utility methods,
type constraints, and generic programming:
// Utility method accepting any context type
public static async Task<string> FormatTimestamp(IContext ctx)
{
var now = await ctx.Now();
return now.ToString("O");
}
External Ingress Client
Call Restate services from outside the runtime using RestateClient:
using Restate.Sdk.Client;
using var client = new RestateClient("http://localhost:8080");
// Call a service handler
var greeting = await client.Service("GreeterService").Call<string>("Greet", "World");
// Call a virtual object
var count = await client.VirtualObject("CounterObject", "my-key").Call<int>("Add", 1);
// Start a workflow
await client.Workflow("SignupWorkflow", "user-1").Call<bool>("Run", "alice@example.com");
// Fire-and-forget with delay (returns invocation ID)
var invocationId = await client.Service("EmailService")
.Send("SendEmail", request, delay: TimeSpan.FromHours(1));
// Scope and limit key, as from a handler
var quote = await client.Service("PricingService")
.Call<string>("Quote", request, CallOptions.WithScope("tenant-a", "customer-7"));
await client.Service("EmailService")
.Send("SendEmail", request, SendOptions.WithScope("tenant-a"));
New in 0.3.0: the scoped ingress paths above, and the typed exception below.
Any non-success ingress response throws a RestateIngressException:
try
{
var greeting = await client.Service("GreeterService").Call<string>("Greet", "World");
}
catch (RestateIngressException ex)
{
// ex.StatusCode — the HTTP status of the ingress response
// ex.ErrorSource — "ingress" when the ingress itself rejected the request (unknown service,
// bad payload, overload); the invocation when the failure came back from
// the handler. Null on restate-server before 1.7.4, which does not report it.
// ex.ErrorCode — the Restate error code, or null when the server did not report one
// ex.Message — the server's error message, or the raw response body
logger.LogError(ex, "Ingress call failed from {Source}", ex.ErrorSource ?? "unknown");
}
It derives from HttpRequestException, so existing catch (HttpRequestException) blocks keep
working.
Reflection-based overloads use camelCase JSON by default. To match an endpoint with different JSON conventions, configure the client explicitly:
using System.Text.Json;
var options = new RestateClientOptions
{
JsonSerializerOptions = JsonSerializerOptions.Default
};
using var defaultJsonClient = new RestateClient("http://localhost:8080", options);
Samples
The samples/ directory contains complete working examples:
| Sample | Port | Demonstrates |
|---|---|---|
| Greeter | 9080 | Service basics, ctx.Run(), ctx.Sleep() |
| Counter | 9081 | Virtual object state, StateKey<T>, shared handlers |
| TicketReservation | 9082 | State machines, delayed sends, cross-service calls |
| SignupWorkflow | 9084 | Workflows, durable promises, awakeables |
| NativeAotGreeter | 9085 | NativeAOT publishing, BuildAot(), source-generated registration |
| Saga | 9086 | Saga/compensation pattern, RetryPolicy, cross-service orchestration |
| FanOut | 9087 | Fan-out/fan-in, RunAsync + All/Race combinators |
| NativeAotCounter | 9088 | Virtual object state under NativeAOT, JsonSerializerContext wiring |
| NativeAotSaga | 9089 | Saga/compensation pattern under NativeAOT |
Run any sample:
cd samples/Greeter
dotnet run
Compatibility
| SDK Version | Restate Server | Protocol | .NET |
|---|---|---|---|
| 0.1.0-alpha.1 | 1.6.0+ | v5 - v6 | .NET 10.0 |
| 0.1.0-alpha.2 | 1.6.0+ | v5 - v6 | .NET 10.0 |
| 0.1.0-alpha.3 | 1.6.0+ | v5 - v6 | .NET 10.0 |
| 0.1.0-alpha.4 | 1.6.0+ | v5 - v6 | .NET 10.0 |
| 0.1.0-alpha.5 | 1.6.0+ | v5 - v6 | .NET 10.0 |
| 0.2.0 | 1.6.0+ | v5 - v7 | .NET 10.0 |
| 0.2.1 | 1.6.0+ | v5 - v7 | .NET 10.0 |
| 0.3.0 | 1.6.0+ | v5 - v7 | .NET 10.0 |
The protocol version is negotiated per invocation from the request content type, so the SDK and the
server settle on the highest version both support; v5 is the floor, which is why the minimum server
version has not moved. 0.3.0 is the release being cut from main: it is the first to carry scope and
limit key, signals, and RestateIngressException -- everything else documented here is in 0.2.1.
Two features degrade rather than fail against older servers: scope and limit key need a server that
honours them (CI runs against restate-server 1.7), and RestateIngressException.ErrorSource and
ErrorCode are only reported by restate-server 1.7.4 and newer.
Contributing
Contributions are welcome. Whether feature requests, bug reports, or pull requests, all contributions are appreciated.
Building from source
dotnet build
dotnet test
Running specific tests
dotnet test test/Restate.Sdk.Tests --filter "FullyQualifiedName~ProtobufParser"
dotnet test test/Restate.Sdk.Generators.Tests
Code formatting
The CI pipeline enforces consistent formatting. Check locally before pushing:
dotnet format --verify-no-changes
CI
Every pull request runs five required checks:
- Build & Test (
ci.yml) -- builds in Release mode, runs all tests with coverage, packs the NuGet packages and verifies the source generator is bundled - Format Check (
ci.yml) -- verifiesdotnet format --verify-no-changescompliance - Integration Test (
ci.yml) -- runs afterBuild & Test: starts a real Restate server in Docker and drives the samples end to end, including the Native AOT builds and request identity - analyze (
codeql.yml) -- CodeQL security analysis for C# - Validate PR title (
pr-title.yml) -- enforces the Conventional Commits title
docs.yml builds the docfx site and publishes it to GitHub Pages on pushes to main.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Restate.Sdk (>= 0.3.0)
- Testcontainers (>= 4.15.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.