---
url: https://wvlet.org/uni/http/rpc.md
---
# RPC

Trait-based RPC framework with automatic route generation, type-safe serialization, and structured error handling.

```scala
import wvlet.uni.http.rpc.*

// Define a service trait
trait UserService:
  def getUser(id: Long): User
  def createUser(name: String, email: String): User

// Create a router from the implementation
val router = RPCRouter.of[UserService](UserServiceImpl())
// Generates: POST /<package>.UserService/getUser, POST /<package>.UserService/createUser
```

## Defining an RPC Service

Define your service API as a Scala trait, then provide an implementation:

```scala
case class User(id: Long, name: String, email: String)

trait UserService:
  def getUser(id: Long): User
  def createUser(name: String, email: String): User
  def listUsers(limit: Int = 10): Seq[User]

class UserServiceImpl extends UserService:
  def getUser(id: Long): User =
    User(id, "Alice", "alice@example.com")

  def createUser(name: String, email: String): User =
    User(1L, name, email)

  def listUsers(limit: Int): Seq[User] =
    Seq(User(1L, "Alice", "alice@example.com"))
```

Methods can return `Rx[T]` for asynchronous responses — the router unwraps the return type automatically:

```scala
import wvlet.uni.rx.Rx

trait AsyncUserService:
  def getUser(id: Long): Rx[User]
```

## Creating an RPC Router

`RPCRouter.of[T]` uses compile-time reflection to extract method metadata and build routes:

```scala
val impl = UserServiceImpl()
val router = RPCRouter.of[UserService](impl)

// Inspect generated routes
router.routes.foreach { route =>
  println(s"${route.path}")
}
// /com.example.api.UserService/getUser
// /com.example.api.UserService/createUser
// /com.example.api.UserService/listUsers

// Look up a specific route
val route = router.findRoute("getUser")
```

All routes use `POST` with the path format `/{serviceName}/{methodName}`, where `serviceName` is the trait's fully qualified name.

The type argument `T` decides both the route prefix and the endpoint set, so pass the service
trait. `RPCRouter.of(impl)` also compiles, but `T` is then inferred from the argument's static
type: given a `UserServiceImpl` value, the routes become `/com.example.api.UserServiceImpl/...`,
and a client built with `RPCClient.of[UserService]` gets `404 Not Found` for every call. Writing
`RPCRouter.of[UserService](impl)` keeps the two sides on the same path.

Every public method of `T` becomes an endpoint, including methods inherited from your own base
traits (`trait UserService extends CrudApi[User]` serves the `CrudApi` methods too). Methods
inherited from library traits (`scala.*`, `java.*`, uni's logging mixins such as `LogSupport`) are
never exposed. Annotate a method with `@rpcIgnore` to keep it out of the RPC endpoints:

```scala
trait UserService:
  def getUser(id: Long): User
  @rpcIgnore
  def cacheStats: String = "..." // callable in-process only
```

`RPCClient` applies the same rules, so client and server agree on the endpoint set.

### Request Format

Parameters are sent as JSON in the request body:

```json
{
  "request": {
    "id": 42
  }
}
```

Parameter names are matched flexibly — `userId`, `user_id`, and `user-id` all match the same parameter. Missing optional parameters (`Option[T]`) default to `None`, and parameters with default values use those defaults.

## Generating a Client

Instead of hand-writing request URLs and JSON, generate a **type-safe client**
from the same service trait with the `sbt-uni` plugin. The generated client
mirrors the trait's methods, so calls are checked by the compiler.

::: tip JVM build-time only
Client generation runs at build time on the JVM via sbt. The *generated* client
is ordinary code that compiles for any platform the trait supports.
:::

### Enable the plugin

In `project/plugins.sbt`:

```scala
addSbtPlugin("org.wvlet.uni" % "sbt-uni" % "2026.1.39")
```

In `build.sbt`, enable `UniPlugin` on the project that should contain the
generated client, depend on the project that defines the service trait, and list
your generation targets:

```scala
lazy val app = project
  .enablePlugins(UniPlugin)
  .dependsOn(api) // project containing UserService
  .settings(
    uniHttpClients := Seq("com.example.api.UserService:rpc")
  )
```

`uniHttpClients` is wired into `Compile / sourceGenerators`, so clients are
regenerated and compiled automatically on the next `compile`. Generation runs
in-process (no forked JVM) and skips files whose content is unchanged.

### Target spec format

Each entry is `"fullyQualifiedTrait:clientType[:targetPackage]"`:

| Part | Values | Notes |
|------|--------|-------|
| `clientType` | `sync`, `async`, `both`, `rpc` | `rpc` is an alias for `sync` |
| `targetPackage` | optional | defaults to the trait's package + `.client` |

```scala
uniHttpClients := Seq(
  "com.example.api.UserService:rpc",                       // SyncClient, default package
  "com.example.api.OrderService:both:com.example.client"   // Sync + Async clients
)
```

Override the output directory with `uniHttpCodegenOutDir` (defaults to
`Compile / sourceManaged`).

### Using the generated client

Generation produces an `object <ServiceName>Client` containing a `SyncClient`
and/or `AsyncClient`, each wrapping an [HTTP client](./client). Construct one
over a configured client pointed at the server's base URL:

```scala
import wvlet.uni.http.Http
import com.example.client.UserServiceClient

// Sync — methods return the declared types directly
val sync = UserServiceClient.SyncClient(
  Http.client.withBaseUri("http://localhost:8080").newSyncClient
)
val user: User = sync.getUser(42)

// Async — methods return Rx[...]
val async = UserServiceClient.AsyncClient(
  Http.client.withBaseUri("http://localhost:8080").newAsyncClient
)
async.getUser(42).map(user => println(user.name))
```

Under the hood the generated methods delegate to `RPCClient.of[UserService]`,
which serializes arguments and decodes responses with [Weaver](../core/weaver) —
the same wire format the [RPC router](#creating-an-rpc-router) expects, so client
and server stay in sync.

### Writing a client by hand

Without codegen, write the client with `RPCClient` directly. `RPCClient.of[T]`
holds the method codecs for `T`, and `newSyncClient(http)` or
`newAsyncClient(http)` binds them to an HTTP client. `call` takes the method name
followed by its arguments in declaration order:

```scala
import wvlet.uni.http.{Http, HttpSyncClient}
import wvlet.uni.http.rpc.RPCClient

class UserServiceClient(http: HttpSyncClient) extends UserService:
  private val rpc = RPCClient.of[UserService].newSyncClient(http)

  def getUser(id: Long): User                       = rpc.call("getUser", id)
  def createUser(name: String, email: String): User = rpc.call("createUser", name, email)
  def listUsers(limit: Int): Seq[User]              = rpc.call("listUsers", limit)

val client = UserServiceClient(Http.client.withBaseUri("http://localhost:8080").newSyncClient)
```

The async form returns `Rx[A]`:

```scala
val rpc = RPCClient
  .of[UserService]
  .newAsyncClient(Http.client.withBaseUri("http://localhost:8080").newAsyncClient)
rpc.call[User]("getUser", 42L).map(user => println(user.name))
```

An `RPCClient` has no connection state, so one instance can be shared by any
number of bound clients. The lower-level `callSync(http, name, args)` and
`callAsync(http, name, args)` take the HTTP client on every call.

## RPC Status Codes

`RPCStatus` provides structured error codes organized by category:

| Category | HTTP Status | Retryable | Description |
|----------|-------------|-----------|-------------|
| `SUCCESS` (S) | 2xx | — | Request completed successfully |
| `USER_ERROR` (U) | 4xx | No | Client error |
| `INTERNAL_ERROR` (I) | 5xx | Yes | Server error |
| `RESOURCE_EXHAUSTED` (R) | 429 | Yes (with backoff) | Rate/quota limits exceeded |

### Common Status Codes

```scala
import wvlet.uni.http.rpc.RPCStatus

// User errors (not retryable)
RPCStatus.INVALID_REQUEST_U1     // 400 - Malformed request
RPCStatus.INVALID_ARGUMENT_U2    // 400 - Bad parameter values
RPCStatus.NOT_FOUND_U5           // 404 - Resource not found
RPCStatus.ALREADY_EXISTS_U6      // 409 - Resource already exists
RPCStatus.UNAUTHENTICATED_U13    // 401 - Authentication required
RPCStatus.PERMISSION_DENIED_U14  // 403 - Forbidden

// Server errors (retryable)
RPCStatus.INTERNAL_ERROR_I0      // 500 - Generic server error
RPCStatus.UNAVAILABLE_I2         // 503 - Service unavailable
RPCStatus.TIMEOUT_I3             // 504 - Timeout

// Resource errors (retry with backoff)
RPCStatus.EXCEEDED_RATE_LIMIT_R2 // 429 - Rate limited
```

## Error Handling

### Throwing RPC Errors

```scala
import wvlet.uni.http.rpc.{RPCStatus, RPCException}

class UserServiceImpl extends UserService:
  def getUser(id: Long): User =
    if id <= 0 then
      throw RPCStatus.INVALID_ARGUMENT_U2
        .newException(s"Invalid user id: ${id}")
    // ...
```

### RPCException

`RPCException` carries a status code and serializes to JSON or MessagePack for wire transport:

```scala
val ex = RPCStatus.NOT_FOUND_U5.newException("User not found")

// Serialize
val json: String = ex.toJson
val response = ex.toResponse  // HTTP response with status header

// Deserialize
val recovered = RPCException.fromJson(json)
val fromResp  = RPCException.fromResponse(response)

// Application-specific error codes
val appEx = RPCStatus.USER_ERROR_U0
  .newException("Insufficient balance", appErrorCode = Some(1001))
```

### Stack Traces

Error responses carry no stack trace by default, on every platform and transport. Stack frames reveal internal class names, file names, and line numbers, so a client sees only the status, message, and `appErrorCode`. The server still logs unexpected exceptions with their stack traces.

For development, create the dispatcher with `withDetailedErrors(true)` (`RPCHandler(...).withDetailedErrors(true)` on Netty) to add the server-side stack trace as a `stackTrace` array of frames (`className`, `methodName`, `fileName`, `lineNumber`), the same shape as Airframe RPC:

```scala
val dispatcher = RPCDispatcher(router).withDetailedErrors(true) // development only
```

Electron IPC (`ElectronRPCServer`) has no such switch and never sends stack traces.

In detailed mode `UNAUTHENTICATED_U13` and `PERMISSION_DENIED_U14` errors still omit stack traces, and so does an exception marked with `noStackTrace`:

```scala
throw RPCStatus.INVALID_ARGUMENT_U2.newException("Invalid input").noStackTrace
```

Unexpected (non-`RPCException`) exceptions from a handler are wrapped as `INTERNAL_ERROR_I0` with no message or stack trace, unless detailed errors are enabled.

To always send the stack trace of one specific error, construct it with `RPCException(status, message, includeStackTrace = Some(true))`. Do this only when every client may see server internals.

On the client, `RPCException.fromJson`, `fromMsgPack`, and `fromResponse` restore the frames when the body has a `stackTrace` field, so responses from older Uni servers and Airframe RPC servers still decode.

## Best Practices

1. **No method overloading** in RPC traits — the router rejects overloaded method names
2. **Use specific status codes** (`NOT_FOUND_U5`) over generic ones (`USER_ERROR_U0`)
3. **Keep service traits in a shared module** so clients can reference the same interface
4. **Use `Rx[T]` return types** for async operations to avoid blocking server threads
5. **Use default parameter values** to maintain backward compatibility when adding new parameters
