Http

Http :: # (opaque)

Send requests using the shared roc-lang/http Request and Response types. This module supplies effects and small JSON/UTF-8 conveniences while leaving pure request and response construction to that package.

[Http.send!] and the functions built on it hold the whole response body in memory. [Http.send_streaming!] returns as soon as the status and headers have arrived and hands the body over as a Stream of chunks, for a download larger than memory or one whose progress is shown.

See the host runtime behavior for HTTP protocol, TLS trust-store, and timeout details.

send! : Request => Try(Response, [InvalidUrl(ParseErr), HttpErr(TransportErr)])

Validate and send an HTTP request.

The request URI must be an absolute HTTP or HTTPS URL accepted by Url. Invalid URLs return InvalidUrl before any host effect occurs. Fragments are removed because they are client-side identifiers and are not sent.

request = Request.from_method(GET).with_uri("https://www.roc-lang.org")
response = Http.send!(request)?
with_json_body : Request, _ -> Try(Request, [JsonErr(_)])

Encode a value as JSON and set it as the request body.

This uses Roc's builtin JSON encoder, so the value's type determines the encoder through static dispatch.

send_json! : Request, _ => Try(Response, [JsonErr(_), InvalidUrl(ParseErr), HttpErr(TransportErr)])

Encode a value as JSON, attach it to the request body, and send it.

get_utf8! : Url => Try(Str, [BadBody(Str), InvalidUrl(ParseErr), HttpErr(TransportErr)])

Perform an HTTP GET and decode the response body as a UTF-8 Str.

The argument is a validated Url. Quoted literals work through Url.from_quote; dynamic strings should be passed through Url.parse.

hello_str = Http.get_utf8!("http://localhost:8000")?
decode_json_response : Response -> Try(_, [BadBody(Str), JsonErr(_)])

Decode a response body as JSON.

This uses Roc's builtin JSON parser, so the expected result type determines the parser through static dispatch.

get! : Url => Try(_, [BadBody(Str), InvalidUrl(ParseErr), HttpErr(TransportErr), JsonErr(_)])

Perform an HTTP GET and decode the response body as JSON.

The argument is a validated Url. JSON parser failures are returned as JsonErr(_).

payload : Try({ foo : Str }, _)
payload = Http.get!("http://localhost:8000")
send_streaming! : Request, StreamingOptions => Try(StreamingResponse, [InvalidUrl(ParseErr), HttpErr(TransportErr)])

Validate and send an HTTP request, returning once the status and headers have arrived. The body is read afterwards, through the StreamingResponse.

The request is validated, built and sent exactly as [Http.send!] does it. Any status is Ok, so check it before reading the body. Either timeout expiring is Err(HttpErr(Timeout)).

request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
    return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, |writer| writer.write_stream!(response.chunks()))?
TransportErr : TransportErr

Errors raised by the host while sending a request, before a real HTTP response is available.

StreamingOptions

:= { idle_timeout : [NoTimeout, After(Duration)] ?? After(Duration.seconds(30)) }

Options for [Http.send_streaming!]. {} selects the defaults.

idle_timeout bounds each single wait: for the status and headers, and then for each part of the body. A server that is silent for that long has stalled, however much it has already sent. It defaults to 30 seconds; After(Duration.zero) expires at once, before anything is sent, and NoTimeout waits for as long as the server takes.

The timeout of the Request keeps its meaning: it is the deadline for the whole exchange, body included, and NoTimeout (its default) and TimeoutMilliseconds(0) both mean no deadline. For a large download leave the request at NoTimeout and rely on idle_timeout.

StreamingResponse

Http.StreamingResponse :: # (opaque)

The status and headers of a response, and its body still to be read.

The body arrives over the response's own connection as it is read. StreamingResponse.chunks and [read_to_end!] read from one shared cursor, as do copies of the response, so each byte of the body is returned once.

The connection is closed when the body ends, when a failure other than an idle timeout ends it, or when the last reference to the response and its streams is released; a body that is not wanted need not be read.

to_inspect : StreamingResponse -> Str

Render the status and headers without exposing the host handle.

status : StreamingResponse -> U16

The status code. Any status is a response: a 404 is Ok with status() 404, and its body can be read or left alone.

headers : StreamingResponse -> List(Header)

The response headers, in the order they arrived.

content_length : StreamingResponse -> [Known(U64), Unknown]

The length of the whole body in bytes, from the Content-Length header. Unknown when the server did not send one, as with a chunked body. For the response to a HEAD request this is the length the header declares, although no body follows.

chunks : StreamingResponse -> Stream(Try(List(U8), [HttpErr(TransportErr)]))

Lazily read the body as non-empty chunks of at most 64 KiB. Chunk boundaries are chosen by the host and carry no meaning.

Building the stream reads nothing; each item is one read from the shared cursor. The stream ends at the end of the body, and a failure is the last item.

After an idle timeout, Err(HttpErr(Timeout)) with the response's deadline not yet reached, the response stays usable and a new chunks() continues where the last one stopped. Any other failure is final: a new stream yields it again.

response = Http.send_streaming!(request, { idle_timeout: After(Duration.seconds(5)) })?
var $done = 0
for! item in response.chunks() {
    bytes = item?
    $done = $done + bytes.len()
}

Process chunks individually to bound memory; Stream.collect! accumulates every chunk.

read_to_end! : StreamingResponse, [NoLimit, MaxBytes(U64)] => Try(List(U8), [ReadLimitExceeded(U64), HttpErr(TransportErr)])

Read the rest of the body: everything StreamingResponse.chunks and earlier reads have not returned. At the end of the body that is Ok([]).

With MaxBytes(max) this fails with ReadLimitExceeded(max) as soon as more than max unread bytes have arrived, without waiting for the rest. NoLimit holds the whole remainder in memory.

A failed call consumes nothing it could keep: after ReadLimitExceeded or an idle timeout the bytes that had arrived are still unread, so StreamingResponse.chunks or another call continues from the same place.

body = response.read_to_end!(MaxBytes(1_048_576))?