Client
ZClient
is an HTTP client that enables us to make HTTP requests and handle responses in a purely functional manner. ZClient leverages the ZIO library's capabilities to provide a high-performance, asynchronous, and type-safe HTTP client solution.
Key Features
Purely Functional: ZClient is built on top of the ZIO library, enabling a purely functional approach to handling HTTP requests and responses. This ensures referential transparency and composability, making it easy to build and reason about complex HTTP interactions.
Type-Safe: ZClient's API is designed to be type-safe, leveraging Scala's type system to catch errors at compile time and provide a seamless development experience. This helps prevent common runtime errors and enables developers to write robust and reliable HTTP client code.
Asynchronous & Non-blocking: ZClient is fully asynchronous and non-blocking, allowing us to perform multiple HTTP requests concurrently without blocking threads. This ensures optimal resource utilization and scalability, making it suitable for high-performance applications.
Middleware Support: ZClient provides support for middleware, allowing us to customize and extend its behavior to suit our specific requirements. We can easily plug in middleware to add functionalities such as logging, debugging, caching, and more.
Flexible Configuration: ZClient offers flexible configuration options, allowing us to fine-tune its behavior according to our needs. We can configure settings such as SSL, proxy, connection pooling, timeouts, and more to optimize the client's performance and behavior.
WebSocket Support: In addition to traditional HTTP requests, ZClient also supports WebSocket communication, enabling bidirectional, full-duplex communication between client and server over a single, long-lived connection.
SSL Support: ZClient provides built-in support for SSL (Secure Sockets Layer) connections, allowing secure communication over the network. Users can configure SSL settings such as certificates, trust stores, and encryption protocols to ensure data confidentiality and integrity.
Making HTTP Requests
We can think of a ZClient
as a function that takes a Request
and returns a ZIO
effect that calls the server with the given request and returns the response that the server sends back.
Requests can be executed in 2 modes:
batched
: The entire body of the request is materialized in memory, and the connection lifecycle is managed automatically by the client.streaming
: The body of the request might be streaming, and the connection lifecycle is managed through theScope
in the effect's environment.
The Client
's companion object contains methods that reflect the 2 modes of request execution:
object Client {
def batched(request: Request): ZIO[Client, Throwable, Response] = ???
def streaming(request: Request): ZIO[Client & Scope, Throwable, Response] = ???
}
"Streaming" Client
The streaming
mode is the default mode for executing HTTP requests. It requires the Client
and Scope
environments to perform the request and handle the response. The Client
environment is used to make the request, while the Scope
environment is used to manage the lifecycle of resources such as connections, sockets, and other I/O-related resources that are acquired and released during the request-response operation.
When making a request in the streaming
mode, we need to explicitly close the Scope
once we've collected the response body:
import zio._
import zio.http._
// OK
val good =
ZIO.scoped {
Client
.streaming(Request.get("http://jsonplaceholder.typicode.com/todos"))
.flatMap(_.body.asString)
}.flatMap(???)
// BAD: The server might be streaming the response body, and we've forcefully closed the connection before it finishes
val bad1 =
ZIO.scoped {
Client
.streaming(Request.get("http://jsonplaceholder.typicode.com/todos"))
.map(_.headers)
}
.flatMap(???)
// BAD: We're closing the scope before collecting the response body
val bad2 =
ZIO.scoped {
Client
.streaming(Request.get("http://jsonplaceholder.typicode.com/todos"))
}
.flatMap(_.body.asString)
.flatMap(???)
// VERY BAD: The connection will not be closed until the application exits, which will lead to resource leaks!
val bad3 =
Client
.streaming(Request.get("http://jsonplaceholder.typicode.com/todos"))
.flatMap(_.body.asString)
.flatMap(???)
.provideSomeLayer[Client](Scope.default)
As a rule of thumb, you should never use Scope.default
with Client!
To learn more about resource management and Scope
in ZIO, refer to the dedicated guide on this topic in the ZIO Core documentation.
"Batched" Client
Handling of Scope
can quickly become cumbersome in cases where we simply want to execute an HTTP request and not handle the lifetime of the HTTP request.
The batched
mode is simply a sub-implementation of the streaming
mode where the Scope
(i.e., connection lifecycle) is managed automatically.
Executing a request via the batched
method can be done as simply as:
import zio._
import zio.http._
val good =
Client
.batched(Request.get("http://jsonplaceholder.typicode.com/todos"))
.flatMap(_.body.asString)
.flatMap(???)
::: warning
The batched
methods will materialize the entire body of the request to memory.
Use this only when you don't need to stream the request body!
:::
We can similarly use the batched
method on an instance of Client
to return a new instance where all the methods will be executed in the batched
mode. Below is a realistic example showcasing the usage of the batched
client:
import zio._
import zio.http._
import zio.schema.DeriveSchema
import zio.schema.codec.JsonCodec.schemaBasedBinaryCodec
case class Todo(
userId: Int,
id: Int,
title: String,
completed: Boolean,
)
object Todo {
implicit val todoSchema = DeriveSchema.gen[Todo]
}
final class JsonPlaceHolderService(baseClient: Client) {
private val client = baseClient.batched
def todos(): ZIO[Any, Throwable, List[Todo]] =
client
.request(Request.get("http://jsonplaceholder.typicode.com/todos"))
.flatMap(_.body.to[List[Todo]])
}
ZIO HTTP has several utility methods to create different types of requests, such as Client#get
, Client#post
, Client#put
, Client#delete
, etc:
Method | Description |
---|---|
def get(suffix: String) | Performs a GET request with the given path suffix. |
def head(suffix: String) | Performs a HEAD request with the given path suffix. |
def patch(suffix: String) | Performs a PATCH request with the given path suffix. |
def post(suffix: String)(body: In) | Performs a POST request with the given path suffix and provided body. |
def put(suffix: String)(body: In) | Performs a PUT request with the given path suffix and provided body. |
def delete(suffix: String) | Performs a DELETE request with the given path suffix. |
Performing WebSocket Connections
We can also think of a client as a function that takes a WebSocketApp
and returns a ZIO
effect that performs the WebSocket operations and returns a response:
object ZClient {
def socket[R](socketApp: WebSocketApp[R]): ZIO[R with Client & Scope, Throwable, Response] = ???
}
The socket
method is not available on the "Batched" client!
Here is a simple example of how to use the ZClient#socket
method to perform a WebSocket connection:
import zio._
import zio.http._
import zio.http.ChannelEvent._
object WebSocketSimpleClient extends ZIOAppDefault {
val url = "ws://ws.vi-server.org/mirror"
val socketApp: WebSocketApp[Any] =
Handler
// Listen for all websocket channel events
.webSocket { channel =>
channel.receiveAll {
// Send a "foo" message to the server once the connection is established
case UserEventTriggered(UserEvent.HandshakeComplete) =>
channel.send(Read(WebSocketFrame.text("foo"))) *>
ZIO.debug("Connection established and the foo message sent to the server")
// Send a "bar" if the server sends a "foo"
case Read(WebSocketFrame.Text("foo")) =>
channel.send(Read(WebSocketFrame.text("bar"))) *>
ZIO.debug("Received the foo message from the server and the bar message sent to the server")
// Close the connection if the server sends a "bar"
case Read(WebSocketFrame.Text("bar")) =>
ZIO.debug("Received the bar message from the server and Goodbye!") *>
channel.send(Read(WebSocketFrame.close(1000)))
case _ =>
ZIO.unit
}
}
val app: ZIO[Client, Throwable, Unit] =
for {
url <- ZIO.fromEither(URL.decode("ws://ws.vi-server.org/mirror"))
client <- ZIO.serviceWith[Client](_.url(url))
_ <- ZIO.scoped(client.socket(socketApp) *> ZIO.never)
} yield ()
val run: ZIO[Any, Throwable, Any] =
app.provide(Client.default)
}
In the above example, we defined a WebSocket client that connects to a mirror server and sends and receives messages. When the connection is established, it receives the UserEvent.HandshakeComplete
event and then it sends a "foo" message to the server. Consequently, the server sends a "foo" message, and the client responds with a "bar" message. Finally, the server sends a "bar" message, and the client closes the connection.
Configuring Headers
By default, the client adds the User-Agent
header to all requests. Additionally, as the ZClient
extends the HeaderOps
trait, we have access to all operations that can be performed on headers inside the client.
For example, to add a custom header we can use the Client#addHeader
method:
import zio._
import zio.http._
import zio.http.Header.Authorization
val program = for {
client <- ZIO.serviceWith[Client](_.addHeader(Authorization.Bearer(token = "dummyBearerToken")))
res <- client.request(Request.get("http://localhost:8080/users"))
} yield ()
To learn more about headers and how they work, check out our dedicated section called Header Operations on the headers page.
Composable URLs
In ZIO HTTP, URLs are composable. This means that if we have two URLs, we can combine them to create a new URL. This is useful when we want to prevent duplication of the base URL in our code. For example, assume we have a base URL http://localhost:8080
and we want to make several requests to different endpoints and query parameters under this base URL. We can configure the client with this URL using the Client#url
and then every request will be made can be relative to this base URL:
import zio._
import zio.http._
import zio.schema.DeriveSchema
import zio.schema.codec.JsonCodec.schemaBasedBinaryCodec
case class User(name: String, age: Int)
object User {
implicit val schema = DeriveSchema.gen[User]
}
val program: ZIO[Client, Throwable, Unit] =
for {
client <- ZIO.serviceWith[Client](_.url(url"http://localhost:8080").batched)
_ <- client.post("/users")(Body.from(User("John", 42)))
res <- client.get("/users")
_ <- client.delete("/users/1")
_ <- res.body.asString.debug
} yield ()
The following methods are available for setting the base URL:
Method Signature | Description |
---|---|
Client#url(url: URL) | Sets the URL directly. |
Client#uri(uri: URI) | Sets the URL from the provided URI. |
Client#path(path: String) | Sets the path of the URL from a string. |
Client#path(path: Path) | Sets the path of the URL from a Path object. |
Client#port(port: Int) | Sets the port of the URL. |
Client#scheme(scheme: Scheme) | Sets the scheme (protocol) for the URL. |
The Scheme
is a sealed trait that represents the different schemes (protocols) that can be used in a request. The available schemes are HTTP
and HTTPS
for HTTP requests, and WS
and WSS
for WebSockets.
Here is the list of methods that are available for adding URL, Path, and QueryParams to the client's configuration:
Methods | Description |
---|---|
Client#addUrl(url: URL) | Adds another URL to the existing one. |
Client#addPath(path: String) | Adds a path segment to the URL. |
Client#addPath(path: Path) | Adds a path segment from a Path object to the URL. |
Client#addLeadingSlash | Adds a leading slash to the URL path. |
Client#addTrailingSlash | Adds a trailing slash to the URL path. |
Client#addQueryParam(key: String, value: String) | Adds a query parameter with the specified key-value pair to the URL. |
Client#addQueryParams(params: QueryParams) | Adds multiple query parameters to the URL from a QueryParams object. |
Client Aspects/Middlewares
Client aspects are a powerful feature of ZIO HTTP, enabling us to intercept, modify, and extend client behavior. The ZClientAspect
is represented as a function that takes a ZClient
and returns a new ZClient
with customized behavior. We apply aspects to a client using the ZClient#@@
method, allowing modification of various execution aspects such as metrics, tracing, encoding, decoding, and debugging.
Debugging Aspects
To debug the client, we can use the ZClientAspect.debug
aspect, which logs the request details to the console. This is useful for debugging and troubleshooting client interactions, as it provides visibility into the low-level details of the HTTP requests and responses:
import zio._
import zio.http._
object ClientWithDebugAspect extends ZIOAppDefault {
val program =
for {
client <- ZIO.service[Client].map(_ @@ ZClientAspect.debug)
_ <- client.batched(Request.get("http://jsonplaceholder.typicode.com/todos"))
} yield ()
override val run = program.provide(Client.default)
}
The ZClientAspect.debug
also takes a partial function from Response
to String
, which enables us to customize the logging output based on the response. This is useful for logging specific details from the response, such as status code, headers, and body:
val debugResponse = ZClientAspect.debug { case res: Response => res.headers.mkString("\n") }
val program =
for {
client <- ZIO.service[Client].map(_ @@ debugResponse)
_ <- client.request(Request.get("http://jsonplaceholder.typicode.com/todos"))
} yield ()
Logging Aspects
To log the client interactions, we can use the ZClientAspect.requestLogging
which logs the request details such as method, duration, url, user-agent, status code and request size.
Let's try an example:
import zio._
import zio.http._
val loggingAspect =
ZClientAspect.requestLogging(
loggedRequestHeaders = Set(Header.UserAgent),
logResponseBody = true,
)
val program =
for {
client <- ZIO.service[Client].map(_ @@ loggingAspect)
_ <- client.request(Request.get("http://jsonplaceholder.typicode.com/todos"))
} yield ()
Follow Redirects
To follow redirects, we can apply the ZClientAspect.followRedirects
aspect, which takes the maximum number of redirects to follow and a callback function that allows us to customize the behavior when a redirect is encountered:
import zio._
import zio.http._
val followRedirects = ZClientAspect.followRedirects(3)((resp, message) => ZIO.logInfo(message).as(resp))
for {
client <- ZIO.service[Client].map(_ @@ followRedirects)
response <- client.request(Request.get("http://google.com"))
_ <- response.body.asString.debug
} yield ()
Configuring ZIO HTTP Client
The ZIO HTTP Client provides a flexible configuration mechanism through the ZClient.Config
class. This class allows us to customize various aspects of the HTTP client, including SSL settings, proxy configuration, connection pool size, timeouts, and more. The ZClient.Config.default
provides a default configuration that can be customized using copy
method or by using the utility methods provided by the ZClient.Config
class.
Let's take a look at the available configuration options:
- SSL Configuration: Allows us to specify SSL settings for secure connections.
- Proxy Configuration: Enables us to configure a proxy server for outgoing HTTP requests.
- Connection Pool Configuration: Defines the size of the connection pool.
- Max Initial Line Length: Sets the maximum length of the initial line in an HTTP request or response. The default is set to 4096 characters.
- Max Header Size: Specifies the maximum size of HTTP headers in bytes. The default is set to 8192 bytes.
- Request Decompression: Specifies whether the client should decompress the response body if it's compressed.
- Local Address: Specifies the local network interface or address to use for outgoing connections. It's set to None, indicating that the client will use the default local address.
- Add User-Agent Header: Indicates whether the client should automatically add a User-Agent header to outgoing requests. It's set to true in the default configuration.
- WebSocket Configuration: Configures settings specific to WebSocket connections. In this example, the default WebSocket configuration is used.
- Idle Timeout: Specifies the maximum idle time for persistent connections in seconds. The default is set to 50 seconds.
- Connection Timeout: Specifies the maximum time to wait for establishing a connection in seconds. By default, the client has no connection timeout.
Here are some of the above configuration options in more detail:
Configuring SSL
The default SSL configuration of ZClient.Config.default
is None
. To enable and configure SSL for the client, we can use the ZClient.Config#ssl
method. This method takes a config of type ClientSSLConfig
which supports different SSL configurations such as Default
, FromCertFile
, FromCertResource
, FromTrustStoreFile
, and `FromTrustStoreResource.
Let's see an example of how to configure SSL for the client:
package example
import zio._
import zio.http._
import zio.http.netty.NettyConfig
import zio.http.netty.client.NettyClientDriver
object HttpsClient extends ZIOAppDefault {
val url = URL.decode("https://jsonplaceholder.typicode.com/todos/1").toOption.get
val headers = Headers(Header.Host("jsonplaceholder.typicode.com"))
val sslConfig = ClientSSLConfig.FromTrustStoreResource(
trustStorePath = "truststore.jks",
trustStorePassword = "changeit",
)
val clientConfig = ZClient.Config.default.ssl(sslConfig)
val program = for {
data <- ZClient.batched(Request.get(url).addHeaders(headers))
_ <- Console.printLine(data)
} yield ()
val run =
program.provide(
ZLayer.succeed(clientConfig),
Client.customized,
NettyClientDriver.live,
DnsResolver.default,
ZLayer.succeed(NettyConfig.default),
)
}
Configuring Proxy
To configure a proxy for the client, we can use the Client#proxy
method. This method takes a Proxy
and updates the client's configuration to use the specified proxy for all requests:
import zio._
import zio.http._
val program = for {
proxyUrl <- ZIO.fromEither(URL.decode("http://localhost:8123"))
client <- ZIO.serviceWith[Client](_.proxy(Proxy(url = proxyUrl)))
res <- client.request(Request.get("https://jsonplaceholder.typicode.com/todos"))
} yield ()
Connection Pooling
Connection pooling is a crucial mechanism in ZIO HTTP for optimizing the management of HTTP connections. By default, ZIO HTTP uses a fixed-size connection pool with a capacity of 10 connections. This means that the client can maintain up to 10 idle connections to the server for reuse. When the client makes a request, it checks the connection pool for an available connection to the server. If a connection is available, it reuses it for the request. If no connection is available, it creates a new connection and adds it to the pool.
To configure the connection pool, we have to update the ZClient.Config#connectionPool
field with the preferred configuration. The ConnectionPoolConfig
trait serves as a base trait for different connection pool configurations. It is a sealed trait with five different implementations:
Disabled
: Indicates that connection pooling is disabled.Fixed
: Takes a single parameter,size
, which specifies a fixed size connection pool.FixedPerHost
: Takes a map ofURL.Location.Absolute
toFixed
to specify a fixed size connection pool per host.Dynamic
: Takes three parameters,minimum
,maximum
, andttl
, to configure a dynamic connection pool with minimum and maximum sizes and a time-to-live (TTL) duration.DynamicPerHost
: Similar to Dynamic, but with configurations per host.
Also the ZClient.Config
has some utility methods to update the connection pool configuration, e.g. ZClient.Config#fixedConnectionPool
and ZClient.Config#dynamicConnectionPool
. Let's see an example of how to configure the connection pool:
package example
import zio._
import zio.http._
import zio.http.netty.NettyConfig
object ClientWithConnectionPooling extends ZIOAppDefault {
val program = for {
url <- ZIO.fromEither(URL.decode("http://jsonplaceholder.typicode.com/posts"))
client <- ZIO.serviceWith[Client](_.addUrl(url))
_ <- ZIO.foreachParDiscard(Chunk.fromIterable(1 to 100)) { i =>
client.batched(Request.get(i.toString)).flatMap(_.body.asString).debug
}
} yield ()
val config = ZClient.Config.default.dynamicConnectionPool(10, 20, 5.second)
override val run =
program.provide(
ZLayer.succeed(config),
Client.live,
ZLayer.succeed(NettyConfig.default),
DnsResolver.default,
)
}
Enabling Response Decompression
When making HTTP requests using a client, such as a web browser or a custom HTTP client, it's essential to optimize data transfer for efficiency and performance.
By default, most HTTP clients do not advertise compression support when making requests to web servers. However, servers often compress response bodies when they detect that the client supports compression. To enable response compression, we need to add the Accept-Encoding
header to our HTTP requests. The Accept-Encoding
header specifies the compression algorithms supported by the client. Common values include gzip
and deflate
. When a server receives a request with the Accept-Encoding
header, it may compress the response body using one of the specified algorithms.
Here's an example of an HTTP request with the Accept-Encoding header:
GET https://example.com/
Accept-Encoding: gzip, deflate
When a server responds with a compressed body, it includes the Content-Encoding header to specify the compression algorithm used. The client then needs to decompress the body before processing its contents.
For example, a compressed response might look like this:
200 OK
content-encoding: gzip
content-type: application/json; charset=utf-8
<compressed-body>
To decompress the response body with ZClient
, we need to enable response decompression by using the ZClient.Config#requestDecompression
method:
package example
import zio._
import zio.http.Header.AcceptEncoding
import zio.http._
import zio.http.netty.NettyConfig
object ClientWithDecompression extends ZIOAppDefault {
val program = for {
url <- ZIO.fromEither(URL.decode("https://jsonplaceholder.typicode.com"))
client <- ZIO.serviceWith[Client](_.addUrl(url))
res <-
client
.addHeader(AcceptEncoding(AcceptEncoding.GZip(), AcceptEncoding.Deflate()))
.batched(Request.get("/todos"))
data <- res.body.asString
_ <- Console.printLine(data)
} yield ()
val config = ZClient.Config.default.requestDecompression(true)
override val run =
program.provide(
ZLayer.succeed(config),
Client.live,
ZLayer.succeed(NettyConfig.default),
DnsResolver.default,
)
}
Customizing ClientDriver
and DnsResolver
Rather than utilizing the default layer, Client.default
, we have the option to employ the Client.customized
layer. This layer requires ClientDriver
, DnsResolver
, and the Client.Config
layers:
object Client {
val customized: ZLayer[Config with ClientDriver with DnsResolver, Throwable, Client] = ???
}
This empowers us to interchange the client driver with alternatives beyond the default Netty driver or to customize it to our specific requirements. Also, we can customize the DNS resolver to use a different DNS resolution mechanism.
Examples
Simple Client Example
package example
import zio._
import zio.http._
object SimpleClient extends ZIOAppDefault {
val url = URL.decode("https://jsonplaceholder.typicode.com/todos").toOption.get
val program = for {
client <- ZIO.service[Client]
res <- client.url(url).batched(Request.get("/"))
data <- res.body.asString
_ <- Console.printLine(data)
} yield ()
override val run = program.provide(Client.default)
}
ClientServer Example
package example
import zio.ZIOAppDefault
import zio.http._
object ClientServer extends ZIOAppDefault {
val url = URL.decode("http://localhost:8080/hello").toOption.get
val app = Routes(
Method.GET / "hello" -> handler(Response.text("hello")),
Method.GET / "" -> handler(ZClient.batched(Request.get(url))),
).sandbox
val run =
Server.serve(app).provide(Server.default, Client.default).exitCode
}
Authentication Client Example
This example code demonstrates accessing a protected route in an authentication server by first obtaining a JWT token through a login request and then using that token to access the protected route:
package example
import zio._
import zio.http._
object AuthenticationClient extends ZIOAppDefault {
/**
* This example is trying to access a protected route in AuthenticationServer
* by first making a login request to obtain a jwt token and use it to access
* a protected route. Run AuthenticationServer before running this example.
*/
val url = "http://localhost:8080"
val loginUrl = URL.decode(s"${url}/login").toOption.get
val greetUrl = URL.decode(s"${url}/profile/me").toOption.get
val program = for {
client <- ZIO.service[Client]
// Making a login request to obtain the jwt token. In this example the password should be the reverse string of username.
token <- client
.batched(
Request
.get(loginUrl)
.withBody(
Body.fromMultipartForm(
Form(
FormField.simpleField("username", "John"),
FormField.simpleField("password", "nhoJ"),
),
Boundary("boundary123"),
),
),
)
.flatMap(_.body.asString)
// Once the jwt token is procured, adding it as a Bearer token in Authorization header while accessing a protected route.
response <- client.batched(Request.get(greetUrl).addHeader(Header.Authorization.Bearer(token)))
body <- response.body.asString
_ <- Console.printLine(body)
} yield ()
override val run = program.provide(Client.default)
}
Reconnecting WebSocket Client Example
This example represents a WebSocket client application that automatically attempts to reconnect upon encountering errors or disconnections. It uses the Promise
to notify about WebSocket errors:
package example
import zio._
import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
import zio.http._
object WebSocketReconnectingClient extends ZIOAppDefault {
val url = "ws://ws.vi-server.org/mirror"
// A promise is used to be able to notify application about websocket errors
def makeSocketApp(p: Promise[Nothing, Throwable]): WebSocketApp[Any] =
Handler
// Listen for all websocket channel events
.webSocket { channel =>
channel.receiveAll {
// On connect send a "foo" message to the server to start the echo loop
case UserEventTriggered(UserEvent.HandshakeComplete) =>
channel.send(ChannelEvent.Read(WebSocketFrame.text("foo")))
// On receiving "foo", we'll reply with another "foo" to keep echo loop going
case Read(WebSocketFrame.Text("foo")) =>
ZIO.logInfo("Received foo message.") *>
ZIO.sleep(1.second) *>
channel.send(ChannelEvent.Read(WebSocketFrame.text("foo")))
// Handle exception and convert it to failure to signal the shutdown of the socket connection via the promise
case ExceptionCaught(t) =>
ZIO.fail(t)
case _ =>
ZIO.unit
}
}.tapErrorZIO { f =>
// signal failure to application
p.succeed(f)
}
val app: ZIO[Client & Scope, Throwable, Unit] = {
(for {
p <- zio.Promise.make[Nothing, Throwable]
_ <- makeSocketApp(p).connect(url).catchAll { t =>
// convert a failed connection attempt to an error to trigger a reconnect
p.succeed(t)
}
f <- p.await
_ <- ZIO.logError(s"App failed: $f")
_ <- ZIO.logError(s"Trying to reconnect...")
_ <- ZIO.sleep(1.seconds)
} yield {
()
}) *> app
}
val run =
ZIO.scoped(app).provide(Client.default)
}