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
48 changes: 44 additions & 4 deletions http-client.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions test/http-client.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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/"))
Expand Down Expand Up @@ -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"))
Expand Down
56 changes: 56 additions & 0 deletions test/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: </style.css>; rel=preload; as=style\r\n"
b"Link: </app.js>; 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):
Expand Down
Loading