Commit9a9c716cRecorded30 Jun 2026Repositorysigil-http
Distinguish never-sent from delivered-ack-unconfirmed (timeout: callers)
Message
When timeout: is set, http-request now classifies failures by phase: a connect or write failure (request never left) still returns #f, but a request that was WRITTEN and then has its response read fail (read deadline, empty/closed read, or unparseable response) RAISES an error carrying the 'http-ack-unconfirmed irritant — the message was likely delivered, so the caller can treat it as best-effort success instead of retrying (which would re-deliver). Exports http-ack-unconfirmed?.
Default path (no timeout:) is byte-identical — the write result is ignored and a failed read still yields #f — so no existing consumer is affected; only courier/telegram pass timeout:.
Changed
package.sgl | 2 +-
src/sigil/http/client.sgl | 56 +++++++++++++++++++++++++++++++++++++++++++++++---------
test/test-client.sgl | 40 +++++++++++++++++++++++++++-------------
3 files changed, 75 insertions(+), 23 deletions(-)Diff
package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package 7
name: "sigil-http"−8
version: "0.16.5"+8
version: "0.16.6" 9
sigil: "^0.16" 10
description: "HTTP client and server library for Sigil" 11
url: "https://codeberg.org/sigil/sigil-http"src/sigil/http/client.sglmodified
@@ -45,6 +45,9 @@
45
http-get/json 46
http-post/json 47
+48
;; Send-status classification (for timeout: callers)+49
http-ack-unconfirmed?+50
51
;; Streaming download 52
http-download 53
@@ -308,10 +311,34 @@
311
(define (deadline-expired? deadline) 312
(and deadline (>= (current-jiffy) deadline))) 313
−311
;;; Raise a clean timeout error. The message prefix is stable so−312
;;; callers can recognize timeouts (e.g. to reconnect vs. fail).+314
;; Irritant marking an exception as "the request was written to the+315
;; server (so it was likely delivered/processed) but reading the+316
;; response failed" — as opposed to a connect/write failure where the+317
;; request never left. Callers can use `http-ack-unconfirmed?` to+318
;; treat the send as best-effort success (no retry) rather than a+319
;; failed send (retry would re-deliver an already-delivered request).+320
(define ack-unconfirmed-irritant 'http-ack-unconfirmed)+321
+322
;;; Is `exn` an "ack unconfirmed" error (request sent, response read+323
;;; failed)? Distinguishes a delivered-but-unconfirmed send from a+324
;;; genuine never-sent failure (the latter does not carry this mark).+325
(define (http-ack-unconfirmed? exn)+326
(and (error-object? exn)+327
(memq ack-unconfirmed-irritant (error-object-irritants exn))+328
#t))+329
+330
;;; Raise an "ack unconfirmed" error: the request was sent but the+331
;;; response could not be read (read deadline, empty/closed read, or+332
;;; unparseable response). Carries `ack-unconfirmed-irritant`.+333
(define (raise-http-ack-unconfirmed reason)+334
(error (string-append "HTTP request sent but response read failed: "+335
reason)+336
ack-unconfirmed-irritant))+337
+338
;;; Raise a clean timeout error (read deadline). Marked ack-unconfirmed+339
;;; because the deadline only fires after the request was written. 340
(define (raise-http-timeout)−314
(error "HTTP request timed out: read deadline exceeded"))+341
(raise-http-ack-unconfirmed "read deadline exceeded")) 342
343
;; ============================================================ 344
;; HTTP Request Building@@ -735,17 +762,28 @@
762
(let* ((parsed-url (parse-url url)) 763
(conn (connect-to-server parsed-url connect-timeout))) 764
(if (not conn)−738
#f+765
#f ; connect failed — request never sent 766
(let ((deadline (timeout->deadline timeout)) 767
(request-str (build-request-string method parsed-url headers body))) 768
;; Write the request while still blocking (requests are 769
;; small and fit the socket buffer), then switch to 770
;; non-blocking so the read loop can enforce the deadline.−744
(conn-write conn request-str)−745
(when deadline (conn-set-non-blocking! conn))−746
(let ((response (read-http-response method conn deadline)))−747
(conn-close conn)−748
response)))))+771
(let ((wrote (conn-write conn request-str)))+772
;; Phase distinction is only surfaced when a timeout is in+773
;; use (opt-in); without it, behavior is byte-identical to+774
;; before (the write result is ignored and a failed read+775
;; just yields #f).+776
(if (and deadline (not wrote))+777
(begin (conn-close conn) #f) ; write failed — never (fully) sent+778
(begin+779
(when deadline (conn-set-non-blocking! conn))+780
(let ((response (read-http-response method conn deadline)))+781
(conn-close conn)+782
(if (and deadline (not response))+783
;; Request was written but no usable response+784
;; came back → delivered, ack unconfirmed.+785
(raise-http-ack-unconfirmed "no response read")+786
response))))))))) 787
788
;;; HTTP GET request. 789
;;;test/test-client.sglmodified
@@ -264,28 +264,42 @@
264
265
(test-group "http read timeout" 266
−267
(test "stalled read raises a timeout error within the deadline"+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
;; Never accept on `listener`; the request will be sent into the−275
;; kernel backlog and the response read will stall.−276
(guard (exn (else (set! raised #t)))+273
(raised #f)+274
(ack-unconfirmed #f))+275
;; Never accept on `listener`; the request is sent into the kernel+276
;; backlog (so it WAS written/delivered) and the response read+277
;; 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)−280
;; A timeout must have been raised (a failed connect would−281
;; return #f without raising, so `raised` confirms the−282
;; deadline path fired)... 283
(assert-true raised)−284
;; ...after roughly the 1s deadline (proves it actually waited−285
;; and wasn't an instant failure)...+284
;; The request was written before the read stalled, so it's+285
;; classified as ack-unconfirmed (delivered), NOT a never-sent+286
;; failure.+287
(assert-true ack-unconfirmed) 288
(assert-true (>= elapsed 0.5))−287
;; ...and well before any OS-level multi-minute block (proves−288
;; the read did not hang).−289
(assert-true (< elapsed 5)))))))+289
(assert-true (< elapsed 5))))))+290
+291
(test "connect failure returns #f (never sent), not ack-unconfirmed"+292
;; Bind+immediately-close a listener to obtain a port with nothing+293
;; 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-call+302
;; turns that into a retryable error), NOT an ack-unconfirmed raise.+303
(assert-false res))))) 304
305
(run-tests)