floci-io/floci · error · AwsException

IdempotencyException

IdempotencyException

Error message

An idempotency token was used with a request that does not match a previous request that used that token.

What it means

ACM IdempotencyException (HTTP 400) thrown during requestCertificate idempotency lookup: a stored token entry exists and is unexpired, but computeRequestHash of the incoming request (Objects.hash of domainName, SANs as a set, keyAlgorithm) differs from the hash recorded when the token was first used. ACM requires the same token be reused only with identical request parameters.

Solutions

  1. Reuse an idempotency token only for retries of the exact same request; generate a fresh token whenever parameters change
  2. Derive the token deterministically from the request content (e.g. UUID5/sha256 of domain+SANs+algorithm) so identical params yield identical tokens and differing params never collide
  3. In tests, use unique tokens per test case or reset the emulator between parameter-changing scenarios

Example fix

// before: same static token for different requests
acm.requestCertificate(r -> r.domainName("a.example.com").idempotencyToken("tok"));
acm.requestCertificate(r -> r.domainName("b.example.com").idempotencyToken("tok")); // IdempotencyException

// after: token derived from request content
String token = UUID.nameUUIDFromBytes((domain + "|" + String.join(",", sans) + "|" + alg).getBytes()).toString();
acm.requestCertificate(r -> r.domainName(domain).idempotencyToken(token));
Defensive patterns

Strategy: validation

Validate before calling

String token = UUID.nameUUIDFromBytes(
    (domain + "|" + (sans == null ? "" : String.join(",", sans.stream().sorted().toList())) + "|" + alg)
    .getBytes(StandardCharsets.UTF_8)).toString();
// identical params -> identical token; different params -> different token, no collision

Try / catch

try {
    acm.requestCertificate(r -> r.domainName(d).idempotencyToken(tok));
} catch (IdempotencyException e) {
    // same token used with different params — mint a fresh token and retry
}

Prevention

When it happens

Trigger: requestCertificate(..., idempotencyToken=T) with domainName/SANs/keyAlgorithm differing from an earlier call that used the same token T within the token's validity window. Note SAN comparison is order-insensitive (HashSet), but adding/removing a SAN, changing the domain, or switching keyAlgorithm changes the hash.

Common situations: Retrying a failed request but with 'fixed' parameters and the same token; token generators derived from request count that collide across differing requests; test suites reusing hardcoded tokens ('token-1') across different certificate requests.

Related errors


AI-assisted analysis of floci-io/floci@62ff490619 (2026-08-14). Data as JSON: /api/errors/182fea0a8956d2b4. Report an issue: GitHub.

Appendix: source

Thrown at src/main/java/io/github/hectorvent/floci/services/acm/AcmService.java:524

     * @throws AwsException if token exists but parameters don't match (IdempotencyTokenException)
     */
    private Optional<Certificate> findByIdempotencyToken(String token, String region, int requestHash) {
        String indexKey = region + "::" + token;
        IdempotencyTokenEntry entry = idempotencyTokenIndex.get(indexKey);

        if (entry == null) {
            return Optional.empty();
        }

        // Lazy expiration: remove expired entries on lookup
        if (entry.isExpired()) {
            idempotencyTokenIndex.remove(indexKey);
            return Optional.empty();
        }

        // Validate request parameters match
        if (entry.requestHash() != requestHash) {
            throw new AwsException("IdempotencyException",
                "An idempotency token was used with a request that does not match a previous request " +
                "that used that token.", 400);
        }

        String certId = extractCertificateIdFromArn(entry.arn());
        return store.get(regionKey(region, certId));
    }

    /**
     * Computes a hash of request parameters for idempotency validation.
     * Parameters include: domainName, SANs (order-independent), keyAlgorithm.
     */
    private int computeRequestHash(String domainName, List<String> sans, KeyAlgorithm keyAlgorithm) {
        return Objects.hash(
            domainName,
            sans != null ? new HashSet<>(sans) : null,
            keyAlgorithm
        );

View on GitHub (pinned to 62ff490619)