RPC
Trait-based RPC framework with automatic route generation, type-safe serialization, and structured error handling.
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/createUserDefining an RPC Service
Define your service API as a Scala trait, then provide an implementation:
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:
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:
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:
{
"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:
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:
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 |
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:
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:
| 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
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 limitedError Handling
Throwing RPC Errors
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:
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
- No method overloading in RPC traits — the router rejects overloaded method names
- Use specific status codes (
NOT_FOUND_U5) over generic ones (USER_ERROR_U0) - Keep service traits in a shared module so clients can reference the same interface
- Use
Rx[T]return types for async operations to avoid blocking server threads - Use default parameter values to maintain backward compatibility when adding new parameters
