Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [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.
## [0.14.4] - 2026-05-23

### Added
Expand Down
7 changes: 7 additions & 0 deletions lib/src/main/java/org/asamk/signal/manager/Manager.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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;
Expand Down Expand Up @@ -156,6 +157,12 @@ void submitRateLimitRecaptchaChallenge(
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<Device> getLinkedDevices() throws IOException;

void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.asamk.signal.manager.api;

/**
* Snapshot of the most recent rate-limit state observed from send results.
*
* <p>{@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.
*
* <p>{@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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
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;
Expand Down Expand Up @@ -175,6 +176,10 @@ public class ManagerImpl implements Manager {
private final List<Runnable> addressChangedListeners = new ArrayList<>();
private final CompositeDisposable disposable = new CompositeDisposable();
private final AtomicLong lastMessageTimestamp = new AtomicLong();
private final java.util.concurrent.atomic.AtomicReference<RateLimitSnapshot> 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,
Expand Down Expand Up @@ -469,6 +474,27 @@ public void submitRateLimitRecaptchaChallenge(
} 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
Expand Down Expand Up @@ -721,7 +747,21 @@ && new RecipientAddress(single.toPartialRecipientAddress()).matches(account.getS
}

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(
Expand Down
14 changes: 14 additions & 0 deletions man/signal-cli.1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/asamk/signal/commands/Commands.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
6 changes: 6 additions & 0 deletions src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,12 @@ public void submitRateLimitRecaptchaChallenge(final String challenge, final Stri
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<Device> getLinkedDevices() throws IOException {
final var thisDevice = signal.getThisDevice();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ public void deleteAccount() {
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<Device> getLinkedDevices() {
return List.of();
Expand Down
Loading