HTTP Client
An effect-based HTTP client built on YAES effects and Java’s java.net.http.HttpClient. The yaes-http-client module provides a lightweight, composable HTTP client that integrates seamlessly with the YAES effect system for structured error handling, resource lifecycle management, and typed body encoding/decoding.
Key Features:
- Java HttpClient backend - Built on
java.net.http.HttpClientwith virtual thread support - Effect integration - Uses
Sync,Raise, andResourceeffects for structured error handling and lifecycle management - Typed error hierarchy - Separate
ConnectionError(transport) andHttpError(HTTP status) error types - Fluent builder API - Immutable request construction with
header,queryParam, andtimeoutextension methods - Body codecs - Automatic request/response body encoding and decoding via the
BodyCodectypeclass - URI validation - Opaque
Uritype with construction-time validation via theRaiseeffect
Requirements:
- Java 25+ (for Virtual Threads and Structured Concurrency)
- Scala 3.8.1+
- yaes-core (included transitively)
Installation
Section titled “Installation”Add yaes-http-client to your project dependencies:
libraryDependencies += "in.rcard.yaes" %% "yaes-http-client" % "0.16.0"Check Maven Central for the latest version.
Quick Start
Section titled “Quick Start”Here’s a minimal HTTP client that sends a GET request and prints the response:
import in.rcard.yaes.*import in.rcard.yaes.http.client.*import scala.concurrent.duration.*
Sync.runBlocking(30.seconds) { Raise.run[ConnectionError] { Resource.run { val client = YaesClient.make()
Raise.run[Uri.InvalidUri] { val uri = Uri("https://httpbin.org/get") val response = client.send(HttpRequest.get(uri)) println(s"Status: ${response.status}") println(s"Body: ${response.body}") } } }}Required Effects:
Sync- Required byYaesClient.send; useSync.runBlocking(or aYaesAppstack) as the outermost handlerResource- Manages the lifecycle of the underlying JavaHttpClient(auto-closed on block exit)Raise[ConnectionError]- Handles transport-level errors (connection refused, timeouts)
Creating a Client
Section titled “Creating a Client”Use YaesClient.make inside a Resource.run block. The underlying Java HttpClient is automatically closed when the Resource block completes:
Resource.run { // Default configuration val client = YaesClient.make()
// Custom configuration val customClient = YaesClient.make(YaesClientConfig( connectTimeout = Some(5.seconds), followRedirects = RedirectPolicy.Never, httpVersion = HttpVersion.Http2 ))
// Use clients here...}Client Configuration
Section titled “Client Configuration”The YaesClientConfig case class controls client-level settings:
case class YaesClientConfig( connectTimeout: Option[Duration] = None, followRedirects: RedirectPolicy = RedirectPolicy.Normal, httpVersion: HttpVersion = HttpVersion.Http11)Configuration options:
| Option | Type | Default | Description |
|---|---|---|---|
connectTimeout | Option[Duration] | None | Maximum time to establish a TCP connection |
followRedirects | RedirectPolicy | Normal | Redirect-following policy |
httpVersion | HttpVersion | Http11 | HTTP protocol version |
Redirect policies:
| Policy | Description |
|---|---|
RedirectPolicy.Never | Never follow redirects |
RedirectPolicy.Normal | Follow redirects except cross-protocol downgrades (HTTPS → HTTP) |
RedirectPolicy.Always | Always follow redirects, including cross-protocol |
HTTP versions:
| Version | Description |
|---|---|
HttpVersion.Http11 | HTTP/1.1 |
HttpVersion.Http2 | HTTP/2 |
Note: Infinite or undefined connect timeouts are silently ignored (treated as “no timeout”).
Building Requests
Section titled “Building Requests”Factory Methods
Section titled “Factory Methods”Create requests using the HttpRequest companion object:
Raise.run[Uri.InvalidUri] { val uri = Uri("https://api.example.com/users")
// Requests without a body val get = HttpRequest.get(uri) val head = HttpRequest.head(uri) val delete = HttpRequest.delete(uri) val options = HttpRequest.options(uri)
// Requests with a body (requires a BodyCodec in scope) val post = HttpRequest.post(uri, """{"name": "Alice"}""") val put = HttpRequest.put(uri, """{"name": "Bob"}""") val patch = HttpRequest.patch(uri, """{"name": "Charlie"}""")}Methods with a body (post, put, patch) require a BodyCodec[A] in scope. The codec determines the Content-Type header and encodes the value to a string.
Fluent Builder API
Section titled “Fluent Builder API”Use extension methods to customize requests after creation:
Raise.run[Uri.InvalidUri] { val uri = Uri("https://api.example.com/users")
val request = HttpRequest.get(uri) .header("Authorization", "Bearer my-token") .header("Accept", "application/json") .queryParam("page", "1") .queryParam("limit", "10") .timeout(30.seconds)}Available extension methods:
| Method | Description |
|---|---|
header(name, value) | Adds or replaces a header (keys are lowercased for consistency) |
queryParam(name, value) | Appends a query parameter (duplicate keys are allowed) |
timeout(duration) | Sets the per-request timeout (infinite durations are ignored) |
All builder methods return a new HttpRequest — the original is not modified.
Header behavior: The
headermethod can override headers set byBodyCodec(e.g.,Content-Type). Header keys are always stored in lowercase.
Query parameter encoding: Query parameters are URL-encoded when appended to the URI at send time.
Sending Requests
Section titled “Sending Requests”Use client.send(request) to execute a request. The method requires Sync and Raise[ConnectionError] effects:
Raise.run[ConnectionError] { Resource.run { val client = YaesClient.make()
Raise.run[Uri.InvalidUri] { val uri = Uri("https://httpbin.org/post") val request = HttpRequest.post(uri, "hello") .header("Accept", "application/json")
val response: HttpResponse = client.send(request) println(s"Status: ${response.status}") println(s"Content-Type: ${response.header("content-type")}") println(s"Body: ${response.body}") } }}Important: send returns the response regardless of status code — it never raises HttpError. Non-2xx responses are only raised when you call response.as[A] to decode the body.
Reading Responses
Section titled “Reading Responses”Raw Response
Section titled “Raw Response”The HttpResponse contains the raw status code, headers, and body:
case class HttpResponse( status: Int, headers: Map[String, String], body: String)Accessing response data:
val response = client.send(request)
response.status // Int (e.g. 200, 404)response.body // String (raw body)response.header("content-type") // Option[String] (case-insensitive lookup)Header handling: All response header keys are stored in lowercase. The header method performs a case-insensitive lookup, so response.header("Content-Type") and response.header("content-type") return the same value.
Typed Decoding
Section titled “Typed Decoding”Use response.as[A] to decode the body into a typed value. This method:
- Checks the status code — raises
HttpErrorfor non-2xx - Decodes the body — raises
DecodingErrorif decoding fails
Raise.run[HttpError | DecodingError] { val body: String = response.as[String]}The union type HttpError | DecodingError makes both error types explicit in the effect signature.
Error Handling
Section titled “Error Handling”The client separates errors into two layers, keeping transport concerns separate from HTTP semantics.
Transport Errors (ConnectionError)
Section titled “Transport Errors (ConnectionError)”Raised by client.send when the request cannot be delivered at the network level:
| Error | Description |
|---|---|
ConnectionRefused(host, port) | TCP connection refused by the target host |
ConnectTimeout(host) | Connection could not be established within the configured timeout |
RequestTimeout(url) | Server accepted the connection but did not respond within the per-request timeout |
Unexpected(cause) | Any other exception during the HTTP exchange |
Handling transport errors:
val result = Raise.either[ConnectionError, HttpResponse] { client.send(request)}result match case Left(ConnectionError.ConnectionRefused(host, port)) => println(s"Cannot connect to $host:$port") case Left(ConnectionError.ConnectTimeout(host)) => println(s"Connection to $host timed out") case Left(ConnectionError.RequestTimeout(url)) => println(s"Request to $url timed out") case Left(ConnectionError.Unexpected(cause)) => println(s"Unexpected error: ${cause.getMessage}") case Right(response) => println(s"Got response: ${response.status}")HTTP Errors (HttpError)
Section titled “HTTP Errors (HttpError)”Raised by response.as[A] when the status code is outside the 2xx range. The error hierarchy distinguishes client errors (4xx) from server errors (5xx) via marker traits.
Client errors (4xx) — ClientHttpError:
| Error | Status |
|---|---|
BadRequest | 400 |
Unauthorized | 401 |
Forbidden | 403 |
NotFound | 404 |
MethodNotAllowed | 405 |
Conflict | 409 |
Gone | 410 |
UnprocessableEntity | 422 |
TooManyRequests | 429 |
OtherClientError(status, body) | Other 4xx |
Server errors (5xx) — ServerHttpError:
| Error | Status |
|---|---|
InternalServerError | 500 |
BadGateway | 502 |
ServiceUnavailable | 503 |
GatewayTimeout | 504 |
OtherServerError(status, body) | Other 5xx |
Other status codes:
| Error | Description |
|---|---|
UnexpectedStatus(status, body) | Status codes outside 4xx and 5xx (e.g., 1xx, 3xx) |
Matching by error category:
val result = Raise.either[HttpError | DecodingError, String] { response.as[String]}result match case Left(e: ClientHttpError) => println(s"Client error ${e.status}: ${e.body}") case Left(e: ServerHttpError) => println(s"Server error ${e.status}: ${e.body}") case Left(e: DecodingError) => println(s"Decoding failed: ${e.message}") case Right(value) => println(s"Success: $value")URI Validation
Section titled “URI Validation”The Uri opaque type wraps java.net.URI and validates syntax at construction time via the Raise effect:
Raise.run[Uri.InvalidUri] { val valid = Uri("https://example.com/api") // succeeds val invalid = Uri("not a valid uri :::") // raises InvalidUri}Handling invalid URIs:
val result = Raise.either[Uri.InvalidUri, Uri] { Uri("https://example.com")}result match case Left(Uri.InvalidUri(input, reason)) => println(s"Invalid URI '$input': $reason") case Right(uri) => println(s"Host: ${uri.host}, Port: ${uri.port}")URI extension methods:
| Method | Return Type | Description |
|---|---|---|
value | String | The URI as a string |
host | Option[String] | The host component |
port | Int | The port (defaults to 80 if unspecified) |
toJavaURI | java.net.URI | The underlying Java URI |
Body Codecs
Section titled “Body Codecs”The client uses the BodyCodec[A] typeclass for encoding request bodies (in post, put, patch) and decoding response bodies (in as[A]). Built-in codecs exist for String, Int, Long, Double, and Boolean.
JSON with Circe
Section titled “JSON with Circe”For JSON support, add the yaes-http-circe module which provides automatic BodyCodec instances for types with Circe Encoder and Decoder:
libraryDependencies += "in.rcard.yaes" %% "yaes-http-circe" % "0.16.0"import in.rcard.yaes.http.circe.givenimport io.circe.{Encoder, Decoder}
case class User(id: Int, name: String) derives Encoder.AsObject, Decoder
Raise.run[Uri.InvalidUri] { val uri = Uri("https://api.example.com/users")
// Content-Type: application/json set automatically by the circe codec val request = HttpRequest.post(uri, User(1, "Alice"))}See JSON with Circe for full documentation on JSON body codecs.
Complete Example
Section titled “Complete Example”A full client example demonstrating configuration, request building, sending, and error handling:
import in.rcard.yaes.*import in.rcard.yaes.http.client.*import scala.concurrent.duration.*
Raise.run[ConnectionError] { Resource.run { // Create a configured client val client = YaesClient.make(YaesClientConfig( connectTimeout = Some(10.seconds), followRedirects = RedirectPolicy.Normal ))
Raise.run[Uri.InvalidUri] { // Build and send a request val uri = Uri("https://httpbin.org/get") val request = HttpRequest.get(uri) .header("Accept", "application/json") .queryParam("name", "Alice") .timeout(30.seconds)
val response = client.send(request)
// Decode the response with error handling val result = Raise.either[HttpError | DecodingError, String] { response.as[String] } result match case Left(e: HttpError) => println(s"HTTP error ${e.status}") case Left(e: DecodingError) => println(s"Decoding failed") case Right(body) => println(s"Response: $body") } }}Testing
Section titled “Testing”# Run all client testssbt "client/test"
# Run a specific test suitesbt "client/testOnly in.rcard.yaes.http.client.HttpRequestSpec"
# Run a specific testsbt "client/testOnly in.rcard.yaes.http.client.YaesClientSendSpec -- -z \"send GET\""