Packages

requests

Send HTTP requests and read responses as text, typed JSON, lines or server-sent events, with timeouts and honest error handling.

import "std:requests"

std:requests is the HTTP client. Use it to call web APIs, download pages, post data and read streaming responses. It is the counterpart of serve, and the examples here call a small server they start themselves on 127.0.0.1, so every one runs as written. Replace the local address with a real URL in your own code.

A first request#

requests.get(url) returns a result. On success you get a response with status, ok, statusText, url, headers and body:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/hello", def(*serve.Context c) (
  c.setHeader("X-Served-By", "osl")
  c.text(200, "Hello, client")
))
app.start("127.0.0.1:8790").unwrap()

auto reply = requests.get("http://127.0.0.1:8790/hello").unwrap()
log reply.status reply.statusText
log reply.ok
log reply.headers["X-Served-By"]
log reply.body

app.stop().unwrap()
Click the code to edit it

Try a path the server doesn't have, such as /nothing, and look at status and ok.

Header names use their canonical capitalisation, such as Content-Type and X-Served-By, whatever the server sent.

Other methods#

Each HTTP method has a function: get, post, put, patch, delete, head and options. All take a URL and an optional options object. The json option encodes a value as the request body and sets Content-Type: application/json. The body option sends a string as it is:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.post("/items", def(*serve.Context c) (
  auto item = c.bodyJSON().unwrap()
  c.json(201, {id: 1, name: item["name"]})
))
app.put("/items/:id", def(*serve.Context c) (
  c.text(200, "replaced " ++ c.param("id") ++ " with " ++ c.body())
))
app.delete("/items/:id", def(*serve.Context c) (
  c.noContent()
))
app.start("127.0.0.1:8791").unwrap()

auto base = "http://127.0.0.1:8791/items"
auto made = requests.post(base, {json: {name: "lamp"}}).unwrap()
log made.status made.body.trim()
auto put = requests.put(base ++ "/1", {body: "plain text"}).unwrap()
log put.status put.body
auto gone = requests.delete(base ++ "/1").unwrap()
log gone.status gone.body.len

app.stop().unwrap()
Click the code to edit it

Query strings and headers#

The query option adds parameters to the URL and escapes them. headers sets request headers, which is where tokens go:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/search", def(*serve.Context c) (
  c.json(200, {
    q: c.query("q"),
    auth: c.bearer(),
    agent: c.userAgent()
  })
))
app.start("127.0.0.1:8792").unwrap()

auto reply = requests.get("http://127.0.0.1:8792/search", {
  query: {q: "hello world, osl"},
  headers: {Authorization: "Bearer abc123", "User-Agent": "docs/1.0"}
}).unwrap()
log reply.url
log reply.body.trim()

app.stop().unwrap()
Click the code to edit it

Typed JSON responses#

Most APIs answer with JSON. .parse<T>() on a response (or on the result of a request) parses the body and checks it against a type, so a wrong shape is an error and not a surprise later. It needs std:json no more than you do:

main.osl
import "std:serve"
import "std:requests"

struct User (
  string name = ""
  int age = 0
)

auto app = serve.new()
app.get("/user", def(*serve.Context c) (
  c.json(200, {name: "Ada", age: 36})
))
app.get("/broken", def(*serve.Context c) (
  c.json(200, {name: "Ada", age: "old"})
))
app.start("127.0.0.1:8793").unwrap()

auto base = "http://127.0.0.1:8793"
User user = requests.get(base ++ "/user").parse<User>().unwrap()
log user.name user.age

auto bad = requests.get(base ++ "/broken").parse<User>()
log bad.isErr() bad.unwrapErr().kind

app.stop().unwrap()
Click the code to edit it

parse sits on the request's result, so it also passes a failed request through: one unwrap or catch at the end covers both the network and the parsing.

A bad status is not a failure#

A request fails only when no response arrives: the host is down, the connection times out or the URL is malformed. A 404 or 500 is a successful request with ok set to false. Check it yourself:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/missing", def(*serve.Context c) (
  c.notFound("no such thing")
))
app.start("127.0.0.1:8794").unwrap()

auto reply = requests.get("http://127.0.0.1:8794/missing").unwrap()
log reply.ok reply.status
log reply.body.trim()

if !reply.ok (
  log "the server said no"
)

app.stop().unwrap()
Click the code to edit it

Failures that are failures#

When no response arrives, you get an error whose kind says why: NetworkError (refused or reset), NotFoundError (unknown host), TimeoutError, ParseError (malformed URL), UnsupportedError (a scheme other than http and https) or IOError (a body over maxBytes). The timeoutMs option sets the limit and defaults to 30 seconds:

main.osl
import "std:serve"
import "std:requests"
import "std:thread"

auto app = serve.new()
app.get("/slow", def(*serve.Context c) (
  thread.sleep(500)
  c.text(200, "finally")
))
app.start("127.0.0.1:8795").unwrap()

auto slow = requests.get("http://127.0.0.1:8795/slow", {timeoutMs: 100})
log slow.isErr() slow.unwrapErr().kind

auto closed = requests.get("http://127.0.0.1:1/")
log closed.unwrapErr().kind

auto odd = requests.get("gopher://example.com/")
log odd.unwrapErr().kind

switch requests.get("http://127.0.0.1:8795/slow", {timeoutMs: 2000}) (
  case ok(r)
    log "got" r.body
  case err(e)
    log "failed:" e.kind
)

app.stop().unwrap()
Click the code to edit it

Retrying#

Networks fail in passing ways, so retry the ones worth retrying. retry calls a function again, with a growing delay, until it succeeds. The function fails by throwing or returning a failed result, so turn a bad status into an error:

main.osl
import "std:serve"
import "std:requests"
import "std:retry"

auto app = serve.new()
int hits = 0
app.get("/flaky", def(*serve.Context c) (
  hits = hits + 1
  if hits < 3 (
    c.internalError("not yet")
    return
  )
  c.text(200, "worked on try " ++ hits)
))
app.start("127.0.0.1:8796").unwrap()

auto body = retry.call(def(int attempt) (
  auto r = requests.get("http://127.0.0.1:8796/flaky").unwrap()
  if !r.ok (
    throw error("status " ++ r.status ++ " on attempt " ++ attempt)
  )
  return r.body
), {attempts: 5, delay: 10}).unwrap()
log body

app.stop().unwrap()
Click the code to edit it

Retry only requests that are safe to repeat. A GET is. A POST that charges a card needs an idempotency key first.

Redirects and size limits#

Redirects are followed by default. Pass redirects: false to receive the redirect itself, which is how you read a Location header. maxBytes (16 MiB by default) stops a server from sending you more than you want to hold:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/old", def(*serve.Context c) (
  c.redirect(301, "/new")
))
app.get("/new", def(*serve.Context c) (
  c.text(200, "the new page")
))
app.get("/big", def(*serve.Context c) (
  c.text(200, "x" * 5000)
))
app.start("127.0.0.1:8797").unwrap()

auto base = "http://127.0.0.1:8797"
auto followed = requests.get(base ++ "/old").unwrap()
log followed.status followed.url followed.body
auto raw = requests.get(base ++ "/old", {redirects: false}).unwrap()
log raw.status raw.headers["Location"]

auto big = requests.get(base ++ "/big", {maxBytes: 1000})
log big.unwrapErr().kind

app.stop().unwrap()
Click the code to edit it

Streaming lines#

requests.get reads the whole body before returning. For a large download, or a server that keeps sending, use requests.stream(method, url, options). It returns as soon as the headers arrive, and stream.lines() yields each line as it comes in. Close the stream when you are done:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/log", def(*serve.Context c) (
  c.text(200, "starting\nworking\ndone\n")
))
app.start("127.0.0.1:8798").unwrap()

auto stream = requests.stream("GET", "http://127.0.0.1:8798/log").unwrap()
log stream.status() stream.ok()
for line in stream.lines() (
  log "line:" line
)
stream.close().unwrap()

app.stop().unwrap()
Click the code to edit it

A stream with an error status is still a stream: check stream.ok() before reading. The timeoutMs of a stream defaults to no limit and covers reading the body, so set one if the server might go quiet.

Server-sent events#

A server that answers with text/event-stream pushes events over one response, as many AI and live-update APIs do. stream.events() yields each as {event, data, id}. The event name is message unless the server names one:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/events", def(*serve.Context c) (
  c.send(200, "text/event-stream", "event: tick\ndata: 1\n\ndata: 2\nid: 7\n\ndata: [DONE]\n\n")
))
app.start("127.0.0.1:8799").unwrap()

auto stream = requests.stream("GET", "http://127.0.0.1:8799/events").unwrap()
for e in stream.events() (
  if e.data == "[DONE]" break
  log e.event e.data e.id
)
stream.close().unwrap()

app.stop().unwrap()
Click the code to edit it

A sentinel such as [DONE] comes through as an ordinary event, so end the loop yourself when you see it.

Many requests at once#

An async function runs on its own thread, so several requests can be in flight together. waitAll collects the results in order:

main.osl
import "std:serve"
import "std:requests"

auto app = serve.new()
app.get("/n/:n", def(*serve.Context c) (
  thread.sleep(100)
  c.text(200, "item " ++ c.param("n"))
))
app.start("127.0.0.1:8789").unwrap()

async fetch(int n) string (
  return requests.get("http://127.0.0.1:8789/n/" ++ n).unwrap().body
)

auto jobs = [fetch(1), fetch(2), fetch(3)]
for body in thread.waitAll(jobs) (
  log body
)

app.stop().unwrap()
Click the code to edit it

Three sleeping requests finish in about the time of one.

Quick check

A server answers 404 Not Found. What does requests.get(url) return?

Your turn

Fetch and total

The server lists prices at /prices as a JSON array of numbers. Fetch it with parse<number[]>(), then print how many prices there are and their total. Treat a failed request as zero prices and print a message instead of crashing.

main.osl
Ctrl Enter runs
Expected output
3 prices, total 14.75
Your turn

Add two numbers remotely

The server adds two numbers: POST /add with {"a": 2, "b": 3} answers {"sum": 5}. Post those numbers, parse the answer into the Sum struct and print the sum.

main.osl
Ctrl Enter runs
Expected output
5

API reference#

17 members, generated from the std:requests source of OSL 9.3.0.

Functions

#requests.delete(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

delete sends a DELETE request; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.get(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

get sends a GET request; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.head(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

head sends a HEAD request, whose response has an empty body; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.options(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

options sends an OPTIONS request; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.patch(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

patch sends a PATCH request; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.post(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

post sends a POST request, usually with a body or json option; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.put(string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

put sends a PUT request; see requests.request for the response, options and errors.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

#requests.request(string method, string url, object? options) result<{body: string, error: string (deprecated), headers: string[string], ok: boolean, raw: any (deprecated), status: int, statusText: string, success: boolean (deprecated), url: string}, error>

Deprecated. Deprecated:

request sends an HTTP request and reads the whole response as {status, ok, statusText, url, headers, body}. A 4xx or 5xx response is not a failure. Options: headers, query, body, json, timeoutMs (default 30000, 0 waits forever), maxBytes (default 16 MiB) and redirects (default true; false returns a redirect as the response instead of following it).

#requests.stream(string method, string url, object? options) result<*requests.Stream, error>

stream sends a request and returns as soon as the response headers arrive, so the body can be read line by line or event by event while the server is still sending it. Close the stream when done. It takes the options of requests.request except maxBytes; timeoutMs defaults to 0 (no limit) and covers reading the body too. It fails like requests.request when no response arrives; an HTTP error status is a stream with ok() false.

Deprecated option timeout: it is in seconds; pass timeoutMs (milliseconds) instead. Deprecated option params: pass query instead. Deprecated option max_bytes: pass maxBytes instead.

*requests.Stream

#stream.close() result<unit, error>

close stops reading and releases the connection. Closing twice is a no-op.

#stream.events() iter<{data: string, event: string, id: string}>

events returns an iterator over the body's server-sent events (text/event-stream), each as {event, data, id}: event is the event type ("message" unless the server names one), data joins the event's data lines with "\n", and id is the last event id seen ("" if none). Comments and retry fields are skipped. A sentinel payload such as "[DONE]" is yielded like any other event. Iterating fails like lines().

#stream.headers() string[string]

headers returns the response headers, mapping each canonical header name to its value; repeated headers are joined with ", ".

#stream.lines() iter<string>

lines returns an iterator over the body's lines, without their line endings, ending when the server finishes the body. Several loops and threads can share one stream; each line goes to one of them. Iterating throws TimeoutError or NetworkError when reading fails and StateError once the stream is closed.

#stream.ok() boolean

ok reports whether the status is 2xx.

#stream.readEvent() string

Deprecated. Deprecated: use for event in stream.events() instead, which yields {event, data, id} records.

readEvent returns the data of the next server-sent event, or "" at the end of the body, after a failure and for a "[DONE]" event alike.

#stream.readLine() string

Deprecated. Deprecated: use for line in stream.lines() instead, which ends at the end of the body and throws on failures.

readLine returns the next line, or "" at the end of the body, after a failure and for a blank line alike.

#stream.status() int

status returns the response's HTTP status code, such as 200.