Improve send rate limit handling
ober
d5d4301788281b6ec942a90295f2c827a4840154
--- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -31,10 +31,13 @@ ;; can never be re-fired by mashing Enter; Ctrl-R restores it deliberately. ;; challenge holds the proof-required token from the last rate-limited send ;; (or #f); Ctrl-Y submits it together with a solved captcha. + ;; rate-limit-until is a real-time millisecond deadline from signal-cli's + ;; retryAfterSeconds hint. While active, sends are blocked locally so we do + ;; not turn one Signal refusal into many retries. (defstruct tui-state (account version receive-mode width height input status quit? event-count conversations selected-index mode picker-query picker-index removed logdb - resend challenge)) + resend challenge rate-limit-until)) (def (run-tui-terminal account actor version receive-mode) ;; Prompt for the log passphrase and open the DB BEFORE termbox grabs the @@ -75,6 +78,7 @@ removed logdb #f + #f #f)]) (append-system-message! state @@ -844,6 +848,8 @@ [(or (eq? (conversation-kind conv) 'system) (not (conversation-target conv))) (tui-state-status-set! state "Select a Signal conversation before sending.")] + [(rate-limit-active? state) + (tui-state-status-set! state (rate-limit-status state))] [else ;; Clear the composer BEFORE the call: once the request is written to ;; signal-cli the message may be delivered even if the RPC reply is an @@ -864,6 +870,7 @@ (tui-state-account state) (conversation-id conv) text ts) (tui-state-resend-set! state #f) + (tui-state-rate-limit-until-set! state #f) (conversation-unread-set! conv 0) (tui-state-status-set! state "Sent.")) (begin @@ -916,7 +923,7 @@ ;; event overwrites it. (def *rpc-hint-keys* - '("retryAfterSeconds" "retryAfter" "token" "challenge" "options" + '("type" "retryAfterSeconds" "retryAfter" "token" "challenge" "options" "captchaRequired")) (def (report-send-failure! state conv e retry-hint) @@ -925,28 +932,40 @@ " failed (it may still have gone out): " (safe-display e))] [err (rpc-error-data e)] - [hints (if err (collect-rpc-hints err '()) '())]) + [hints (if err (collect-rpc-hints err '()) '())] + [token (and err (rpc-error-field err "token"))] + [retry-after (and err (rpc-error-number-field err "retryAfterSeconds"))] + [rate-limit? (and err + (let ([type (rpc-error-field err "type")]) + (and type (string=? type "RATE_LIMIT_FAILURE"))))]) (append-system-message! state base) (for-each (lambda (hint) (append-system-message! state (string-append " " hint))) hints) (when err (append-json-detail! state err) - ;; A proof-required refusal carries a challenge token; remember it so - ;; Ctrl-Y can submit a solved captcha without retyping it. - (let ([token (rpc-error-field err "token")]) - (when token - (tui-state-challenge-set! state token) + (when retry-after + (let ([until (+ (real-time) (* retry-after 1000))]) + (tui-state-rate-limit-until-set! state until) (append-system-message! state - "Challenge token recorded -- solve the captcha at signalcaptchas.org/challenge/generate.html, then Ctrl-Y to submit it.")))) + (string-append "Rate-limit cooldown recorded: wait " + (number->string retry-after) + " seconds before retrying.")))) + ;; A proof-required refusal carries a challenge token; remember it so + ;; Ctrl-Y can submit a solved captcha without retyping it. + (when token + (tui-state-challenge-set! state token) + (append-system-message! + state + "Challenge token recorded -- solve the captcha at signalcaptchas.org/challenge/generate.html, then Ctrl-Y to submit it.")) + (when (and rate-limit? (not token)) + (append-system-message! + state + "Signal returned a plain rate-limit failure with no challenge token; this one is wait-only."))) (tui-state-status-set! state - (string-append base - (if (pair? hints) - (string-append " [" (join-strings hints "; ") "]") - "") - " -- " retry-hint)))) + (send-failure-status token retry-after rate-limit? retry-hint base hints)))) ;; First string value stored under `key` anywhere in the payload, or #f. (def (rpc-error-field x key) @@ -965,6 +984,61 @@ (rpc-error-field (cdr x) key))] [else #f])) + ;; First numeric value stored under `key` anywhere in the payload, or #f. + (def (rpc-error-number-field x key) + (cond + [(hashtable? x) + (let ([direct (hashtable-ref x key #f)]) + (if (number? direct) + direct + (let-values ([(_ vals) (hashtable-entries x)]) + (let loop ([i 0]) + (and (< i (vector-length vals)) + (or (rpc-error-number-field (vector-ref vals i) key) + (loop (+ i 1))))))))] + [(pair? x) + (or (rpc-error-number-field (car x) key) + (rpc-error-number-field (cdr x) key))] + [else #f])) + + (def (send-failure-status token retry-after rate-limit? retry-hint base hints) + (cond + [token + (string-append "Signal requires captcha proof. Ctrl-Y submits it; " + retry-hint)] + [retry-after + (string-append "Signal rate-limited sending. Retry after " + (number->string retry-after) + " seconds; " + retry-hint)] + [rate-limit? + (string-append "Signal rate-limited sending with no retry time; wait before retrying. " + retry-hint)] + [else + (string-append base + (if (pair? hints) + (string-append " [" (join-strings hints "; ") "]") + "") + " -- " retry-hint)])) + + (def (rate-limit-active? state) + (let ([until (tui-state-rate-limit-until state)]) + (and (number? until) + (if (> until (real-time)) + #t + (begin + (tui-state-rate-limit-until-set! state #f) + #f))))) + + (def (rate-limit-status state) + (let* ([until (tui-state-rate-limit-until state)] + [remaining (if (number? until) + (max 1 (quotient (+ (- until (real-time)) 999) 1000)) + 0)]) + (string-append "Signal asked us to wait " + (number->string remaining) + " seconds before retrying; message not sent."))) + ;; actor-call raises (error 'actor-call <message> <error-object>) where the ;; error object is the parsed JSON-RPC "error" member; fish it back out of ;; the condition irritants. #f when the failure was not an RPC error. @@ -1093,6 +1167,7 @@ (if (eq? (car outcome) 'ok) (begin (tui-state-challenge-set! state #f) + (tui-state-rate-limit-until-set! state #f) (append-system-message! state "Rate-limit challenge accepted.") (tui-state-status-set! state "Challenge accepted -- try sending again.")) @@ -1164,6 +1239,8 @@ [(not (file-exists? path)) (tui-state-status-set! state (string-append "No such file: " path))] + [(rate-limit-active? state) + (tui-state-status-set! state (rate-limit-status state))] [else ;; Leave attach mode and clear the path/caption BEFORE the call, for ;; the same reason as send-composer-message!: a "failed" reply may @@ -1185,6 +1262,7 @@ (capture-outbound! (tui-state-logdb state) (tui-state-account state) (conversation-id conv) label ts) + (tui-state-rate-limit-until-set! state #f) (conversation-unread-set! conv 0) (tui-state-status-set! state "File sent.")) (report-send-failure! state conv (cdr outcome)