signalapp/Signal-Server · warning · RateLimitExceededException
Encountered a negative retry duration
Error message
Encountered a negative retry duration: {}, will not include a Retry-After header in response What it means
The RateLimitExceededException mapper builds a 429 response and includes a Retry-After header from the exception's retry duration. If that duration is negative (a caller constructed the exception with a bad/already-elapsed duration), the mapper logs a warning and simply omits the Retry-After header; the 429 is still returned.
Solutions
- Fix the rate limiter code to compute retry duration as remaining-budget divided by refill rate, clamped with Duration.ofSeconds(Math.max(1, ...)) or similar.
- Check for clock skew on the host if durations derive from wall-clock timestamps.
- Treat the warning as a bug indicator: Retry-After is being silently dropped for those 429s.
- Write a unit test asserting non-negative retry durations from every RateLimitExceededException construction site.
Example fix
// before Duration retryDuration = Duration.between(Instant.now(), lastRefill); // negative if refilled throw new RateLimitExceededException(retryDuration); // after Duration retryDuration = Duration.between(Instant.now(), nextRefillInstant); throw new RateLimitExceededException(retryDuration.isNegative() ? Duration.ofSeconds(1) : retryDuration);
Defensive patterns
Strategy: validation
Validate before calling
// before constructing the exception
Duration d = Duration.between(Instant.now(), nextRefillInstant);
if (d.isNegative()) { d = Duration.ZERO; } // or log and clamp
new RateLimitExceededException(d); Prevention
- Always compute retry durations against a monotonic source or clamp negatives to a minimum of 1 second.
- Unit-test every RateLimitExceededException construction site for non-negative durations.
- Watch server logs for this warning as a signal of inverted Duration.between arguments or clock skew.
When it happens
Trigger: Throwing RateLimitExceededException with a retry duration computed from a timestamp already in the past (e.g. clock skew, or Duration.between arguments inverted), yielding a negative Optional duration.
Common situations: Rate limiter implementations computing 'next refill minus now' with clock drift, or constructing the exception before computing the duration correctly.
Related errors
- 429 Too Many Requests (rate limit exceeded)
- 429 Too Many Requests or 409 Conflict (not allowed to…
- return Response.status(429).build();
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/e7f3cc8d209f5937.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java:31
@Provider
public class RateLimitExceededExceptionMapper implements ExceptionMapper<RateLimitExceededException> {
private static final Logger logger = LoggerFactory.getLogger(RateLimitExceededExceptionMapper.class);
/**
* Convert a RateLimitExceededException to a 429 response
* with a Retry-After header.
*
* @param e A RateLimitExceededException potentially containing a recommended retry duration
* @return the response
*/
@Override
public Response toResponse(RateLimitExceededException e) {
return e.getRetryDuration()
.filter(d -> {
if (d.isNegative()) {
logger.warn("Encountered a negative retry duration: {}, will not include a Retry-After header in response",
d);
}
// only include non-negative durations in retry headers
return !d.isNegative();
})
.map(d -> Response.status(Response.Status.TOO_MANY_REQUESTS).header("Retry-After", d.toSeconds()))
.orElseGet(() -> Response.status(Response.Status.TOO_MANY_REQUESTS)).build();
}
}
View on GitHub (pinned to 100ab61c82)