Skip to content

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.

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]":

PartValuesNotes
clientTypesync, async, both, rpcrpc is an alias for sync
targetPackageoptionaldefaults 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. 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 — the same wire format the 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:

CategoryHTTP StatusRetryableDescription
SUCCESS (S)2xx—Request completed successfully
USER_ERROR (U)4xxNoClient error
INTERNAL_ERROR (I)5xxYesServer error
RESOURCE_EXHAUSTED (R)429Yes (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

Released under the Apache 2.0 License.