Add getRateLimitStatus JSON-RPC method

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 <noreply@anthropic.com>
This commit is contained in:
Tony Cebzanov 2026-04-16 08:26:40 -04:00
parent ddfad2c4ce
commit c979fcbb53
7 changed files with 153 additions and 1 deletions

View File

@ -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<Device> getLinkedDevices() throws IOException;
void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException;

View File

@ -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);
}
}

View File

@ -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<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,
@ -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(

View File

@ -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());

View File

@ -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());
}
}
}

View File

@ -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<Device> getLinkedDevices() throws IOException {
final var thisDevice = signal.getThisDevice();

View File

@ -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<Device> getLinkedDevices() {
return List.of();