From c979fcbb5380169e5ff18c2d3dfad83885a7b539 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 16 Apr 2026 08:26:40 -0400 Subject: [PATCH 1/2] Add getRateLimitStatus JSON-RPC method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only query returning the most recent rate-limit state observed from send results. Callers can check whether the account is currently rate-limited, how long until retry, and whether a captcha challenge is required — without having to trigger a send and inspect the response, and without having to reconstruct state themselves. Useful for: - Admin UIs that want to display current rate-limit state on load - Monitoring/alerting (poll for active rate limits) - Clients that restart and would otherwise lose tracked state The status is tracked in-memory on the Manager: updated whenever a SendMessageResult reports a rate-limit failure, cleared after a successful captcha submission, and auto-expires when the retry-after window elapses (getRateLimitStatus returns active=false once the current time is past expiresAtEpochSeconds). JSON-RPC response shape: { "active": bool, "proofRequired": bool, "retryAfterSeconds": long, // omitted if not active "challengeToken": string, // omitted unless proofRequired "expiresAtEpochSeconds": long // omitted if not active } D-Bus is not updated to expose this state; DbusManagerImpl returns inactive as a stub since D-Bus clients already observe rate-limit state via send results. Co-Authored-By: Claude Sonnet 4.6 --- .../org/asamk/signal/manager/Manager.java | 7 ++ .../signal/manager/api/RateLimitStatus.java | 26 +++++++ .../signal/manager/internal/ManagerImpl.java | 42 +++++++++++- .../org/asamk/signal/commands/Commands.java | 1 + .../commands/GetRateLimitStatusCommand.java | 67 +++++++++++++++++++ .../asamk/signal/dbus/DbusManagerImpl.java | 6 ++ .../jsonrpc/SubscribeCallEventsTest.java | 5 ++ 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/RateLimitStatus.java create mode 100644 src/main/java/org/asamk/signal/commands/GetRateLimitStatusCommand.java diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index fda3a323..39a4fc29 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -35,6 +35,7 @@ import org.asamk.signal.manager.api.PendingAdminApprovalException; import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.RateLimitException; +import org.asamk.signal.manager.api.RateLimitStatus; import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientIdentifier; @@ -156,6 +157,12 @@ public interface Manager extends Closeable { String captcha ) throws IOException, CaptchaRejectedException; + /** + * Return the most recent rate-limit state observed from send results, or an inactive + * status if no rate-limit has been seen (or the previous window has elapsed). + */ + RateLimitStatus getRateLimitStatus(); + List getLinkedDevices() throws IOException; void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException; diff --git a/lib/src/main/java/org/asamk/signal/manager/api/RateLimitStatus.java b/lib/src/main/java/org/asamk/signal/manager/api/RateLimitStatus.java new file mode 100644 index 00000000..231f7ec0 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/RateLimitStatus.java @@ -0,0 +1,26 @@ +package org.asamk.signal.manager.api; + +/** + * Snapshot of the most recent rate-limit state observed from send results. + * + *

{@code active} is true while the current system time is still inside the retry-after + * window. When the window elapses, callers see {@code active = false} without needing to + * clear the state themselves. + * + *

{@code proofRequired} distinguishes a plain HTTP 413 rate limit (resolved by waiting) + * from a HTTP 428 challenge (requires captcha submission via + * {@code submitRateLimitChallenge}). When {@code proofRequired} is true, + * {@code challengeToken} is populated. + */ +public record RateLimitStatus( + boolean active, + boolean proofRequired, + Long retryAfterSeconds, + String challengeToken, + Long expiresAtEpochSeconds +) { + + public static RateLimitStatus inactive() { + return new RateLimitStatus(false, false, null, null, null); + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index c2aba751..4952adbd 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -54,6 +54,7 @@ import org.asamk.signal.manager.api.PinLockMissingException; import org.asamk.signal.manager.api.PinLockedException; import org.asamk.signal.manager.api.Profile; import org.asamk.signal.manager.api.RateLimitException; +import org.asamk.signal.manager.api.RateLimitStatus; import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientIdentifier; @@ -175,6 +176,10 @@ public class ManagerImpl implements Manager { private final List addressChangedListeners = new ArrayList<>(); private final CompositeDisposable disposable = new CompositeDisposable(); private final AtomicLong lastMessageTimestamp = new AtomicLong(); + private final java.util.concurrent.atomic.AtomicReference rateLimitSnapshot = new java.util.concurrent.atomic.AtomicReference<>(); + + /** Internal snapshot of a rate-limit event — captured from send results, read via getRateLimitStatus(). */ + private record RateLimitSnapshot(long expiresAtEpochMs, String challengeToken) {} public ManagerImpl( SignalAccount account, @@ -468,6 +473,27 @@ public class ManagerImpl implements Manager { } catch (org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException ignored) { throw new CaptchaRejectedException(); } + rateLimitSnapshot.set(null); + } + + @Override + public RateLimitStatus getRateLimitStatus() { + final var snapshot = rateLimitSnapshot.get(); + if (snapshot == null) { + return RateLimitStatus.inactive(); + } + final var remainingMs = snapshot.expiresAtEpochMs() - System.currentTimeMillis(); + if (remainingMs <= 0) { + rateLimitSnapshot.compareAndSet(snapshot, null); + return RateLimitStatus.inactive(); + } + final var retryAfterSeconds = (remainingMs + 999L) / 1000L; + final var expiresAtEpochSeconds = (snapshot.expiresAtEpochMs() + 999L) / 1000L; + return new RateLimitStatus(true, + snapshot.challengeToken() != null, + retryAfterSeconds, + snapshot.challengeToken(), + expiresAtEpochSeconds); } @Override @@ -720,7 +746,21 @@ public class ManagerImpl implements Manager { } private SendMessageResult toSendMessageResult(final org.whispersystems.signalservice.api.messages.SendMessageResult result) { - return SendMessageResult.from(result, account.getRecipientResolver(), account.getRecipientAddressResolver()); + final var apiResult = SendMessageResult.from(result, + account.getRecipientResolver(), + account.getRecipientAddressResolver()); + recordRateLimitState(apiResult); + return apiResult; + } + + private void recordRateLimitState(final SendMessageResult apiResult) { + if (!apiResult.isRateLimitFailure() || apiResult.rateLimitRetryAfterMilliseconds() == null) { + return; + } + final var proofRequired = apiResult.proofRequiredFailure(); + final var token = proofRequired == null ? null : proofRequired.getToken(); + final var expiresAt = System.currentTimeMillis() + apiResult.rateLimitRetryAfterMilliseconds(); + rateLimitSnapshot.set(new RateLimitSnapshot(expiresAt, token)); } private SendMessageResults sendTypingMessage( diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index 05dc1ff4..4c112686 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -20,6 +20,7 @@ public class Commands { addCommand(new HangupCallCommand()); addCommand(new GetAttachmentCommand()); addCommand(new GetAvatarCommand()); + addCommand(new GetRateLimitStatusCommand()); addCommand(new GetStickerCommand()); addCommand(new GetUserStatusCommand()); addCommand(new AddStickerPackCommand()); diff --git a/src/main/java/org/asamk/signal/commands/GetRateLimitStatusCommand.java b/src/main/java/org/asamk/signal/commands/GetRateLimitStatusCommand.java new file mode 100644 index 00000000..d31ede42 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/GetRateLimitStatusCommand.java @@ -0,0 +1,67 @@ +package org.asamk.signal.commands; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.RateLimitStatus; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; + +public class GetRateLimitStatusCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "getRateLimitStatus"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help( + "Return the current rate-limit state for this account, or an inactive status if no rate limit is active."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var status = m.getRateLimitStatus(); + switch (outputWriter) { + case JsonWriter writer -> writer.write(JsonRateLimitStatus.from(status)); + case PlainTextWriter writer -> { + if (!status.active()) { + writer.println("Not rate limited"); + } else if (status.proofRequired()) { + writer.println("Rate limited (proof required), retry after {}s, challenge token: {}", + status.retryAfterSeconds(), + status.challengeToken()); + } else { + writer.println("Rate limited, retry after {}s", status.retryAfterSeconds()); + } + } + } + } + + private record JsonRateLimitStatus( + boolean active, + boolean proofRequired, + @JsonInclude(JsonInclude.Include.NON_NULL) Long retryAfterSeconds, + @JsonInclude(JsonInclude.Include.NON_NULL) String challengeToken, + @JsonInclude(JsonInclude.Include.NON_NULL) Long expiresAtEpochSeconds + ) { + + static JsonRateLimitStatus from(RateLimitStatus status) { + return new JsonRateLimitStatus(status.active(), + status.proofRequired(), + status.retryAfterSeconds(), + status.challengeToken(), + status.expiresAtEpochSeconds()); + } + } +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index faf099e4..33ffb3cc 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -237,6 +237,12 @@ public class DbusManagerImpl implements Manager { signal.submitRateLimitChallenge(challenge, captcha); } + @Override + public org.asamk.signal.manager.api.RateLimitStatus getRateLimitStatus() { + // D-Bus does not currently expose rate-limit state; clients should observe send results instead. + return org.asamk.signal.manager.api.RateLimitStatus.inactive(); + } + @Override public List getLinkedDevices() throws IOException { final var thisDevice = signal.getThisDevice(); diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 37c00829..8eaf4a72 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -186,6 +186,11 @@ class SubscribeCallEventsTest { public void submitRateLimitRecaptchaChallenge(String c, String cap) { } + @Override + public org.asamk.signal.manager.api.RateLimitStatus getRateLimitStatus() { + return org.asamk.signal.manager.api.RateLimitStatus.inactive(); + } + @Override public List getLinkedDevices() { return List.of(); From 3dbe3fba50f2c2e59a5c8c2c80aec8d07a96b367 Mon Sep 17 00:00:00 2001 From: Tony Cebzanov Date: Thu, 16 Apr 2026 14:50:48 -0400 Subject: [PATCH 2/2] Document getRateLimitStatus in man page and changelog Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++++ man/signal-cli.1.adoc | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44478806..eb884597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `getRateLimitStatus` JSON-RPC/CLI command returning the current rate-limit state for the account (active flag, `retryAfterSeconds`, `challengeToken`, `expiresAtEpochSeconds`). Useful for admin UIs, monitoring, and clients that want to query current state without triggering a send. + ### Changed - Send message results now surface server-advised retry time for plain rate-limit (HTTP 413) failures, not only for proof-required challenges. The `retryAfterSeconds` field in JSON-RPC `SendMessageResult` is populated whenever the server sends a `Retry-After` header. The canonical way to distinguish proof-required failures remains `token != null`. Text output includes "retry after N seconds" when known. diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index e349a2ad..22b78cfd 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -1119,6 +1119,20 @@ The challenge token from the failed send attempt. *--captcha* CAPTCHA:: The captcha result, starting with signalcaptcha:// +=== getRateLimitStatus + +Return the current rate-limit state for this account, or an inactive status if no rate limit is active. + +State is tracked from observed send results: the status becomes active when a send fails with a rate-limit error, clears after a successful `submitRateLimitChallenge`, and auto-expires when the server-advised retry-after deadline passes. + +With JSON output the response has the following fields: + +- `active`: boolean, true if a rate-limit window is currently active +- `proofRequired`: boolean, true if a captcha challenge must be solved via `submitRateLimitChallenge` +- `retryAfterSeconds`: seconds remaining until the limit expires (omitted when `active` is false) +- `challengeToken`: the challenge token to pass to `submitRateLimitChallenge` (only present when `proofRequired` is true) +- `expiresAtEpochSeconds`: Unix timestamp when the rate-limit window expires (omitted when `active` is false) + === version Show version information.