diff --git a/http-client.carp b/http-client.carp index 2eb86ee..76a1e7a 100644 --- a/http-client.carp +++ b/http-client.carp @@ -523,11 +523,13 @@ that cares about a truncated body must check `error` afterwards.") (Connection.close conn) (Result.Error (ClientError.Send e)))))))))) - (hidden read-headers) - (private read-headers) - (defn read-headers [conn] + (hidden read-head) + (private read-head) + ; reads only if `pending` does not already hold a whole head + (defn read-head [conn pending] (let-do [sb (StringBuf.create) - found false] + found (String.contains-string? pending "\r\n\r\n")] + (StringBuf.append-str &sb pending) (while (not found) (match (Connection.read conn) (Result.Success chunk) @@ -553,6 +555,44 @@ that cares about a truncated body must check `error` afterwards.") (Result.Success resp) (Result.Success (Pair.init resp body-start))))))))) + (hidden max-interim) + (private max-interim) + (def max-interim 5) + + (hidden interim?) + (private interim?) + ; RFC 9110 ยง15.2; a 101 hands the connection to another protocol, so it is final + (defn interim? [code] (and (<= 100 code) (and (< code 200) (/= code 101)))) + + (hidden read-headers) + (private read-headers) + (defn read-headers [conn] + (let-do [pending @"" + skipped 0 + result (the + (Result (Pair Response String) ClientError) + (Result.Error (ClientError.Receive @"")))] + (while-do true + (match (read-head conn &pending) + (Result.Error e) (do (set! result (Result.Error e)) (break)) + (Result.Success pair) + (cond + (not (interim? @(Response.code (Pair.a &pair)))) + (do (set! result (Result.Success pair)) (break)) + (= skipped max-interim) + (do + (set! result + (Result.Error + (ClientError.Parse + (fmt + "too many 1xx informational responses (max %d)" + max-interim)))) + (break)) + (do + (set! pending @(Pair.b &pair)) + (set! skipped (Int.inc skipped)))))) + result)) + (doc default-max-redirects "is the default maximum number of HTTP redirects to follow. Used by `request`, `request-stream`, and convenience methods.") (def default-max-redirects 10) diff --git a/test/http-client.carp b/test/http-client.carp index 844e195..1d08db3 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -220,6 +220,25 @@ true &(String.chars b)))) +(defn link-header [res] + (match res + (Result.Error e) (ClientError.message &e) + (Result.Success r) (Maybe.from (Response.header &r "Link") @"none"))) + +(defn cookie-names [res] + (match res + (Result.Error e) (ClientError.message &e) + (Result.Success r) + (String.join "," + &(Array.copy-map &(fn [c] @(Cookie.name c)) + (Response.cookies &r))))) + +; The status and body of a cookie-jar request, then what the jar sends back. +(defn jar-status-and-cookies [url] + (let-do [jar (CookieJar.create) + res (status-and-body (Client.get-with-jar url &jar))] + (fmt "%s %s" &res &(Maybe.from (CookieJar.cookie-header &jar url) @"none")))) + (deftest test (assert-true test (Result.success? &(Client.get "http://127.0.0.1:8791/")) @@ -725,6 +744,98 @@ &(get-body "http://127.0.0.1:8791/header-continuation") "a header value that is not valid UTF-8 leaves the body intact") + (assert-equal test + "200 [final-body]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-continue")) + "a 100 Continue is skipped for the response after it") + + (assert-equal test + "200 [hello world]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-hints")) + "a 103 Early Hints is skipped ahead of a chunked response") + + (assert-equal test + "none" + &(link-header (Client.get "http://127.0.0.1:8791/interim-hints")) + "the headers of a skipped interim response do not reach the final one") + + (assert-equal test + "200 [final-body]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-many")) + "several interim responses in a row are all skipped") + + (assert-equal test + "200 [final-body]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-split")) + "a final response that arrives in a later read than the interim one is read") + + (assert-equal test + "200 [final-body]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-partial")) + "a final head that starts in the interim response's read is read whole") + + ; /interim-held keeps the connection open for 10 s after the response + (assert-equal test + "200 [hello world]" + &(let-do [t0 (System.time) + res (status-and-body + (Client.get "http://127.0.0.1:8791/interim-held"))] + (if (< (- (System.time) t0) 5) res @"waited for the close")) + "a final head read along with the interim one is not waited for") + + (assert-equal test + "200 [ok]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-redirect")) + "a redirect after an interim response is followed") + + (assert-equal test + "200 []" + &(head-status-and-body "http://127.0.0.1:8791/interim-continue") + "a HEAD skips an interim response too") + + (assert-equal test + "200 [final-body]" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-flood/5")) + "five interim responses are skipped") + + (assert-equal test + "too many 1xx informational responses (max 5)" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-flood/6")) + "a sixth interim response is an error") + + (assert-equal test + "parse" + &(error-kind (Client.get "http://127.0.0.1:8791/interim-flood/6")) + "too many interim responses is a Parse error") + + (assert-equal test + "incomplete HTTP headers" + &(status-and-body (Client.get "http://127.0.0.1:8791/interim-close")) + "a close right after an interim response is an error") + + (assert-equal test + "101 []" + &(status-and-body (Client.get "http://127.0.0.1:8791/switching")) + "a 101 Switching Protocols is a final response") + + (assert-equal test + "200 [payload]" + &(status-and-body + (Client.post "http://127.0.0.1:8791/post" + {@"Expect" [@"100-continue"]} + "payload")) + "a request that expects 100-continue gets the final response") + + (assert-equal test + "final" + &(cookie-names (Client.get "http://127.0.0.1:8791/interim-cookie")) + "a Set-Cookie on an interim response is not part of the final one") + + (assert-equal test + "200 [ok] final=kept" + &(jar-status-and-cookies "http://127.0.0.1:8791/interim-cookie") + "a Set-Cookie on an interim response is not stored in the jar") + (assert-equal test "malformed chunked body: invalid chunk size 'zz'" &(status-and-body (Client.get "http://127.0.0.1:8791/chunked-bad-hex")) diff --git a/test/server.py b/test/server.py index 639e29f..1889912 100755 --- a/test/server.py +++ b/test/server.py @@ -87,6 +87,13 @@ def _body_bytes(self): def _headers_dump(self): return "".join(f"{k}: {v}\n" for k, v in self.headers.items()) + def _sends(self, *parts): + """Writes each part in its own send, pausing between them.""" + for i, part in enumerate(parts): + if i: + time.sleep(0.2) + self.wfile.write(part) + # -- routing ------------------------------------------------------------ def _route(self): parsed = urlparse(self.path) @@ -278,6 +285,55 @@ def _route(self): if path == "/header-continuation": return self._raw(b"X-Weird: \x80\x80\x80\x80\x80", b"HELLO-BODY") + # 1xx interim responses ahead of the final one, written as raw bytes + cont = b"HTTP/1.1 100 Continue\r\n\r\n" + hints = ( + b"HTTP/1.1 103 Early Hints\r\n" + b"Link: ; rel=preload; as=style\r\n" + b"Link: ; rel=preload; as=script\r\n\r\n" + ) + final = self._length_body(10, b"" if self.command == "HEAD" else b"final-body") + chunked = ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n" + b"Connection: close\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n" + ) + if path == "/interim-continue": + return self._sends(cont + final) + if path == "/interim-hints": + return self._sends(hints + chunked) + if path == "/interim-held": + self._sends(hints + chunked) + time.sleep(10) + return + if path == "/interim-many": + return self._sends( + cont + b"HTTP/1.1 102 Processing\r\n\r\n" + hints + final + ) + if path == "/interim-split": + return self._sends(cont, final) + if path == "/interim-partial": + return self._sends(hints + final[:30], final[30:]) + if path.startswith("/interim-flood/"): + return self._sends(cont * int(path.rsplit("/", 1)[1]) + final) + if path == "/interim-redirect": + return self._sends( + cont + b"HTTP/1.1 302 Found\r\nLocation: /get\r\n" + b"Content-Length: 0\r\nConnection: close\r\n\r\n" + ) + if path == "/interim-cookie": + return self._sends( + b"HTTP/1.1 103 Early Hints\r\nSet-Cookie: interim=leaked; Path=/\r\n\r\n" + b"HTTP/1.1 200 OK\r\nSet-Cookie: final=kept; Path=/\r\n" + b"Content-Length: 2\r\nConnection: close\r\n\r\nok" + ) + if path == "/interim-close": + return self._sends(cont) + if path == "/switching": + return self._sends( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: example\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + return self._send(404, "not found") def do_GET(self):