AtlatestRepositorysigil-http
sigil-http / tree / testtest-client.sgl
1
;;; Tests for (sigil http client) — chunked decoding and response parsing3
(import (sigil test)4
(sigil core)5
(sigil io)6
(sigil string)7
(sigil time)8
(sigil socket)9
(sigil http client)10
(sigil http response))13
;; ============================================================14
;; Chunked Decoding15
;; ============================================================17
(test-group "decode-chunked-body"19
(test "single ASCII chunk"20
(let ((body (decode-chunked-body "5\r\nhello\r\n0\r\n\r\n")))21
(assert-equal body "hello")))23
(test "multiple ASCII chunks"24
(let ((body (decode-chunked-body "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n")))25
(assert-equal body "hello world")))27
(test "empty body"28
(let ((body (decode-chunked-body "0\r\n\r\n")))29
(assert-equal body "")))31
(test "UTF-8 multi-byte characters"32
;; "héllo" is 6 bytes in UTF-8 (é = 2 bytes), 5 characters33
(let ((body (decode-chunked-body "6\r\nhéllo\r\n0\r\n\r\n")))34
(assert-equal body "héllo")))36
(test "mixed ASCII and UTF-8 chunks"37
;; First chunk: "hello " = 6 bytes38
;; Second chunk: "wörld" = 6 bytes (ö = 2 bytes)39
(let ((body (decode-chunked-body "6\r\nhello \r\n6\r\nwörld\r\n0\r\n\r\n")))40
(assert-equal body "hello wörld")))42
(test "CJK characters"43
;; "日本語" = 9 bytes in UTF-8 (3 bytes each)44
(let ((body (decode-chunked-body "9\r\n日本語\r\n0\r\n\r\n")))45
(assert-equal body "日本語")))47
(test "Greek text"48
;; "Γειά" = 8 bytes in UTF-8 (2 bytes each)49
(let ((body (decode-chunked-body "8\r\nΓειά\r\n0\r\n\r\n")))50
(assert-equal body "Γειά"))))53
;; ============================================================54
;; Full HTTP Response Parsing55
;; ============================================================57
(test-group "parse-http-response"59
(test "simple response"60
(let ((res (parse-http-response61
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello")))62
(assert-true (http-response? res))63
(assert-equal (http-response-status res) 200)64
(assert-equal (http-response-body res) "hello")))66
(test "chunked response with ASCII"67
(let ((res (parse-http-response68
(string-append69
"HTTP/1.1 200 OK\r\n"70
"Transfer-Encoding: chunked\r\n\r\n"71
"5\r\nhello\r\n0\r\n\r\n"))))72
(assert-true (http-response? res))73
(assert-equal (http-response-body res) "hello")))75
(test "chunked response with UTF-8 body"76
(let ((res (parse-http-response77
(string-append78
"HTTP/1.1 200 OK\r\n"79
"Transfer-Encoding: chunked\r\n\r\n"80
"6\r\nhéllo\r\n0\r\n\r\n"))))81
(assert-true (http-response? res))82
(assert-equal (http-response-body res) "héllo"))))84
;; ============================================================85
;; build-api-url86
;; ============================================================88
(test-group "build-api-url"90
(test "base URL only"91
(assert-equal (build-api-url "https://api.example.com") "https://api.example.com"))93
(test "single path part"94
(assert-equal (build-api-url "https://api.example.com" "users")95
"https://api.example.com/users"))97
(test "multiple path parts"98
(assert-equal (build-api-url "https://api.example.com" "v1" "users" "123")99
"https://api.example.com/v1/users/123"))101
(test "no trailing slash on base"102
(assert-equal (build-api-url "https://api.twitch.tv/helix" "channels")103
"https://api.twitch.tv/helix/channels"))105
(test "forgejo-style with api/v1 prefix"106
(assert-equal (build-api-url "https://codeberg.org" "api" "v1" "repos")107
"https://codeberg.org/api/v1/repos")))110
;; ============================================================111
;; make-response-checker112
;; ============================================================114
(test-group "make-response-checker"116
(test "success returns parsed JSON"117
(let ((checker (make-response-checker name: "Test")))118
(assert-equal119
(checker (http-response status: 200 body: "{\"ok\":true}"))120
#{ ok: #t })))122
(test "success with empty body returns #t"123
(let ((checker (make-response-checker name: "Test")))124
(assert-equal125
(checker (http-response status: 204 body: ""))126
#t)))128
(test "generic error for 400+"129
(let ((checker (make-response-checker name: "Test API")))130
(assert-error131
(checker (http-response status: 500 body: "server error")))))133
(test "specific handler for status code"134
(let ((checker (make-response-checker135
name: "YouTube API"136
handlers: (list137
(cons 401 "Access token expired.")138
(cons 403 "Quota exceeded.")))))139
(assert-error140
(checker (http-response status: 401 body: "unauthorized")))))142
(test "no response raises error"143
(let ((checker (make-response-checker name: "Test")))144
(assert-error145
(checker #f))))147
(test "custom parse-error handler"148
(let ((checker (make-response-checker149
name: "Custom"150
parse-error: (lambda (status body)151
(error (string-append "custom: " (number->string status)))))))152
(assert-error153
(checker (http-response status: 422 body: "bad"))))))156
;; ============================================================157
;; make-json-api158
;; ============================================================160
(test-group "make-json-api"162
(test "returns dict with all method keys"163
(let ((api (make-json-api164
auth-headers: (lambda () #{ authorization: "Bearer tok" })165
check-response: (lambda (r) r))))166
(assert-true (dict? api))167
(assert-true (procedure? (dict-ref api get:)))168
(assert-true (procedure? (dict-ref api post:)))169
(assert-true (procedure? (dict-ref api put:)))170
(assert-true (procedure? (dict-ref api patch:)))171
(assert-true (procedure? (dict-ref api delete:))))))174
;; ============================================================175
;; HTTP Response Framing (Content-Length / chunked / no-body)176
;; ============================================================177
;;178
;; The read loop must terminate as soon as the framed body is fully179
;; received, NOT wait for the peer to close (EOF). A keep-alive or180
;; half-open peer can send a complete response and then hold the181
;; connection open indefinitely — which previously hung the read (or,182
;; with a timeout, tripped a spurious deadline on an already-delivered183
;; request, causing duplicate retries).185
(test-group "find-header-end-bytes"187
(test "locates the \\r\\n\\r\\n boundary"188
(let* ((bv (string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"))189
(pos (find-header-end-bytes bv)))190
(assert-true (and pos (> pos 0)))191
;; bytes at pos must be \r \n \r \n192
(assert-equal (bytevector-u8-ref bv pos) 13)193
(assert-equal (bytevector-u8-ref bv (+ pos 1)) 10)194
(assert-equal (bytevector-u8-ref bv (+ pos 2)) 13)195
(assert-equal (bytevector-u8-ref bv (+ pos 3)) 10)))197
(test "returns #f when headers are incomplete"198
(assert-false (find-header-end-bytes199
(string->utf8 "HTTP/1.1 200 OK\r\nContent-Len")))))201
(test-group "no-body-expected?"202
(test "HEAD never has a body" (assert-true (no-body-expected? 'HEAD 200)))203
(test "204 No Content" (assert-true (no-body-expected? 'GET 204)))204
(test "304 Not Modified" (assert-true (no-body-expected? 'GET 304)))205
(test "1xx informational" (assert-true (no-body-expected? 'GET 100)))206
(test "200 GET does have a body" (assert-false (no-body-expected? 'GET 200))))208
(test-group "detect-framing"210
(test "Content-Length response"211
(let ((f (detect-framing 'GET212
(string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel"))))213
(assert-equal (car f) 'length)214
(assert-equal (caddr f) 5)))216
(test "chunked response"217
(let ((f (detect-framing 'GET218
(string->utf8 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n"))))219
(assert-equal (car f) 'chunked)))221
(test "HEAD with Content-Length is still body-less"222
(let ((f (detect-framing 'HEAD223
(string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n"))))224
(assert-equal (car f) 'no-body)))226
(test "no Content-Length and not chunked falls back to until-close"227
(let ((f (detect-framing 'GET228
(string->utf8 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi"))))229
(assert-equal (car f) 'until-close)))231
(test "incomplete headers yield #f"232
(assert-false (detect-framing 'GET233
(string->utf8 "HTTP/1.1 200 OK\r\nContent-Len")))))235
(test-group "framing-complete?"237
(test "length: complete when body bytes reach Content-Length"238
(assert-true (framing-complete? (list 'length 10 5) 15 #f)))239
(test "length: incomplete when short"240
(assert-false (framing-complete? (list 'length 10 5) 12 #f)))241
(test "no-body: always complete"242
(assert-true (framing-complete? (list 'no-body) 0 #f)))243
(test "until-close: never complete (relies on EOF)"244
(assert-false (framing-complete? (list 'until-close) 9999 #f)))246
(test "chunked: complete with full 0-terminated stream"247
(let ((bv (string->utf8 "5\r\nhello\r\n0\r\n\r\n")))248
(assert-true (framing-complete? (list 'chunked 0) (bytevector-length bv) bv))))249
(test "chunked: incomplete without terminator"250
(let ((bv (string->utf8 "5\r\nhello\r\n")))251
(assert-false (framing-complete? (list 'chunked 0) (bytevector-length bv) bv)))))253
;; ============================================================254
;; Read Timeout (opt-in)255
;; ============================================================256
;;257
;; Demonstrates that the `timeout:` keyword bounds a stalled read: we258
;; bind a listening socket but never accept the connection. The kernel259
;; still completes the TCP handshake (via the listen backlog), so the260
;; client connects and writes the request successfully, then blocks261
;; waiting for a response that never comes — exactly the half-open262
;; wedge the timeout is meant to break. With `timeout:` the read must263
;; raise a timeout error within the deadline instead of hanging.265
(test-group "http read timeout"267
(test "stalled read raises an ack-unconfirmed timeout within the deadline"268
(let ((listener (tcp-listen 0 host: "127.0.0.1")))269
(assert-true (socket? listener))270
(let* ((port (cadr (socket-local-address listener)))271
(url (string-append "http://127.0.0.1:" (number->string port) "/"))272
(start (current-second))273
(raised #f)274
(ack-unconfirmed #f))275
;; Never accept on `listener`; the request is sent into the kernel276
;; backlog (so it WAS written/delivered) and the response read277
;; stalls — a delivered-but-ack-unconfirmed condition.278
(guard (exn (else (set! raised #t)279
(set! ack-unconfirmed (http-ack-unconfirmed? exn))))280
(http-get url timeout: 1))281
(let ((elapsed (- (current-second) start)))282
(socket-close listener)283
(assert-true raised)284
;; The request was written before the read stalled, so it's285
;; classified as ack-unconfirmed (delivered), NOT a never-sent286
;; failure.287
(assert-true ack-unconfirmed)288
(assert-true (>= elapsed 0.5))289
(assert-true (< elapsed 5))))))291
(test "connect failure returns #f (never sent), not ack-unconfirmed"292
;; Bind+immediately-close a listener to obtain a port with nothing293
;; listening → connect is refused → request never sent.294
(let* ((tmp (tcp-listen 0 host: "127.0.0.1"))295
(port (cadr (socket-local-address tmp))))296
(socket-close tmp)297
(let ((res (guard (exn (else (cons 'raised exn)))298
(http-get (string-append "http://127.0.0.1:"299
(number->string port) "/")300
timeout: 1))))301
;; A never-sent failure surfaces as #f (the caller's tg-api-call302
;; turns that into a retryable error), NOT an ack-unconfirmed raise.303
(assert-false res)))))305
;; ============================================================306
;; Bounding the remaining blocking segments307
;; ============================================================308
;;309
;; An outbound HTTP call passes through six blocking segments. Five are310
;; bounded; DNS is a documented residual. See "Unbounded segment: DNS" in311
;; client.sgl for why.312
;;313
;; THE RIG, and why it is the discriminating one: `tcp-listen` and then314
;; never `tcp-accept`. The kernel completes the TCP three-way handshake from315
;; the listen backlog, so connect() SUCCEEDS and the socket is ESTABLISHED.316
;; A connect-phase timeout therefore provably cannot fire against it, and any317
;; bound observed comes from the segment under test and nothing else.318
;;319
;; This is the shape that wedged a production monitoring service for 55 days.321
(define (with-stalled-listener proc)322
(let* ((listener (tcp-listen 0 host: "127.0.0.1"))323
(port (cadr (socket-local-address listener))))324
(let ((result (proc port)))325
(socket-close listener)326
result)))328
(define (seconds-taken thunk)329
(let* ((t0 (current-second))330
(outcome (guard (exn (else (cons 'raised exn))) (cons 'value (thunk)))))331
(cons (- (current-second) t0) outcome)))333
(test-group "bounded blocking segments"335
;; --- Segment: TLS handshake -------------------------------------------336
;;337
;; The one that actually killed a service. `connect-timeout:` used to stop338
;; at the moment the peer accepted, leaving the handshake read unbounded.340
(test "https handshake against an accept-and-stall peer is bounded by connect-timeout"341
(with-stalled-listener342
(lambda (port)343
(let* ((url (string-append "https://127.0.0.1:" (number->string port) "/"))344
(measured (seconds-taken (lambda () (http-get url connect-timeout: 1))))345
(elapsed (car measured))346
(outcome (cdr measured)))347
;; The connection could not be established, so the request was348
;; never sent: #f, not a raise.349
(assert-true (eq? 'value (car outcome)))350
(assert-false (cdr outcome))351
;; Bounded, not merely eventual. Each phase gets the full value,352
;; so the ceiling is about two connect-timeouts plus slack.353
(assert-true (< elapsed 6))))))355
;; POSITIVE CONTROL for the assertion above. Without this, "returned #f356
;; quickly" is satisfied by a client that cannot do HTTPS at all, or that357
;; rejects the URL before opening a socket. Here the peer is refusing358
;; connections outright, which must ALSO be fast, and must be reached by a359
;; different path.360
(test "https to a refused port also returns #f, so speed alone proves nothing"361
(let* ((tmp (tcp-listen 0 host: "127.0.0.1"))362
(port (cadr (socket-local-address tmp))))363
(socket-close tmp)364
(let* ((url (string-append "https://127.0.0.1:" (number->string port) "/"))365
(measured (seconds-taken (lambda () (http-get url connect-timeout: 1))))366
(outcome (cdr measured)))367
(assert-true (eq? 'value (car outcome)))368
(assert-false (cdr outcome))369
;; Connection refused is immediate: well under the deadline. So the370
;; stall test's elapsed time, which sits near its deadline rather371
;; than near zero, is measuring a real wait.372
(assert-true (< (car measured) 1)))))374
;; --- Segment: request write -------------------------------------------375
;;376
;; A request usually fits the socket buffer, so this usually returns at377
;; once. "Usually" is not a bound: a peer that never drains its receive378
;; queue fills the window, and a large body then blocks forever.380
(test "a large body into a never-drained peer is bounded by timeout"381
(with-stalled-listener382
(lambda (port)383
;; 6 MiB is chosen between two constraints, both measured on this384
;; host.385
;;386
;; It must EXCEED the socket buffers, or the kernel simply absorbs387
;; the body and the write never blocks: 512 KiB, 2 MiB and 4 MiB were388
;; all absorbed here (tcp_wmem max is 4 MiB).389
;;390
;; And it must stay small enough that handling it does not swamp the391
;; timing assertion. At 8 MiB this call measured 46 s against a 1 s392
;; deadline, all of it spent before the write loop's first deadline393
;; check. Large-string allocation in the runtime is seconds-slow394
;; (make-string of 8 MiB takes ~6 s standalone), and the collections395
;; that provokes land wherever they land. At 6 MiB the same call396
;; measures 1.02 s, so the assertion is measuring the deadline.397
;;398
;; If this ever fails as an ack-unconfirmed READ timeout rather than399
;; a write timeout, the host's socket buffers are bigger than the400
;; body: raise the size. It fails loudly rather than skipping.401
(let* ((url (string-append "http://127.0.0.1:" (number->string port) "/"))402
(body (make-string (* 6 1024 1024) #\x))403
(measured (seconds-taken (lambda () (http-post url body timeout: 1))))404
(elapsed (car measured))405
(outcome (cdr measured)))406
(assert-true (eq? 'raised (car outcome)))407
;; A write deadline can only fire with bytes still unsent, so the408
;; server holds a partial request it cannot act on. That is a409
;; never-delivered send and it must NOT carry the ack-unconfirmed410
;; mark, or a caller will decline to retry a request that never411
;; arrived.412
(assert-false (http-ack-unconfirmed? (cdr outcome)))413
(assert-true (>= elapsed 0.5))414
(assert-true (< elapsed 5))))))416
;; POSITIVE CONTROL for the write bound: the same call with a body that417
;; fits must succeed at the write and fail LATER, at the read, with the418
;; other classification. If the write loop were simply broken, this would419
;; fail as a write timeout too.420
(test "a small body still writes, then stalls at the read as ack-unconfirmed"421
(with-stalled-listener422
(lambda (port)423
(let* ((url (string-append "http://127.0.0.1:" (number->string port) "/"))424
(measured (seconds-taken (lambda () (http-post url "small=1" timeout: 1))))425
(outcome (cdr measured)))426
(assert-true (eq? 'raised (car outcome)))427
;; Written and delivered, response never came: ack-unconfirmed.428
(assert-true (http-ack-unconfirmed? (cdr outcome)))))))430
;; --- Segment: plain-HTTP connect --------------------------------------432
;; --- Segment: plain-HTTP connect --------------------------------------433
;;434
;; READ THIS BEFORE ADDING TO THIS SECTION. The two tests below assert a435
;; FALSY result, and a falsy result is the cheapest thing in the world to436
;; produce: `connect-first-address` wraps its native call in a guard, so an437
;; unbound procedure, a wrong arity, a wrong argument type or the whole438
;; bounded path being deleted ALL yield #f as well. AN ASSERTION THAT ONLY439
;; CHECKS FOR A FALSY RESULT CANNOT DISTINGUISH "CORRECTLY FALSE" FROM440
;; "NOTHING RAN". On their own these two would pass against no441
;; implementation at all.442
;;443
;; What makes them mean something is the LIVE control that has to hold at444
;; the same time: "plain http with both bounds armed still works" in445
;; test/integration/live-timeouts-main.sgl fetches a 200 through this exact446
;; path from a real listener. Failure here plus success there is the pair447
;; that pins the behaviour. Neither half is worth much alone, and the live448
;; half is named in run-timeout-tests.sh's required-controls list so it449
;; cannot quietly stop running.451
(test "plain-http connect-timeout returns a clean #f for a refused port"452
(let* ((tmp (tcp-listen 0 host: "127.0.0.1"))453
(port (cadr (socket-local-address tmp))))454
(socket-close tmp)455
(let* ((url (string-append "http://127.0.0.1:" (number->string port) "/"))456
(measured (seconds-taken (lambda () (http-get url connect-timeout: 1))))457
(outcome (cdr measured)))458
(assert-true (eq? 'value (car outcome)))459
(assert-false (cdr outcome))460
(assert-true (< (car measured) 2)))))462
(test "an unresolvable host fails without hanging on the bounded connect path"463
(let* ((measured (seconds-taken464
(lambda () (http-get "http://no-such-host-xyz.invalid/"465
connect-timeout: 1))))466
(outcome (cdr measured)))467
(assert-true (eq? 'value (car outcome)))468
(assert-false (cdr outcome)))))470
(test-group "incremental response-body streaming"472
(test "chunked decoder survives every transport split and emits raw UTF-8 bytes"473
(let* ((payload "data: Γειά 👋\n\n")474
(payload-bytes (string->utf8 payload))475
(wire (bytevector-append476
(string->utf8477
(string-append (number->string (bytevector-length payload-bytes) 16)478
"\r\n"))479
payload-bytes480
(string->utf8 "\r\n0\r\n\r\n")))481
(wire-length (bytevector-length wire)))482
(let split-loop ((at 1))483
(when (< at wire-length)484
(let ((state (stream-chunked-state))485
(chunks (vector '())))486
(define (receive chunk)487
(vector-set! chunks 0 (append (vector-ref chunks 0) (list chunk))))488
(stream-chunked-feed! state (bytevector-copy wire 0 at) receive)489
(stream-chunked-feed! state (bytevector-copy wire at wire-length) receive)490
(assert-equal491
(utf8->string (fetch-assemble (vector-ref chunks 0))) payload)492
(split-loop (+ at 1)))))))494
(test "chunked decoder emits multiple chunks in order and stops at terminator"495
(let ((state (stream-chunked-state))496
(chunks (vector '())))497
(stream-chunked-feed!498
state (string->utf8 "3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n")499
(lambda (chunk)500
(vector-set! chunks 0 (append (vector-ref chunks 0) (list chunk)))))501
(assert-equal (utf8->string (fetch-assemble (vector-ref chunks 0))) "onetwo")))503
(test "invalid chunk size is rejected"504
(assert-error505
(stream-chunked-feed! (stream-chunked-state)506
(string->utf8 "nope\r\n")507
(lambda (chunk) #t)))))509
(run-tests)