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)