Client Guide
The top-level request helpers are intentionally familiar, but in 2.0 the underlying pieces are explicit and reusable.
The short version:
- use
HTTP.requestor verb helpers for eager responses - use
HTTP.openorresponse_streamfor streaming - use
HTTP.Clientwhen you want one reusable bundle of transport, retry, cookie, proxy, and HTTP/2 preferences - use phase-specific timeout keywords instead of the old
readtimeout
High-Level Requests
HTTP.request is the main entrypoint. The verb helpers such as HTTP.get and HTTP.post are convenience wrappers around it.
using HTTP
server = HTTP.serve!("127.0.0.1", 0; listenany = true) do req
payload = if req.target == "/stream"
"streaming response body"
else
"$(req.method) $(req.target)"
end
return HTTP.Response(
200;
headers = ["Content-Type" => "text/plain"],
body = payload,
)
end
base_url = "http://127.0.0.1:$(HTTP.port(server))"
resp = HTTP.request("GET", base_url * "/requests"; proxy = HTTP.ProxyConfig())
HTTP.forceclose(server)
(status = resp.status, body = String(resp.body))Useful top-level request helpers:
HTTP.get,HTTP.head,HTTP.post,HTTP.put,HTTP.patch,HTTP.delete,HTTP.optionsHTTP.requestfor the fully general call shapeHTTP.openwhen you want streaming control instead of an eagerly consumed body
Streaming Responses
HTTP.open gives you pull-based control over the response stream while still using the normal redirect/decompression machinery.
using HTTP
server = HTTP.serve!("127.0.0.1", 0; listenany = true) do req
payload = req.target == "/stream" ? "streaming response body" : "$(req.method) $(req.target)"
return HTTP.Response(
200;
headers = ["Content-Type" => "text/plain"],
body = payload,
)
end
base_url = "http://127.0.0.1:$(HTTP.port(server))"
response = HTTP.open(:GET, base_url * "/stream"; proxy = HTTP.ProxyConfig()) do stream
response_text = String(read(stream))
@info "got body" response_text
end
HTTP.forceclose(server)
responseThe do-block form returns the final HTTP.Response, not the value returned by the do block. Capture anything you want to keep from inside the block in an outer variable.
If you only need to stream into an IO, use the response_stream keyword:
using HTTP
server = HTTP.serve!("127.0.0.1", 0; listenany = true) do req
payload = req.target == "/stream" ? "streaming response body" : "$(req.method) $(req.target)"
return HTTP.Response(
200;
headers = ["Content-Type" => "text/plain"],
body = payload,
)
end
base_url = "http://127.0.0.1:$(HTTP.port(server))"
buffer = IOBuffer()
response = HTTP.get(base_url * "/buffered"; response_stream = buffer, proxy = HTTP.ProxyConfig())
seekstart(buffer)
HTTP.forceclose(server)
(status = response.status, body = String(take!(buffer)))Reusing a Client
Construct a Client when a set of options should travel together across many requests. Top-level calls already reuse default connection and cookie machinery; a Client gives you an explicit owner for a particular transport, cookie jar, retry bucket, proxy policy, and HTTP/2 preference.
using HTTP
server = HTTP.serve!("127.0.0.1", 0; listenany = true) do req
payload = req.target == "/stream" ? "streaming response body" : "$(req.method) $(req.target)"
return HTTP.Response(
200;
headers = ["Content-Type" => "text/plain"],
body = payload,
)
end
base_url = "http://127.0.0.1:$(HTTP.port(server))"
retry_bucket = HTTP.RetryBucket(capacity = 100)
transport = HTTP.Transport(
max_idle_per_host = 2,
max_idle_total = 4,
proxy = HTTP.ProxyConfig(),
)
client = HTTP.Client(
transport = transport,
cookiejar = HTTP.CookieJar(),
retry_bucket = retry_bucket,
)
client_response = HTTP.request("GET", base_url * "/reused"; client = client)
close(client)
HTTP.forceclose(server)
(status = client_response.status, body = String(client_response.body))Important Client and Transport knobs:
prefer_http2 = trueto prefer ALPN-negotiated HTTP/2 for secure traffic- connection-pool sizing via
max_idle_per_hostandmax_idle_total - shared
CookieJarstate across related requests - explicit proxy routing with
ProxyConfig,ProxyURL,ProxyFromEnvironment, andNoProxy - coordinated retries through a shared
RetryBucket
Per-Client defaults
Client can also act as a configuration container, just like requests.Session() or axios.create() in other ecosystems. Defaults set on the client apply to every request issued through it; per-call keywords always win when both are provided.
client = HTTP.Client(
default_headers = ["User-Agent" => "MyApp/1.0", "X-API-Version" => "v2"],
default_query = Dict("api_key" => "secret"),
default_basicauth = "alice" => "password",
request_timeout = 30,
connect_timeout = 5,
read_idle_timeout = 10,
)
# Defaults applied automatically.
HTTP.get(client, "https://api.example.com/users")
# Per-call values override defaults for this call only.
HTTP.get(client, "https://api.example.com/users";
headers = ["X-API-Version" => "v1"],
request_timeout = 60,
)Recognized client defaults:
default_headers: vector or dict of headers added when not present per-calldefault_query: dict, named-tuple, or vector-of-pairs of query parameters; per-call keys override matching defaultsdefault_basicauth: applied unless the call passesbasicauthor an explicitAuthorizationheaderconnect_timeout,request_timeout,response_header_timeout,read_idle_timeout,write_idle_timeout: applied when the per-call timeout is0(the default)
Positional Client calls
The verb helpers also accept the Client positionally — HTTP.get(client, url) is equivalent to HTTP.get(url; client = client). This works for get, head, post, put, patch, delete, options, request, and open.
Closed clients are poisoned
After close(client), subsequent calls that use it raise ArgumentError:
client = HTTP.Client()
HTTP.get(client, "https://example.com")
close(client)
HTTP.get(client, "https://example.com") # throws ArgumentError("HTTP.Client is closed")Use isopen(client) to check the live state.
Cancelling an in-flight request
Pass an HTTP.RequestContext via the context keyword to give an external task control over an outstanding request. Calling HTTP.cancel!(ctx) from another task aborts the in-flight read/write and the spawning task observes an HTTP.CanceledError. HTTP/1 cancellation closes the active connection, while HTTP/2 cancellation resets the active stream:
ctx = HTTP.RequestContext()
task = Threads.@spawn HTTP.get("https://slow.example.com/long"; context = ctx)
sleep(0.5)
HTTP.cancel!(ctx; message = "user pressed Ctrl-C")
try
fetch(task)
catch e
inner = e isa Base.TaskFailedException ? e.task.exception : e
@assert inner isa HTTP.CanceledError
endThe same context keyword works on HTTP.request, HTTP.get/head/post/ put/patch/delete/options, HTTP.open, and the lower-level HTTP.do!. Combined with a deadline (HTTP.RequestContext(deadline_ns = ...) or HTTP.set_deadline!(ctx, ...)) the same context value can drive both absolute deadlines and external cancellation.
Request and Response Bodies
The top-level request helpers buffer response bodies into Vector{UInt8} by default. For request and server-response bodies, ordinary strings, byte vectors, forms, and IO objects cover the common user-facing cases. Lower-level body wrappers exist for the protocol implementation and custom streaming extensions, but most application code should not need to construct them directly.
Reading the response body
Convert the raw bytes to a String when you want text:
using HTTP
response = HTTP.get("http://example.com")
text = String(response.body)String(::Vector{UInt8}) aliases the underlying buffer rather than copying it, so response.body is left empty (length == 0) once the String has been constructed. If you want to keep the bytes around for a second read, use String(copy(response.body)) (or copy(response.body) if you want raw bytes), or stream into a sink you own with response_stream = IOBuffer().
Sending JSON
HTTP.jl ships without a JSON dependency, so the request body is yours to serialize. The recommended JSON library is JSON.jl — pair it with an explicit Content-Type: application/json header:
using HTTP, JSON
payload = Dict("name" => "alice", "age" => 30)
response = HTTP.post(
"https://api.example.com/users";
headers = ["Content-Type" => "application/json"],
body = JSON.json(payload),
)
returned = JSON.parse(String(response.body))The verb helpers accept the body either positionally (HTTP.post(url, headers, body)) or via the body= keyword as shown above.
Sending form data
HTTP.post(url, [], dict) (or NamedTuple) auto-serializes to application/x-www-form-urlencoded and sets the matching Content-Type header for you:
HTTP.post("http://example.com/login", [], Dict("user" => "alice", "pw" => "s3cret"))For multipart/form-data (file uploads), use HTTP.Form:
form = HTTP.Form(Dict("file" => open("upload.bin", "r"), "kind" => "binary"))
HTTP.post("http://example.com/upload", [], form)Query parameters
The query keyword URL-encodes a Dict or vector of pairs and appends them to the URL's query string. Use it instead of building the query string by hand.
HTTP.get("http://example.com/search"; query = Dict("page" => 2, "limit" => 10))
# GET /search?limit=10&page=2A Dict is convenient but does not preserve order. Pass a vector of pairs when ordering matters:
HTTP.get("http://example.com/search"; query = ["page" => 2, "tag" => "hot"])
# GET /search?page=2&tag=hotRepeat a key in the vector form to send the same parameter multiple times:
HTTP.get("http://example.com/search"; query = ["tag" => "a", "tag" => "b"])
# GET /search?tag=a&tag=bquery is appended to any existing query string in the URL — it does not replace it:
HTTP.get("http://example.com/search?type=user"; query = ["page" => 2])
# GET /search?type=user&page=2Reading query parameters on the server
Server-side, req.target holds the request path plus its query string. Use HTTP.URI to split the two and HTTP.queryparams (returns a Dict) or HTTP.queryparampairs (preserves order and repeated keys) to decode them:
HTTP.serve!("127.0.0.1", 8080) do req
uri = HTTP.URI(req.target)
params = HTTP.queryparams(uri.query) # Dict{String,String}
pairs = HTTP.queryparampairs(uri.query) # Vector{Pair{String,String}}
return HTTP.Response(200; body = "got $(length(params)) params")
endRetries and Timeouts
The retry path is explicit and conservative. For predictable behavior, prefer a long-lived Client over relying solely on default top-level behavior.
using HTTP
bucket = HTTP.RetryBucket(capacity = 100)
function retry_if(attempt, err, req, resp)
if err !== nothing
return attempt <= 2
end
return resp !== nothing && resp.status in (429, 503) && attempt <= 3
end
url = "https://example.com"
response = HTTP.get(
url;
retry = true,
retries = 3,
retry_bucket = bucket,
retry_if = retry_if,
respect_retry_after = true,
)Timeout Model
The client APIs now expose timeout controls by phase instead of only a single read timeout:
connect_timeoutbounds DNS, TCP connect, proxyCONNECT, TLS handshake, and HTTP/2 session setuprequest_timeoutis the overall deadline for the whole exchangeresponse_header_timeoutbounds the wait from "request sent" to "response headers available"read_idle_timeoutbounds inactivity between inbound read-progress events, including response-header waits whenresponse_header_timeoutis unsetwrite_idle_timeoutbounds inactivity between outbound write-progress eventsexpect_continue_timeoutcontrols how long HTTP/1 uploads wait on100-continuebefore sending the body anyway
readtimeout is still accepted for compatibility, but it is deprecated and now behaves like read_idle_timeout.
For example:
using HTTP
resp = HTTP.get(
url;
connect_timeout = 2.0,
response_header_timeout = 5.0,
read_idle_timeout = 30.0,
)HTTP.open uses the same timeout model, and HTTP.WebSockets.open uses the handshake-relevant subset (connect_timeout, request_timeout, response_header_timeout, read_idle_timeout, and write_idle_timeout).
Debugging Requests
When a request misbehaves, verbose prints what the client is doing on the wire. verbose = 1 shows one-line attempt/response/done summaries; verbose = 2 adds the request and response head text:
HTTP.get("http://example.com"; verbose = 2)Sample output:
[http] request attempt 1 GET http://example.com/ via h1
[http] request
GET / HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate
User-Agent: HTTP.jl/2.0.0
[http] response attempt 1 200 for http://example.com/
[http] response
HTTP/1.1 200 OK
Content-Length: 1256
[http] done 200 for http://example.com/For programmatic introspection — for example, to push events into your own logger — pass a trace callback. The callback receives subtypes of HTTP.ClientEvent:
HTTP.RequestEvent— request being sentHTTP.ResponseHeadEvent— response headers receivedHTTP.RetryEvent— retry scheduledHTTP.RedirectEvent— redirect followedHTTP.DoneEvent— request finished (with response or error)
Reach for these APIs when you need more control:
RetryBucketfor coordinated retry throttlingconnect_timeout,request_timeout,response_header_timeout,read_idle_timeout,write_idle_timeout, andexpect_continue_timeoutonrequestretry_if,retry_non_idempotent, andrespect_retry_afterfor custom retry policy
The full custom retry callback signature is:
retry_if(attempt::Integer, err, req::HTTP.Request, resp) -> Union{Bool,Nothing}When it runs for a request-path failure, err is a RequestRetryError; inspect err.err to see the underlying transport or protocol exception. Response-based retry decisions keep err = nothing and pass the response through resp. Returning true requests another attempt when the request body can be replayed, false suppresses a retry, and nothing defers to the built-in retry rules.
1.x Compatibility Keywords
HTTP.jl 2.0 accepts several 1.x client keywords as migration aids. Treat them as temporary compatibility, not as the preferred API:
readtimeoutmaps toread_idle_timeoutpoolshould become a long-livedClientorTransportretry_delaysandretry_checkshould becomeretry_if,retries, andretry_bucketsslconfigandsocket_type_tlsshould move to transport/TLS configurationcopyheaders,canonicalize_headers,detect_content_type,observelayers,logerrors, andlogtagare accepted for compatibility where possible
See the migration guide for before/after examples.