HTTP Client
uni provides both synchronous and asynchronous HTTP clients.
Creating Clients
Sync Client
import wvlet.uni.http.Http
val client = Http.client.newSyncClientAsync Client
val asyncClient = Http.client.newAsyncClientPlatform Backends
The same Http.client API runs on all three Scala platforms. uni selects the right transport for the runtime automatically — you write the request once and it works on JVM, Scala.js, and Scala Native.
| Platform | Sync (newSyncClient) | Async (newAsyncClient) | Underlying transport |
|---|---|---|---|
| JVM | JavaHttpChannel | JavaHttpAsyncChannel | java.net.http.HttpClient (Java 11+) |
| Scala.js | NodeSyncHttpChannel (Node.js only) | FetchChannel | Fetch API (browser + Node), Node worker_threads for sync |
| Scala Native | CurlChannel | CurlAsyncChannel | libcurl |
Both client types share the same send / sendStreaming interface, so most code is platform-agnostic. A few platform constraints are worth knowing:
- Browsers have no synchronous HTTP. On Scala.js,
newSyncClientworks only under Node.js (it usesworker_threads+Atomics.waiton aSharedArrayBuffer). In a browser, calling it throwsNotImplementedError— usenewAsyncClient, which returnsRx[HttpResponse], instead. For the background on the Node sync implementation, see the ADR. - Scala Native requires libcurl, both to link and at runtime; the factory initializes it globally the first time a client is created. See Linking libcurl on Scala Native below.
- The async client is the cross-platform common denominator. If you target the browser, prefer the async API everywhere so the same code compiles for every platform.
To plug in a custom transport (testing, a different HTTP library), set a channel factory globally:
import wvlet.uni.http.Http
Http.setDefaultChannelFactory(myChannelFactory)Linking libcurl on Scala Native
You need libcurl only if your binary actually uses the HTTP client. The @link("curl") annotation that pulls libcurl in rides on CurlBindings, which Scala Native's dead-code elimination drops when nothing reaches it. A Native project that only uses, say, LogSupport links with no libcurl installed at all.
When you do use the client, install libcurl as a shared library and Scala Native adds -lcurl for you — no nativeConfig entry needed.
libcurl must be dynamically linked
uni looks curl_easy_setopt and curl_easy_getinfo up at runtime, among the modules already loaded into the process. A statically linked libcurl (libcurl.a, or vcpkg's *-windows-static triplets) exports no symbols to find, so the binary links but aborts on its first request with uni_curl_shim: libcurl symbol 'curl_easy_setopt' not found in this process. Use the shared library.
Why the lookup happens at runtime rather than through an ordinary extern is the subject of this ADR — in short, it is what lets curl-free Native projects link at all.
Installing libcurl
# The -dev package is what provides the unversioned libcurl.so symlink
# that `-lcurl` resolves against.
sudo apt-get install -y libcurl4-openssl-dev# libcurl ships with the system; nothing to install.
# For a newer version than Apple's:
brew install curlvcpkg install curl:x64-windows # or arm64-windows
# Scala Native turns @link("curl") into `curl.lib`, but vcpkg installs the
# import library as `libcurl.lib`, so give the linker the name it looks for.
$root = "$env:VCPKG_INSTALLATION_ROOT\installed\x64-windows"
Copy-Item "$root\lib\libcurl.lib" "$root\lib\curl.lib" -Force
# `lib` is where the linker searches; `bin` is where the built binary finds
# libcurl.dll at runtime.
$env:LIB = "$root\lib;$env:LIB"
$env:PATH = "$root\bin;$env:PATH"Alpine and other musl images need curl-dev. Distroless or scratch runtime images must still carry libcurl.so and its TLS dependencies, since the lookup resolves against the loaded shared library — statically linking your Scala Native binary does not fold libcurl into it.
Making Requests
GET Request
import wvlet.uni.http.Request
val response = client.send(Request.get("https://api.example.com/users"))
println(response.status) // HttpStatus
println(response.contentAsString) // Response bodyPOST Request
val request = Request
.post("https://api.example.com/users")
.withJsonContent("""{"name": "Alice"}""")
val response = client.send(request)With Headers
val request = Request
.get("https://api.example.com/data")
.withBearerToken("token123")
.withAccept("application/json")
val response = client.send(request)Multipart Upload (multipart/form-data)
Use withMultipart to upload files alongside form fields in a single request. The boundary and Content-Type: multipart/form-data; boundary=... header are generated automatically.
import wvlet.uni.http.{Request, MultipartPart, ContentType}
val avatarBytes: Array[Byte] = readBytes("avatar.png")
val request = Request
.post("https://api.example.com/upload")
.withMultipart(Seq(
MultipartPart.field("name", "alice"),
MultipartPart.file("avatar", "avatar.png", avatarBytes, ContentType.ImagePng)
))
val response = client.send(request)MultipartPart.file defaults to application/octet-stream when no content type is given. MultipartPart.field is a plain form value serialized as UTF-8. To add custom per-part headers, construct the case classes directly:
val request = Request
.post("https://api.example.com/upload")
.withMultipart(Seq(
MultipartPart.FilePart(
name = "report",
filename = "q1.pdf",
bytes = pdfBytes,
contentType = ContentType.ApplicationPdf,
headers = HttpMultiMap("X-Checksum-Sha256" -> checksum)
)
))For a fixed boundary (for example in snapshot tests), use the builder:
import wvlet.uni.http.Multipart
val mp = Multipart
.builder()
.withBoundary("----fixed-boundary")
.addField("name", "alice")
.addFile("avatar", "avatar.png", avatarBytes, ContentType.ImagePng)
.build()
val request = Request.post("/upload").withMultipartContent(mp)Scope note: multipart/form-data is intended for bundling form fields and small-to-moderate files (avatars, PDFs, a few MB at most) in a single request. All parts are held in memory. Large-file or resumable uploads (S3 multipart, tus.io) use separate protocols and are out of scope for this API.
Content Types and Headers
The examples above use two small helper types.
ContentType is an application/...-style media type. It provides named constants for the common ones, so you avoid typo-prone string literals:
import wvlet.uni.http.ContentType
ContentType.ApplicationJson // "application/json"
ContentType.ApplicationPdf // "application/pdf"
ContentType.ApplicationOctetStream
ContentType.ImagePng // "image/png"
ContentType.TextPlain
ContentType.TextEventStream // "text/event-stream" (SSE)
ContentType("application/vnd.api+json") // any custom typeHttpMultiMap is the case-insensitive multi-valued map used for headers and form fields (a header can legitimately appear more than once). Build one from pairs, and add to it without mutating the original:
import wvlet.uni.http.HttpMultiMap
val headers = HttpMultiMap("Accept" -> "application/json")
val more = headers + ("X-Request-Id" -> "abc123")You rarely construct an HttpMultiMap directly — request builders like withBearerToken and withAccept manage headers for you — but it's the type you reach for when adding custom per-part headers to a multipart upload.
Response Handling
val response = client.send(request)
// Status
response.status.isSuccess // true for 2xx
response.status.code // 200, 404, etc.
// Headers
response.header("Content-Type") // Option[String]
// Body
response.contentAsString // String
response.contentAsBytes // Array[Byte]Async Requests
val asyncClient = Http.client.newAsyncClient
asyncClient
.send(Request.get("https://api.example.com/data"))
.map { response =>
response.contentAsString
}
.subscribe { content =>
println(content)
}Streaming Responses
Stream large responses as byte chunks:
asyncClient
.sendStreaming(Request.get("https://example.com/large-file"))
.subscribe { chunk: Array[Byte] =>
processChunk(chunk)
}Client Configuration
val client = Http.client
.withConnectTimeoutMillis(5000) // 5 seconds
.withReadTimeoutMillis(30000) // 30 seconds
.withMaxRetry(3)
.newSyncClientDisabling Retry
// Create a client with no retries
val clientNoRetry = Http.client.noRetry.newSyncClient
// Or configure zero retries
val clientNoRetry2 = Http.client.withMaxRetry(0).newSyncClientError Handling
import wvlet.uni.http.HttpException
try
val response = client.send(request)
if !response.status.isSuccess then
throw HttpException(response.status, response.contentAsString)
catch
case e: HttpException =>
logger.error(s"HTTP error: ${e.status}")
case e: IOException =>
logger.error("Network error", e)Best Practices
- Reuse clients - Create once, use many times
- Close clients - Call
close()when done - Set timeouts - Prevent hanging requests
- Handle errors - Check status codes
- Use async - For non-blocking operations
Example: REST Client
class ApiClient(baseUrl: String):
private val client = Http.client.newSyncClient
def getUser(id: String): User =
val response = client.send(
Request.get(s"${baseUrl}/users/${id}")
)
if response.status.isSuccess then
Weaver.fromJson[User](response.contentAsString)
else
throw HttpException(response.status)
def createUser(user: User): User =
val response = client.send(
Request
.post(s"${baseUrl}/users")
.withJsonContent(Weaver.toJson(user))
)
Weaver.fromJson[User](response.contentAsString)
def close(): Unit = client.close()