From c1aefc524391ca730fcc50327ccbccbc00c1fa16 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 27 Aug 2026 00:39:49 +0200 Subject: [PATCH 1/4] Add retryable generic client requests --- src/client.toit | 107 ++++++++++++++++++++++++++- tests/client-request-retry-test.toit | 104 ++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 tests/client-request-retry-test.toit diff --git a/src/client.toit b/src/client.toit index 40584d9..4e4a00d 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 @@ -460,7 +554,8 @@ class 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 @@ -659,7 +754,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 +768,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..8b614e5 --- /dev/null +++ b/tests/client-request-retry-test.toit @@ -0,0 +1,104 @@ +// 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 monitor +import net + +READ-TIMEOUT ::= Duration --ms=20 +WAIT-FOR-CLOSE ::= Duration --ms=200 + +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 --read-timeout=READ-TIMEOUT + server-done := monitor.Latch + received-methods := [] + + task:: + try: + server.listen server-socket:: | request/http.RequestIncoming writer/http.ResponseWriter | + if request.path == "/warm-up": + writer.out.write "ready" + 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 + wait-until-server-closes-idle-connection + + 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 + wait-until-server-closes-idle-connection + + // POST isn't idempotent by definition, so it isn't retried without an + // explicit opt-in. + exception := catch: + client.request http.POST POST-DATA + --uri="http://localhost:$port/upload?kind=post" + --headers=headers + --content-type="application/octet-stream" + expect-not-null exception + expect-equals [http.PUT] received-methods + + warm-up client port + wait-until-server-closes-idle-connection + + 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 := client.get --host="localhost" --port=port --path="/warm-up" + expect-equals 200 response.status-code + expect-equals "ready" response.body.read-all.to-string + +wait-until-server-closes-idle-connection: + // Leave enough margin that the server has closed its side of the cached + // connection before the client attempts to reuse it. + sleep WAIT-FOR-CLOSE From b2063eb55ef322ffac177b62dafcb9b3674a303c Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 27 Aug 2026 00:49:03 +0200 Subject: [PATCH 2/4] Avoid retrying buffered POST requests --- src/client.toit | 11 ++++++++++- tests/client-request-retry-test.toit | 29 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/client.toit b/src/client.toit index 4e4a00d..ff6c5fb 100644 --- a/src/client.toit +++ b/src/client.toit @@ -548,6 +548,9 @@ 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. @@ -614,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 @@ -666,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 @@ -717,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 diff --git a/tests/client-request-retry-test.toit b/tests/client-request-retry-test.toit index 8b614e5..a70826e 100644 --- a/tests/client-request-retry-test.toit +++ b/tests/client-request-retry-test.toit @@ -77,6 +77,35 @@ main: warm-up client port wait-until-server-closes-idle-connection + exception = catch: + client.post POST-DATA + --uri="http://localhost:$port/upload?kind=post" + --headers=headers + --content-type="application/octet-stream" + expect-not-null exception + + warm-up client port + wait-until-server-closes-idle-connection + + exception = catch: + client.post-json {"value": 1} + --uri="http://localhost:$port/upload?kind=post" + --headers=headers + expect-not-null exception + + warm-up client port + wait-until-server-closes-idle-connection + + exception = catch: + client.post-form {"value": "1"} + --uri="http://localhost:$port/upload?kind=post" + --headers=headers + expect-not-null exception + expect-equals [http.PUT] received-methods + + warm-up client port + wait-until-server-closes-idle-connection + response = client.request http.POST POST-DATA --uri="http://localhost:$port/upload?kind=post" --headers=headers From 66528e89514755f2c42843d58d8cf8174c289820 Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 27 Aug 2026 01:14:30 +0200 Subject: [PATCH 3/4] Synchronize retry test with server closes --- tests/client-request-retry-test.toit | 36 +++++++++++----------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/tests/client-request-retry-test.toit b/tests/client-request-retry-test.toit index a70826e..eae5f97 100644 --- a/tests/client-request-retry-test.toit +++ b/tests/client-request-retry-test.toit @@ -7,9 +7,6 @@ import http import monitor import net -READ-TIMEOUT ::= Duration --ms=20 -WAIT-FOR-CLOSE ::= Duration --ms=200 - PUT-DATA ::= "put payload".to-byte-array POST-DATA ::= "post payload".to-byte-array @@ -17,8 +14,9 @@ main: network := net.open server-socket := network.tcp-listen 0 port := server-socket.local-address.port - server := http.Server --read-timeout=READ-TIMEOUT + server := http.Server server-done := monitor.Latch + server-closed := monitor.Semaphore received-methods := [] task:: @@ -26,6 +24,10 @@ main: server.listen server-socket:: | request/http.RequestIncoming writer/http.ResponseWriter | if request.path == "/warm-up": writer.out.write "ready" + writer.close + socket := writer.detach + socket.close + server-closed.up else: expect-equals "/upload" request.query.resource expect-equals "yes" (request.headers.single "X-Test") @@ -48,8 +50,7 @@ main: headers := http.Headers headers.set "X-Test" "yes" try: - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed response := client.request http.PUT PUT-DATA --host="localhost" @@ -61,8 +62,7 @@ main: expect-equals 200 response.status-code expect-equals PUT-DATA response.body.read-all - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed // POST isn't idempotent by definition, so it isn't retried without an // explicit opt-in. @@ -74,8 +74,7 @@ main: expect-not-null exception expect-equals [http.PUT] received-methods - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed exception = catch: client.post POST-DATA @@ -84,8 +83,7 @@ main: --content-type="application/octet-stream" expect-not-null exception - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed exception = catch: client.post-json {"value": 1} @@ -93,8 +91,7 @@ main: --headers=headers expect-not-null exception - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed exception = catch: client.post-form {"value": "1"} @@ -103,8 +100,7 @@ main: expect-not-null exception expect-equals [http.PUT] received-methods - warm-up client port - wait-until-server-closes-idle-connection + warm-up client port server-closed response = client.request http.POST POST-DATA --uri="http://localhost:$port/upload?kind=post" @@ -122,12 +118,8 @@ main: server-done.get network.close -warm-up client/http.Client port/int: +warm-up client/http.Client port/int server-closed/monitor.Semaphore: response := client.get --host="localhost" --port=port --path="/warm-up" expect-equals 200 response.status-code expect-equals "ready" response.body.read-all.to-string - -wait-until-server-closes-idle-connection: - // Leave enough margin that the server has closed its side of the cached - // connection before the client attempts to reuse it. - sleep WAIT-FOR-CLOSE + server-closed.down From 190198a5487c089524d470c25da60454076662eb Mon Sep 17 00:00:00 2001 From: Florian Loitsch Date: Thu, 27 Aug 2026 01:30:41 +0200 Subject: [PATCH 4/4] Make retry test close portable --- tests/client-request-retry-test.toit | 46 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/tests/client-request-retry-test.toit b/tests/client-request-retry-test.toit index eae5f97..cf783fc 100644 --- a/tests/client-request-retry-test.toit +++ b/tests/client-request-retry-test.toit @@ -4,6 +4,7 @@ import expect show * import http +import http.connection show is-close-exception_ import monitor import net @@ -16,6 +17,7 @@ main: port := server-socket.local-address.port server := http.Server server-done := monitor.Latch + response-read := monitor.Semaphore server-closed := monitor.Semaphore received-methods := [] @@ -26,6 +28,7 @@ main: writer.out.write "ready" writer.close socket := writer.detach + response-read.down socket.close server-closed.up else: @@ -50,7 +53,7 @@ main: headers := http.Headers headers.set "X-Test" "yes" try: - warm-up client port server-closed + warm-up client port response-read server-closed response := client.request http.PUT PUT-DATA --host="localhost" @@ -62,45 +65,41 @@ main: expect-equals 200 response.status-code expect-equals PUT-DATA response.body.read-all - warm-up client port server-closed + warm-up client port response-read server-closed // POST isn't idempotent by definition, so it isn't retried without an // explicit opt-in. - exception := catch: + expect-connection-close: client.request http.POST POST-DATA --uri="http://localhost:$port/upload?kind=post" --headers=headers --content-type="application/octet-stream" - expect-not-null exception expect-equals [http.PUT] received-methods - warm-up client port server-closed + warm-up client port response-read server-closed - exception = catch: + expect-connection-close: client.post POST-DATA --uri="http://localhost:$port/upload?kind=post" --headers=headers --content-type="application/octet-stream" - expect-not-null exception - warm-up client port server-closed + warm-up client port response-read server-closed - exception = catch: + expect-connection-close: client.post-json {"value": 1} --uri="http://localhost:$port/upload?kind=post" --headers=headers - expect-not-null exception - warm-up client port server-closed + warm-up client port response-read server-closed - exception = catch: + expect-connection-close: client.post-form {"value": "1"} --uri="http://localhost:$port/upload?kind=post" --headers=headers - expect-not-null exception expect-equals [http.PUT] received-methods - warm-up client port server-closed + warm-up client port response-read server-closed response = client.request http.POST POST-DATA --uri="http://localhost:$port/upload?kind=post" @@ -118,8 +117,21 @@ main: server-done.get network.close -warm-up client/http.Client port/int server-closed/monitor.Semaphore: +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 - expect-equals "ready" response.body.read-all.to-string - server-closed.down + 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>"