Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 114 additions & 4 deletions src/client.toit
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,96 @@ class Client:
certificate_ = certificate
add-finalizer this:: this.finalize_

/**
Sends an HTTP request with an optional byte-array body.

The request is retried on a new connection if a cached connection was
closed by the server. GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are
retried by default. Other methods are only retried if
$retry-on-connection-close is true.

Since the body is a byte array, it can be replayed safely when reconnecting.
Use $new-request for streaming bodies that cannot be replayed.

This method returns redirect responses without following them.
*/
request method/string data/ByteArray?=null -> Response
--uri/string
--headers/Headers?=null
--content-type/string?=null
--retry-on-connection-close/bool?=null:
parsed := parse_ uri --web-socket=false
retry := retry-on-connection-close == null
? is-idempotent-method_ method
: retry-on-connection-close
return request_ method data parsed
--headers=headers
--content-type=content-type
--retry-on-connection-close=retry

/**
Sends an HTTP request with an optional byte-array body.

The request is retried on a new connection if a cached connection was
closed by the server. GET, HEAD, PUT, DELETE, OPTIONS, and TRACE are
retried by default. Other methods are only retried if
$retry-on-connection-close is true.

Since the body is a byte array, it can be replayed safely when reconnecting.
Use $new-request for streaming bodies that cannot be replayed.

The $query-parameters are encoded into the request path.

This method returns redirect responses without following them.
*/
request method/string data/ByteArray?=null -> Response
--host/string
--port/int?=null
--path/string="/"
--query-parameters/Map?=null
--headers/Headers?=null
--content-type/string?=null
--retry-on-connection-close/bool?=null
--use-tls/bool?=null:
parsed := parse_ host port path query-parameters use-tls --web-socket=false
retry := retry-on-connection-close == null
? is-idempotent-method_ method
: retry-on-connection-close
return request_ method data parsed
--headers=headers
--content-type=content-type
--retry-on-connection-close=retry

request_ method/string data/ByteArray? parsed/ParsedUri_ -> Response
--headers/Headers?
--content-type/string?
--retry-on-connection-close/bool:
headers = headers ? headers.copy : Headers
if headers.single "Transfer-Encoding": throw "INVALID_ARGUMENT"
if headers.single "Host": throw "INVALID_ARGUMENT"
if content-type:
existing := headers.single "Content-Type"
if existing and existing.to-ascii-lower != content-type.to-ascii-lower:
throw "INVALID_ARGUMENT"
headers.set "Content-Type" content-type

response/Response? := null
try-to-reuse_ parsed
--retry-on-connection-close=retry-on-connection-close:
| connection/Connection |
outgoing := connection.new-request method parsed.path headers
if data: outgoing.body = io.Reader data
response = outgoing.send
return response

is-idempotent-method_ method/string -> bool:
return method == GET
or method == HEAD
or method == PUT
or method == DELETE
or method == OPTIONS
or method == TRACE

/**
Variant of $(new-request method --host).

Expand Down Expand Up @@ -203,6 +293,10 @@ class Client:
Do not use $query-parameters for a $POST request. See instead $post-form,
which encodes the key-value pairs in the body as expected for a POST
request.

Since the body can be an arbitrary reader, $RequestOutgoing.send cannot
replay it after a cached connection has been closed. Use $request for
byte-array bodies that should be retried safely.
*/
new-request method/string -> RequestOutgoing
--host/string
Expand Down Expand Up @@ -454,13 +548,17 @@ class Client:

If $follow-redirects is true, follows redirects (when the status code is 3xx).

To avoid replaying a non-idempotent request, this method does not retry if a
cached connection was closed by the server.

The $use-tls argument can be used to override the default TLS usage of the
client.

# Advanced
If the data can be generated dynamically, it's more efficient to create a new
request with $new-request and to set the $RequestOutgoing.body to a reader
that produces the data only when needed.
that produces the data only when needed. Such a streaming request cannot be
retried automatically if the connection closes while sending it.
*/
post data/ByteArray -> Response
--host/string
Expand Down Expand Up @@ -519,7 +617,7 @@ class Client:

MAX-REDIRECTS.repeat:
response := null
try-to-reuse_ parsed: | connection |
try-to-reuse_ parsed --no-retry-on-connection-close: | connection |
request := connection.new-request POST parsed.path headers
request.body = io.Reader data
response = request.send
Expand Down Expand Up @@ -571,6 +669,9 @@ class Client:
version of this library. See $(post-json object --uri).

If $follow-redirects is true, follows redirects (when the status code is 3xx).

To avoid replaying a non-idempotent request, this method does not retry if a
cached connection was closed by the server.
*/
post-json object/any -> Response
--host/string
Expand Down Expand Up @@ -622,6 +723,9 @@ class Client:
version of this library. See $(post-form map --uri).

If $follow-redirects is true, follows redirects (when the status code is 3xx).

To avoid replaying a non-idempotent request, this method does not retry if a
cached connection was closed by the server.
*/
post-form map/Map -> Response
--host/string
Expand Down Expand Up @@ -659,7 +763,9 @@ class Client:

return post_ encoded parsed --headers=headers --content-type="application/x-www-form-urlencoded" --follow-redirects=follow-redirects

try-to-reuse_ location/ParsedUri_ [block]:
try-to-reuse_ location/ParsedUri_
--retry-on-connection-close/bool=true
[block]:
// We try to reuse an existing connection to a server, but a web server can
// lose interest in a long-running connection at any time and close it, so
// if it fails we need to reconnect.
Expand All @@ -671,7 +777,11 @@ class Client:
// info to a from-scratch connection attempt.
for attempt := 0; attempt < 3; attempt++:
reused := ensure-connection_ location
catch --unwind=(: attempt == 2 or ((not reused or not is-close-exception_ it) and it != "RESUME_FAILED")):
catch --unwind=(: attempt == 2
or ((not reused
or not retry-on-connection-close
or not is-close-exception_ it)
and it != "RESUME_FAILED")):
sock := connection_.socket_
if sock is tls.Socket and not reused:
tls-socket := sock as tls.Socket
Expand Down
137 changes: 137 additions & 0 deletions tests/client-request-retry-test.toit
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (C) 2026 Toit contributors.
// Use of this source code is governed by a Zero-Clause BSD license that can
// be found in the tests/TESTS_LICENSE file.

import expect show *
import http
import http.connection show is-close-exception_
import monitor
import net

PUT-DATA ::= "put payload".to-byte-array
POST-DATA ::= "post payload".to-byte-array

main:
network := net.open
server-socket := network.tcp-listen 0
port := server-socket.local-address.port
server := http.Server
server-done := monitor.Latch
response-read := monitor.Semaphore
server-closed := monitor.Semaphore
received-methods := []

task::
try:
server.listen server-socket:: | request/http.RequestIncoming writer/http.ResponseWriter |
if request.path == "/warm-up":
writer.out.write "ready"
writer.close
socket := writer.detach
response-read.down
socket.close
server-closed.up
else:
expect-equals "/upload" request.query.resource
expect-equals "yes" (request.headers.single "X-Test")
expect-equals "application/octet-stream" (request.headers.single "Content-Type")
received-methods.add request.method

body := request.body.read-all
if request.method == http.PUT:
expect-equals "put" request.query.parameters["kind"]
expect-equals PUT-DATA body
else:
expect-equals http.POST request.method
expect-equals "post" request.query.parameters["kind"]
expect-equals POST-DATA body
writer.out.write body
finally:
server-done.set true

client := http.Client network
headers := http.Headers
headers.set "X-Test" "yes"
try:
warm-up client port response-read server-closed

response := client.request http.PUT PUT-DATA
--host="localhost"
--port=port
--path="/upload"
--query-parameters={"kind": "put"}
--headers=headers
--content-type="application/octet-stream"
expect-equals 200 response.status-code
expect-equals PUT-DATA response.body.read-all

warm-up client port response-read server-closed

// POST isn't idempotent by definition, so it isn't retried without an
// explicit opt-in.
expect-connection-close:
client.request http.POST POST-DATA
--uri="http://localhost:$port/upload?kind=post"
--headers=headers
--content-type="application/octet-stream"
expect-equals [http.PUT] received-methods

warm-up client port response-read server-closed

expect-connection-close:
client.post POST-DATA
--uri="http://localhost:$port/upload?kind=post"
--headers=headers
--content-type="application/octet-stream"

warm-up client port response-read server-closed

expect-connection-close:
client.post-json {"value": 1}
--uri="http://localhost:$port/upload?kind=post"
--headers=headers

warm-up client port response-read server-closed

expect-connection-close:
client.post-form {"value": "1"}
--uri="http://localhost:$port/upload?kind=post"
--headers=headers
expect-equals [http.PUT] received-methods

warm-up client port response-read server-closed

response = client.request http.POST POST-DATA
--uri="http://localhost:$port/upload?kind=post"
--headers=headers
--content-type="application/octet-stream"
--retry-on-connection-close
expect-equals 200 response.status-code
expect-equals POST-DATA response.body.read-all

expect-equals [http.PUT, http.POST] received-methods
finally:
client.close
server.close
server-socket.close
server-done.get
network.close

warm-up client/http.Client port/int
response-read/monitor.Semaphore
server-closed/monitor.Semaphore:
response := client.get --host="localhost" --port=port --path="/warm-up"
expect-equals 200 response.status-code
data/string? := null
try:
data = response.body.read-all.to-string
finally:
response-read.up
server-closed.down
expect-equals "ready" data

expect-connection-close [block]:
exception := catch --trace=(: not is-close-exception_ it): block.call
expect
is-close-exception_ exception
--message="Expected a connection-close exception, got <$exception>"