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 /UserService/getUser, POST /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}")
}
// /UserService/getUser
// /UserService/createUser
// /UserService/listUsers

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

All routes use POST with the path format /{serviceName}/{methodName}.

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.6")

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, which serializes arguments and decodes responses with Weaver — the same wire format the RPC router expects, so client and server stay in sync.

RPC Status Codes

RPCStatus provides structured error codes organized by category:

CategoryHTTP StatusRetryableDescription
SUCCESS (S)2xxRequest 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))

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.