{"record":{"id":"e7f3cc8d209f5937","repo":"signalapp/Signal-Server","slug":"encountered-a-negative-retry-duration-will-no","errorCode":null,"errorMessage":"Encountered a negative retry duration: {}, will not include a Retry-After header in response","messagePattern":"Encountered a negative retry duration: (.+?), will not include a Retry-After header in response","errorType":"http","errorClass":"RateLimitExceededException","httpStatus":429,"severity":"warning","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java","lineNumber":31,"sourceCode":"\n@Provider\npublic class RateLimitExceededExceptionMapper implements ExceptionMapper<RateLimitExceededException> {\n\n  private static final Logger logger = LoggerFactory.getLogger(RateLimitExceededExceptionMapper.class);\n\n  /**\n   * Convert a RateLimitExceededException to a 429 response\n   * with a Retry-After header.\n   *\n   * @param e A RateLimitExceededException potentially containing a recommended retry duration\n   * @return the response\n   */\n  @Override\n  public Response toResponse(RateLimitExceededException e) {\n    return e.getRetryDuration()\n        .filter(d -> {\n          if (d.isNegative()) {\n            logger.warn(\"Encountered a negative retry duration: {}, will not include a Retry-After header in response\",\n                d);\n          }\n          // only include non-negative durations in retry headers\n          return !d.isNegative();\n        })\n        .map(d -> Response.status(Response.Status.TOO_MANY_REQUESTS).header(\"Retry-After\", d.toSeconds()))\n        .orElseGet(() -> Response.status(Response.Status.TOO_MANY_REQUESTS)).build();\n  }\n}\n","sourceCodeStart":13,"sourceCodeEnd":41,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java#L13-L41","documentation":"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.","triggerScenarios":"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.","commonSituations":"Rate limiter implementations computing 'next refill minus now' with clock drift, or constructing the exception before computing the duration correctly.","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."],"exampleFix":"// before\nDuration retryDuration = Duration.between(Instant.now(), lastRefill); // negative if refilled\nthrow new RateLimitExceededException(retryDuration);\n\n// after\nDuration retryDuration = Duration.between(Instant.now(), nextRefillInstant);\nthrow new RateLimitExceededException(retryDuration.isNegative() ? Duration.ofSeconds(1) : retryDuration);","handlingStrategy":"validation","validationCode":"// before constructing the exception\nDuration d = Duration.between(Instant.now(), nextRefillInstant);\nif (d.isNegative()) { d = Duration.ZERO; } // or log and clamp\nnew RateLimitExceededException(d);","typeGuard":null,"tryCatchPattern":null,"preventionTips":["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."],"tags":["rate-limit","http-429","retry-after","duration"],"backgroundTag":"rate-limit-exceeded","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}