Refactor retry after handling

This commit is contained in:
AsamK 2026-04-15 20:55:47 +02:00
parent 5bfb044245
commit 561dfc373f
13 changed files with 101 additions and 149 deletions

View File

@ -2,11 +2,11 @@ package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception {
private long nextAttemptTimestamp;
private long nextVerificationAttemptMilliseconds;
public CaptchaRequiredException(final long nextAttemptTimestamp) {
public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) {
super("Captcha required");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds;
}
public CaptchaRequiredException(final String message) {
@ -17,7 +17,7 @@ public class CaptchaRequiredException extends Exception {
super(message, cause);
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public long getNextVerificationAttemptMilliseconds() {
return nextVerificationAttemptMilliseconds;
}
}

View File

@ -10,12 +10,19 @@ public class ProofRequiredException extends Exception {
private final String token;
private final Set<Option> options;
private final long retryAfterSeconds;
private final long retryAfterMilliseconds;
public ProofRequiredException(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
this.token = e.getToken();
this.options = e.getOptions().stream().map(Option::from).collect(Collectors.toSet());
this.retryAfterSeconds = e.getRetryAfterSeconds();
public ProofRequiredException(final String token, final Set<Option> options, final long retryAfterMilliseconds) {
super("Rate limit");
this.token = token;
this.options = options;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public static ProofRequiredException from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
return new ProofRequiredException(e.getToken(),
e.getOptions().stream().map(Option::from).collect(Collectors.toSet()),
e.getRetryAfterSeconds() * 1000L);
}
public String getToken() {
@ -26,8 +33,8 @@ public class ProofRequiredException extends Exception {
return options;
}
public long getRetryAfterSeconds() {
return retryAfterSeconds;
public long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
public enum Option {

View File

@ -2,14 +2,18 @@ package org.asamk.signal.manager.api;
public class RateLimitException extends Exception {
private final long nextAttemptTimestamp;
private final Long retryAfterMilliseconds;
public RateLimitException(final long nextAttemptTimestamp) {
public RateLimitException(final Long retryAfterMilliseconds) {
super("Rate limit");
this.nextAttemptTimestamp = nextAttemptTimestamp;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public long getNextAttemptTimestamp() {
return nextAttemptTimestamp;
public static RateLimitException from(org.whispersystems.signalservice.api.push.exceptions.RateLimitException e) {
return new RateLimitException(e.getRetryAfterMilliseconds().orElse(null));
}
public Long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
}
}

View File

@ -9,40 +9,13 @@ public record SendMessageResult(
boolean isNetworkFailure,
boolean isUnregisteredFailure,
boolean isIdentityFailure,
boolean isRateLimitFailure,
RateLimitException rateLimitException,
ProofRequiredException proofRequiredFailure,
boolean isInvalidPreKeyFailure,
Long rateLimitRetryAfterSeconds
boolean isInvalidPreKeyFailure
) {
/**
* Source-compatible constructor for callers built against the pre-retry-after record shape.
* Delegates to the canonical constructor with a null retry-after, which is the correct value
* for any result not produced by {@link #from}.
*/
public SendMessageResult(
RecipientAddress address,
boolean isSuccess,
boolean isNetworkFailure,
boolean isUnregisteredFailure,
boolean isIdentityFailure,
boolean isRateLimitFailure,
ProofRequiredException proofRequiredFailure,
boolean isInvalidPreKeyFailure
) {
this(address,
isSuccess,
isNetworkFailure,
isUnregisteredFailure,
isIdentityFailure,
isRateLimitFailure,
proofRequiredFailure,
isInvalidPreKeyFailure,
null);
}
public static SendMessageResult unregisteredFailure(RecipientAddress address) {
return new SendMessageResult(address, false, false, true, false, false, null, false, null);
return new SendMessageResult(address, false, false, true, false, null, null, false);
}
public static SendMessageResult from(
@ -52,30 +25,28 @@ public record SendMessageResult(
) {
final var rateLimitFailure = sendMessageResult.getRateLimitFailure();
final var proofRequiredFailure = sendMessageResult.getProofRequiredFailure();
final Long retryAfterSeconds;
if (proofRequiredFailure != null) {
retryAfterSeconds = proofRequiredFailure.getRetryAfterSeconds();
} else if (rateLimitFailure != null) {
retryAfterSeconds = rateLimitFailure.getRetryAfterMilliseconds()
.map(SendMessageResult::millisToCeilingSeconds)
.orElse(null);
} else {
retryAfterSeconds = null;
}
return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
sendMessageResult.getAddress())).toApiRecipientAddress(),
sendMessageResult.isSuccess(),
sendMessageResult.isNetworkFailure(),
sendMessageResult.isUnregisteredFailure(),
sendMessageResult.getIdentityFailure() != null,
rateLimitFailure != null || proofRequiredFailure != null,
proofRequiredFailure == null ? null : new ProofRequiredException(proofRequiredFailure),
sendMessageResult.isInvalidPreKeyFailure(),
retryAfterSeconds);
rateLimitFailure == null ? null : RateLimitException.from(rateLimitFailure),
proofRequiredFailure == null ? null : ProofRequiredException.from(proofRequiredFailure),
sendMessageResult.isInvalidPreKeyFailure());
}
static long millisToCeilingSeconds(long millis) {
// Round up so we never advise a retry before the server's deadline.
return (millis + 999L) / 1000L;
public boolean isRateLimitFailure() {
return this.rateLimitException != null || this.proofRequiredFailure != null;
}
public Long rateLimitRetryAfterMilliseconds() {
if (proofRequiredFailure != null) {
return proofRequiredFailure.getRetryAfterMilliseconds();
} else if (rateLimitException != null) {
return rateLimitException.getRetryAfterMilliseconds();
} else {
return null;
}
}
}

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) {
@ -28,15 +29,15 @@ public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<S
}
/**
* Longest rate-limit retry-after window across all rate-limited recipients, in seconds.
* Longest rate-limit retry-after window across all rate-limited recipients, in milliseconds.
* Null when no recipient reported one (server omitted Retry-After, or no rate-limit failures).
*/
public Long maxRateLimitRetryAfterSeconds() {
public Long maxRateLimitRetryAfterMilliseconds() {
return results.values()
.stream()
.flatMap(List::stream)
.map(SendMessageResult::rateLimitRetryAfterSeconds)
.filter(r -> r != null)
.map(SendMessageResult::rateLimitRetryAfterMilliseconds)
.filter(Objects::nonNull)
.max(Long::compareTo)
.orElse(null);
}

View File

@ -278,7 +278,7 @@ public class ManagerImpl implements Manager {
registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet);
} catch (CdsiResourceExhaustedException e) {
logger.debug("CDSI resource exhausted: {}", e.getMessage());
throw new RateLimitException(System.currentTimeMillis() + e.getRetryAfterSeconds() * 1000L);
throw new RateLimitException(e.getRetryAfterSeconds() * 1000L);
}
return numbers.stream().collect(Collectors.toMap(n -> n, n -> {

View File

@ -65,14 +65,12 @@ public class NumberVerificationUtils {
if (nextAttempt == null) {
throw new VerificationMethodNotAvailableException();
} else if (nextAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextAttempt * 1000;
throw new RateLimitException(timestamp);
throw new RateLimitException(nextAttempt * 1000L);
}
final var nextVerificationAttempt = sessionResponse.getMetadata().getNextVerificationAttempt();
if (nextVerificationAttempt != null && nextVerificationAttempt > 0) {
final var timestamp = sessionResponse.getClientReceivedAtMilliseconds() + nextVerificationAttempt * 1000;
throw new CaptchaRequiredException(timestamp);
throw new CaptchaRequiredException(nextVerificationAttempt * 1000L);
}
if (sessionResponse.getMetadata().getRequestedInformation().contains("captcha")) {

View File

@ -1,38 +0,0 @@
package org.asamk.signal.manager.api;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class SendMessageResultTest {
/**
* Ceiling division we must never advise a retry before the server's deadline,
* so sub-second values round up rather than truncate toward zero.
*/
@Test
void millisToCeilingSecondsRoundsUp() {
assertEquals(0L, SendMessageResult.millisToCeilingSeconds(0L));
assertEquals(1L, SendMessageResult.millisToCeilingSeconds(1L));
assertEquals(1L, SendMessageResult.millisToCeilingSeconds(500L));
assertEquals(1L, SendMessageResult.millisToCeilingSeconds(999L));
assertEquals(1L, SendMessageResult.millisToCeilingSeconds(1000L));
assertEquals(2L, SendMessageResult.millisToCeilingSeconds(1001L));
assertEquals(2L, SendMessageResult.millisToCeilingSeconds(1500L));
assertEquals(60L, SendMessageResult.millisToCeilingSeconds(60_000L));
}
/**
* Source-compat: callers built against the pre-retry-after record shape use the 8-arg
* constructor. It must continue to compile and produce a record with a null retry-after.
*/
@Test
@SuppressWarnings("deprecation")
void legacyEightArgConstructorPreservesSourceCompat() {
var result = new SendMessageResult(new RecipientAddress(null, null, "+15551234567", null),
true, false, false, false, false, null, false);
assertNull(result.rateLimitRetryAfterSeconds());
}
}

View File

@ -700,9 +700,13 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
} catch (RateLimitException e) {
throw new Error.Failure(e.getMessage()
+ ", retry at "
+ DateUtils.formatTimestamp(e.getNextAttemptTimestamp()));
final var retryAfterMilliseconds = e.getRetryAfterMilliseconds();
throw new Error.Failure(e.getMessage() + (
retryAfterMilliseconds == null
? ""
: ", retry at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds)
));
}
return numbers.stream().map(number -> registered.get(number).uuid() != null).toList();

View File

@ -20,6 +20,7 @@ public record JsonSendMessageResult(
}
public static JsonSendMessageResult from(SendMessageResult result, GroupId groupId) {
final var rateLimitRetryAfterMilliseconds = result.rateLimitRetryAfterMilliseconds();
return new JsonSendMessageResult(JsonRecipientAddress.from(result.address()),
groupId != null ? groupId.toBase64() : null,
result.isSuccess()
@ -34,7 +35,7 @@ public record JsonSendMessageResult(
? Type.INVALID_PRE_KEY_FAILURE
: Type.IDENTITY_FAILURE,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
result.rateLimitRetryAfterSeconds());
rateLimitRetryAfterMilliseconds == null ? null : Math.ceilDiv(rateLimitRetryAfterMilliseconds, 1000L));
}
public enum Type {

View File

@ -129,16 +129,19 @@ public class CommandUtil {
} else {
message = "Invalid captcha given.";
}
if (e.getNextAttemptTimestamp() > 0) {
message += "\nNext Captcha may be provided at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
if (e.getNextVerificationAttemptMilliseconds() > 0) {
message += "\nNext Captcha may be provided at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ e.getNextVerificationAttemptMilliseconds());
}
return message;
}
public static String getRateLimitMessage(final RateLimitException e) {
String message = "Rate limit reached";
if (e.getNextAttemptTimestamp() > 0) {
message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(e.getNextAttemptTimestamp());
final var retryAfterMilliseconds = e.getRetryAfterMilliseconds();
if (retryAfterMilliseconds != null) {
message += "\nNext attempt may be tried at " + DateUtils.formatTimestamp(System.currentTimeMillis()
+ retryAfterMilliseconds);
}
return message;
}

View File

@ -59,8 +59,8 @@ public class SendMessageResultUtils {
if (sendMessageResults.hasOnlyUntrustedIdentity()) {
throw new UntrustedKeyErrorException("Failed to send message due to untrusted identities");
} else if (sendMessageResults.hasOnlyRateLimitFailure()) {
final var retryAfter = sendMessageResults.maxRateLimitRetryAfterSeconds();
final var nextAttempt = retryAfter == null ? 0L : System.currentTimeMillis() + retryAfter * 1000L;
final var retryAfter = sendMessageResults.maxRateLimitRetryAfterMilliseconds();
final var nextAttempt = retryAfter == null ? 0L : System.currentTimeMillis() + retryAfter;
throw new RateLimitErrorException("Failed to send message due to rate limiting",
new RateLimitException(nextAttempt));
} else {
@ -108,14 +108,14 @@ public class SendMessageResultUtils {
.map(ProofRequiredException.Option::toString)
.collect(Collectors.joining(", ")),
failure.getToken(),
failure.getRetryAfterSeconds());
Math.ceilDiv(failure.getRetryAfterMilliseconds(), 1000L));
} else if (result.isNetworkFailure()) {
return String.format("Network failure for \"%s\"", identifier);
} else if (result.isRateLimitFailure()) {
final var retryAfter = result.rateLimitRetryAfterSeconds();
return retryAfter != null
? String.format("Rate limit failure for \"%s\", retry after %d seconds", identifier, retryAfter)
: String.format("Rate limit failure for \"%s\"", identifier);
final var retryAfter = result.rateLimitRetryAfterMilliseconds();
return retryAfter != null ? String.format("Rate limit failure for \"%s\", retry after %d seconds",
identifier,
Math.ceilDiv(retryAfter, 1000L)) : String.format("Rate limit failure for \"%s\"", identifier);
} else if (result.isUnregisteredFailure()) {
return String.format("Unregistered user \"%s\"", identifier);
} else if (result.isIdentityFailure()) {

View File

@ -1,5 +1,6 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.RateLimitException;
import org.asamk.signal.manager.api.RecipientAddress;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendMessageResult;
@ -20,11 +21,13 @@ class JsonSendMessageResultTest {
@Test
void rateLimitFailureSurfacesRetryAfterSeconds() {
var result = new SendMessageResult(ADDRESS,
false, false, false, false,
true,
null,
false,
3600L);
false,
false,
false,
new RateLimitException(3600_000L),
null,
false);
var json = JsonSendMessageResult.from(result);
@ -36,11 +39,13 @@ class JsonSendMessageResultTest {
@Test
void rateLimitFailureWithoutRetryAfterLeavesFieldNull() {
var result = new SendMessageResult(ADDRESS,
false, false, false, false,
true,
null,
false,
null);
false,
false,
false,
new RateLimitException(null),
null,
false);
var json = JsonSendMessageResult.from(result);
@ -50,12 +55,7 @@ class JsonSendMessageResultTest {
@Test
void successLeavesRetryAfterNull() {
var result = new SendMessageResult(ADDRESS,
true, false, false, false,
false,
null,
false,
null);
var result = new SendMessageResult(ADDRESS, true, false, false, false, null, null, false);
var json = JsonSendMessageResult.from(result);
@ -72,16 +72,15 @@ class JsonSendMessageResultTest {
var aggregate = new SendMessageResults(1L,
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()), List.of(small, big, unknown)));
assertEquals(3600L, aggregate.maxRateLimitRetryAfterSeconds());
assertEquals(3600L, aggregate.maxRateLimitRetryAfterMilliseconds());
}
@Test
void aggregateReturnsNullWhenNoRetryAfter() {
var aggregate = new SendMessageResults(1L,
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()),
List.of(rateLimited("+15551234567", null))));
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()), List.of(rateLimited("+15551234567", null))));
assertNull(aggregate.maxRateLimitRetryAfterSeconds());
assertNull(aggregate.maxRateLimitRetryAfterMilliseconds());
}
/**
@ -99,15 +98,17 @@ class JsonSendMessageResultTest {
Map.of(new RecipientIdentifier.Uuid(UUID.randomUUID()),
List.of(withoutValue, withValue, alsoWithValue)));
assertEquals(7200L, aggregate.maxRateLimitRetryAfterSeconds());
assertEquals(7200L, aggregate.maxRateLimitRetryAfterMilliseconds());
}
private static SendMessageResult rateLimited(String number, Long retryAfterSeconds) {
return new SendMessageResult(new RecipientAddress(null, null, number, null),
false, false, false, false,
true,
null,
false,
retryAfterSeconds);
false,
false,
false,
new RateLimitException(retryAfterSeconds),
null,
false);
}
}