Skip to content

HTTP Client

uni provides both synchronous and asynchronous HTTP clients.

Creating Clients

Sync Client

scala
import wvlet.uni.http.Http

val client = Http.client.newSyncClient

Async Client

scala
val asyncClient = Http.client.newAsyncClient

Platform 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.

PlatformSync (newSyncClient)Async (newAsyncClient)Underlying transport
JVMJavaHttpChannelJavaHttpAsyncChanneljava.net.http.HttpClient (Java 11+)
Scala.jsNodeSyncHttpChannel (Node.js only)FetchChannelFetch API (browser + Node), Node worker_threads for sync
Scala NativeCurlChannelCurlAsyncChannellibcurl

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, newSyncClient works only under Node.js (it uses worker_threads + Atomics.wait on a SharedArrayBuffer). In a browser, calling it throws NotImplementedError — use newAsyncClient, which returns Rx[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:

scala
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

bash
# The -dev package is what provides the unversioned libcurl.so symlink
# that `-lcurl` resolves against.
sudo apt-get install -y libcurl4-openssl-dev
bash
# libcurl ships with the system; nothing to install.
# For a newer version than Apple's:
brew install curl
powershell
vcpkg 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

scala
import wvlet.uni.http.Request

val response = client.send(Request.get("https://api.example.com/users"))
println(response.status)           // HttpStatus
println(response.contentAsString)  // Response body

POST Request

scala
val request = Request
  .post("https://api.example.com/users")
  .withJsonContent("""{"name": "Alice"}""")

val response = client.send(request)

With Headers

scala
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.

scala
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:

scala
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:

scala
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:

scala
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 type

HttpMultiMap 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:

scala
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

scala
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

scala
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:

scala
asyncClient
  .sendStreaming(Request.get("https://example.com/large-file"))
  .subscribe { chunk: Array[Byte] =>
    processChunk(chunk)
  }

Client Configuration

scala
val client = Http.client
  .withConnectTimeoutMillis(5000)   // 5 seconds
  .withReadTimeoutMillis(30000)     // 30 seconds
  .withMaxRetry(3)
  .newSyncClient

Disabling Retry

scala
// Create a client with no retries
val clientNoRetry = Http.client.noRetry.newSyncClient

// Or configure zero retries
val clientNoRetry2 = Http.client.withMaxRetry(0).newSyncClient

Error Handling

scala
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

  1. Reuse clients - Create once, use many times
  2. Close clients - Call close() when done
  3. Set timeouts - Prevent hanging requests
  4. Handle errors - Check status codes
  5. Use async - For non-blocking operations

Example: REST Client

scala
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()

Released under the Apache 2.0 License.