diff --git a/src/client.toit b/src/client.toit index 40584d9..ff6c5fb 100644 --- a/src/client.toit +++ b/src/client.toit @@ -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). @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/tests/client-request-retry-test.toit b/tests/client-request-retry-test.toit new file mode 100644 index 0000000..cf783fc --- /dev/null +++ b/tests/client-request-retry-test.toit @@ -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>"