diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java
index 97449416..98497745 100644
--- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java
+++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java
@@ -16,7 +16,9 @@ package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.net.URL;
+import java.time.Duration;
import java.time.Instant;
+import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@@ -32,7 +34,7 @@ import org.slf4j.LoggerFactory;
/**
* Represents an authorization request at the ACME server.
*/
-public class Authorization extends AcmeJsonResource {
+public class Authorization extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = -3116928998379417741L;
private static final Logger LOG = LoggerFactory.getLogger(Authorization.class);
@@ -60,6 +62,7 @@ public class Authorization extends AcmeJsonResource {
* {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED},
* {@link Status#REVOKED}.
*/
+ @Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
@@ -151,6 +154,24 @@ public class Authorization extends AcmeJsonResource {
});
}
+ /**
+ * Waits until the authorization is completed.
+ *
+ * Is is completed if it reaches either {@link Status#VALID} or
+ * {@link Status#INVALID}.
+ *
+ * This method is synchronous and blocks the current thread.
+ *
+ * @param timeout
+ * Timeout until a terminal status must have been reached
+ * @return Status that was reached
+ * @since 3.4.0
+ */
+ public Status waitForCompletion(Duration timeout)
+ throws AcmeException, InterruptedException {
+ return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
+ }
+
/**
* Permanently deactivates the {@link Authorization}.
*/
diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java
index 0e79e45e..13ca8ccf 100644
--- a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java
+++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java
@@ -22,6 +22,7 @@ import java.net.URL;
import java.security.KeyPair;
import java.time.Duration;
import java.time.Instant;
+import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
@@ -41,7 +42,7 @@ import org.slf4j.LoggerFactory;
/**
* A representation of a certificate order at the CA.
*/
-public class Order extends AcmeJsonResource {
+public class Order extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = 5435808648658292177L;
private static final Logger LOG = LoggerFactory.getLogger(Order.class);
@@ -60,6 +61,7 @@ public class Order extends AcmeJsonResource {
* {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}.
* If the server supports STAR, another possible value is {@link Status#CANCELED}.
*/
+ @Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
@@ -186,6 +188,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair, Consumer)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
+ * @see #waitUntilReady(Duration)
+ * @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(KeyPair domainKeyPair) throws AcmeException {
@@ -208,6 +212,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
+ * @see #waitUntilReady(Duration)
+ * @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(KeyPair domainKeyPair, Consumer builderConsumer) throws AcmeException {
@@ -235,6 +241,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair)
* @see #execute(KeyPair, Consumer)
* @see #execute(byte[])
+ * @see #waitUntilReady(Duration)
+ * @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(PKCS10CertificationRequest csr) throws AcmeException {
@@ -256,6 +264,8 @@ public class Order extends AcmeJsonResource {
* @param csr
* Binary representation of a CSR containing the parameters for the
* certificate being requested, in DER format
+ * @see #waitUntilReady(Duration)
+ * @see #waitForCompletion(Duration)
*/
public void execute(byte[] csr) throws AcmeException {
LOG.debug("finalize");
@@ -268,6 +278,43 @@ public class Order extends AcmeJsonResource {
invalidate();
}
+ /**
+ * Waits until the order is ready for finalization.
+ *
+ * Is is ready if it reaches {@link Status#READY}. The method will also return if the
+ * order already has another terminal state, which is either {@link Status#VALID} or
+ * {@link Status#INVALID}.
+ *
+ * This method is synchronous and blocks the current thread.
+ *
+ * @param timeout
+ * Timeout until a terminal status must have been reached
+ * @return Status that was reached
+ * @since 3.4.0
+ */
+ public Status waitUntilReady(Duration timeout)
+ throws AcmeException, InterruptedException {
+ return waitForStatus(EnumSet.of(Status.READY, Status.VALID, Status.INVALID), timeout);
+ }
+
+ /**
+ * Waits until the order finalization is completed.
+ *
+ * Is is completed if it reaches either {@link Status#VALID} or
+ * {@link Status#INVALID}.
+ *
+ * This method is synchronous and blocks the current thread.
+ *
+ * @param timeout
+ * Timeout until a terminal status must have been reached
+ * @return Status that was reached
+ * @since 3.4.0
+ */
+ public Status waitForCompletion(Duration timeout)
+ throws AcmeException, InterruptedException {
+ return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
+ }
+
/**
* Checks if this order is auto-renewing, according to the ACME STAR specifications.
*
diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java
new file mode 100644
index 00000000..b9324d75
--- /dev/null
+++ b/acme4j-client/src/main/java/org/shredzone/acme4j/PollableResource.java
@@ -0,0 +1,107 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2024 Richard "Shred" Körber
+ * http://acme4j.shredzone.org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+package org.shredzone.acme4j;
+
+import static java.time.Instant.now;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import org.shredzone.acme4j.exception.AcmeException;
+
+/**
+ * Marks an ACME Resource with a pollable status.
+ *
+ * The resource provides a status, and a method for updating the internal cache to read
+ * the current status from the server.
+ *
+ * @since 3.4.0
+ */
+public interface PollableResource {
+
+ /**
+ * Default delay between status polls if there is no Retry-After header.
+ */
+ Duration DEFAULT_RETRY_AFTER = Duration.ofSeconds(3L);
+
+ /**
+ * Returns the current status of the resource.
+ */
+ Status getStatus();
+
+ /**
+ * Fetches the current status from the server.
+ *
+ * @return Retry-After time, if given by the CA, otherwise empty.
+ */
+ Optional fetch() throws AcmeException;
+
+ /**
+ * Waits until a terminal status has been reached, by polling until one of the given
+ * status or the given timeout has been reached. This call honors the Retry-After
+ * header if set by the CA.
+ *
+ * This method is synchronous and blocks the current thread.
+ *
+ * If the resource is already in a terminal status, the method returns immediately.
+ *
+ * @param statusSet
+ * Set of {@link Status} that are accepted as terminal
+ * @param timeout
+ * Timeout until a terminal status must have been reached
+ * @return Status that was reached
+ */
+ default Status waitForStatus(Set statusSet, Duration timeout)
+ throws AcmeException, InterruptedException {
+ Objects.requireNonNull(timeout, "timeout");
+ Objects.requireNonNull(statusSet, "statusSet");
+ if (statusSet.isEmpty()) {
+ throw new IllegalArgumentException("At least one Status is required");
+ }
+
+ var currentStatus = getStatus();
+ if (statusSet.contains(currentStatus)) {
+ return currentStatus;
+ }
+
+ var timebox = now().plus(timeout);
+ Instant now;
+
+ while ((now = now()).isBefore(timebox)) {
+ // Poll status and get the time of the next poll
+ var retryAfter = fetch()
+ .orElse(now.plus(DEFAULT_RETRY_AFTER));
+
+ currentStatus = getStatus();
+ if (statusSet.contains(currentStatus)) {
+ return currentStatus;
+ }
+
+ // Preemptively end the loop if the next iteration would be after timebox
+ if (retryAfter.isAfter(timebox)) {
+ break;
+ }
+
+ // Wait until retryAfter is reached
+ Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
+ }
+
+ throw new AcmeException("Timeout has been reached");
+ }
+
+}
diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java
index fc70afc0..a05640ef 100644
--- a/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java
+++ b/acme4j-client/src/main/java/org/shredzone/acme4j/challenge/Challenge.java
@@ -13,11 +13,14 @@
*/
package org.shredzone.acme4j.challenge;
+import java.time.Duration;
import java.time.Instant;
+import java.util.EnumSet;
import java.util.Optional;
import org.shredzone.acme4j.AcmeJsonResource;
import org.shredzone.acme4j.Login;
+import org.shredzone.acme4j.PollableResource;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeException;
@@ -37,7 +40,7 @@ import org.slf4j.LoggerFactory;
* own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all
* required data to the challenge response.
*/
-public class Challenge extends AcmeJsonResource {
+public class Challenge extends AcmeJsonResource implements PollableResource {
private static final long serialVersionUID = 2338794776848388099L;
private static final Logger LOG = LoggerFactory.getLogger(Challenge.class);
@@ -76,6 +79,7 @@ public class Challenge extends AcmeJsonResource {
* A challenge is only completed when it reaches either status {@link Status#VALID} or
* {@link Status#INVALID}.
*/
+ @Override
public Status getStatus() {
return getJSON().get(KEY_STATUS).asStatus();
}
@@ -155,6 +159,8 @@ public class Challenge extends AcmeJsonResource {
* If this method is invoked a second time, the ACME server is requested to retry the
* validation. This can be useful if the client state has changed, for example after a
* firewall rule has been updated.
+ *
+ * @see #waitForCompletion(Duration)
*/
public void trigger() throws AcmeException {
LOG.debug("trigger");
@@ -167,4 +173,22 @@ public class Challenge extends AcmeJsonResource {
}
}
+ /**
+ * Waits until the challenge is completed.
+ *
+ * Is is completed if it reaches either {@link Status#VALID} or
+ * {@link Status#INVALID}.
+ *
+ * This method is synchronous and blocks the current thread.
+ *
+ * @param timeout
+ * Timeout until a terminal status must have been reached
+ * @return Status that was reached
+ * @since 3.4.0
+ */
+ public Status waitForCompletion(Duration timeout)
+ throws AcmeException, InterruptedException {
+ return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
+ }
+
}
diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java
index 14a40e2c..1168d1ee 100644
--- a/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java
+++ b/acme4j-example/src/main/java/org/shredzone/acme4j/example/ClientTest.java
@@ -21,13 +21,10 @@ import java.net.URI;
import java.net.URL;
import java.security.KeyPair;
import java.security.Security;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
+import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
-import java.util.EnumSet;
import java.util.Optional;
-import java.util.Set;
import java.util.function.Supplier;
import javax.swing.JOptionPane;
@@ -101,8 +98,8 @@ public class ClientTest {
//Challenge type to be used
private static final ChallengeType CHALLENGE_TYPE = ChallengeType.HTTP;
- // Maximum attempts of status polling until VALID/INVALID is expected
- private static final int MAX_ATTEMPTS = 50;
+ // Maximum time to wait until VALID/INVALID is expected
+ private static final Duration TIMEOUT = Duration.ofSeconds(60L);
private static final Logger LOG = LoggerFactory.getLogger(ClientTest.class);
@@ -115,7 +112,7 @@ public class ClientTest {
* @param domains
* Domains to get a common certificate for
*/
- public void fetchCertificate(Collection domains) throws IOException, AcmeException {
+ public void fetchCertificate(Collection domains) throws IOException, AcmeException, InterruptedException {
// Load the user key file. If there is no key file, create a new one.
KeyPair userKeyPair = loadOrCreateUserKeyPair();
@@ -137,16 +134,18 @@ public class ClientTest {
authorize(auth);
}
+ // Wait for the order to become READY
+ order.waitUntilReady(TIMEOUT);
+
// Order the certificate
order.execute(domainKeyPair);
// Wait for the order to complete
- Status status = waitForCompletion(order::getStatus, order::fetch);
+ Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString)
- .orElse("unknown")
- );
+ .orElse("unknown"));
throw new AcmeException("Order failed... Giving up.");
}
@@ -259,7 +258,7 @@ public class ClientTest {
* @param auth
* {@link Authorization} to perform
*/
- private void authorize(Authorization auth) throws AcmeException {
+ private void authorize(Authorization auth) throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid. No need to process a challenge.
@@ -292,7 +291,7 @@ public class ClientTest {
challenge.trigger();
// Poll for the challenge to complete.
- Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
+ Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString)
@@ -382,70 +381,6 @@ public class ClientTest {
return challenge;
}
- /**
- * Waits for completion of a resource. A resource is completed if the status is either
- * {@link Status#VALID} or {@link Status#INVALID}.
- *
- * This method polls the current status, respecting the retry-after header if set. It
- * is synchronous and may take a considerable time for completion.
- *
- * It is meant as a simple example! For production services, it is recommended to do
- * an asynchronous processing here.
- *
- * @param statusSupplier
- * Method of the resource that returns the current status
- * @param statusUpdater
- * Method of the resource that updates the internal state and fetches the
- * current status from the server. It returns the instant of an optional
- * retry-after header.
- * @return The final status, either {@link Status#VALID} or {@link Status#INVALID}
- * @throws AcmeException
- * If an error occured, or if the status did not reach one of the accepted
- * result values after a certain number of checks.
- */
- private Status waitForCompletion(Supplier statusSupplier, UpdateMethod statusUpdater)
- throws AcmeException {
- // A set of terminating status values
- Set acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
-
- // Limit the number of checks, to avoid endless loops
- for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
-
- Instant now = Instant.now();
-
- // Update the status property
- Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
- .orElse(now.plusSeconds(3L));
-
- // Check the status
- Status currentStatus = statusSupplier.get();
- if (acceptableStatus.contains(currentStatus)) {
- // Reached VALID or INVALID, we're done here
- return currentStatus;
- }
-
- // Wait before checking again
- try {
- Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
- } catch (InterruptedException ex) {
- Thread.currentThread().interrupt();
- throw new AcmeException("interrupted");
- }
- }
-
- throw new AcmeException("Too many update attempts, status did not change");
- }
-
- /**
- * Functional interface that refers to a resource update method that returns an
- * optional retry-after instant and is able to throw an {@link AcmeException}.
- */
- @FunctionalInterface
- private interface UpdateMethod {
- Optional updateAndGetRetryAfter() throws AcmeException;
- }
-
/**
* Presents the instructions for preparing the challenge validation, and waits for
* dismissal. If the user cancelled the dialog, an exception is thrown.
diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java
index 25cca03b..85d5c06e 100644
--- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/boulder/OrderHttpIT.java
@@ -13,22 +13,17 @@
*/
package org.shredzone.acme4j.it.boulder;
-import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
import java.net.URI;
import java.security.KeyPair;
+import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.AccountBuilder;
-import org.shredzone.acme4j.Authorization;
-import org.shredzone.acme4j.Order;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.challenge.Http01Challenge;
-import org.shredzone.acme4j.exception.AcmeException;
-import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
import org.shredzone.acme4j.it.BammBammClient;
import org.shredzone.acme4j.util.KeyPairUtils;
@@ -38,6 +33,7 @@ import org.shredzone.acme4j.util.KeyPairUtils;
public class OrderHttpIT {
private static final String TEST_DOMAIN = "example.com";
+ private static final Duration TIMEOUT = Duration.ofSeconds(30L);
private final String bammbammUrl = System.getProperty("bammbammUrl", "http://localhost:14001");
@@ -66,25 +62,20 @@ public class OrderHttpIT {
client.httpAddToken(challenge.getToken(), challenge.getAuthorization());
challenge.trigger();
+ challenge.waitForCompletion(TIMEOUT);
- await()
- .pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateAuth(auth))
- .untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
-
+ assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
client.httpRemoveToken(challenge.getToken());
}
- order.execute(domainKeyPair);
+ order.waitUntilReady(TIMEOUT);
+ assertThat(order.getStatus()).isEqualTo(Status.READY);
- await()
- .pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateOrder(order))
- .untilAsserted(() -> assertThat(order.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
+ order.execute(domainKeyPair);
+ order.waitForCompletion(TIMEOUT);
+ assertThat(order.getStatus()).isEqualTo(Status.VALID);
var cert = order.getCertificate().getCertificate();
assertThat(cert.getNotAfter()).isNotNull();
@@ -108,32 +99,4 @@ public class OrderHttpIT {
return KeyPairUtils.createKeyPair(2048);
}
- /**
- * Safely updates the authorization, catching checked exceptions.
- *
- * @param auth
- * {@link Authorization} to update
- */
- private void updateAuth(Authorization auth) {
- try {
- auth.update();
- } catch (AcmeException ex) {
- throw new AcmeLazyLoadingException(auth, ex);
- }
- }
-
- /**
- * Safely updates the order, catching checked exceptions.
- *
- * @param order
- * {@link Order} to update
- */
- private void updateOrder(Order order) {
- try {
- order.update();
- } catch (AcmeException ex) {
- throw new AcmeLazyLoadingException(order, ex);
- }
- }
-
}
diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
index 2a0cdb7a..733b3968 100644
--- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderIT.java
@@ -13,9 +13,7 @@
*/
package org.shredzone.acme4j.it.pebble;
-import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.URI;
@@ -46,6 +44,7 @@ import org.shredzone.acme4j.exception.AcmeServerException;
public class OrderIT extends PebbleITBase {
private static final String TEST_DOMAIN = "example.com";
+ private static final Duration TIMEOUT = Duration.ofSeconds(30L);
/**
* Test if a certificate can be ordered via http-01 challenge.
@@ -169,24 +168,20 @@ public class OrderIT extends PebbleITBase {
var challenge = validator.prepare(auth);
challenge.trigger();
- await()
- .pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateAuth(auth))
- .untilAsserted(() -> assertThat(auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
+ challenge.waitForCompletion(TIMEOUT);
+ assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
+
+ auth.fetch();
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
}
+ order.waitUntilReady(TIMEOUT);
+ assertThat(order.getStatus()).isEqualTo(Status.READY);
+
order.execute(domainKeyPair);
- await()
- .pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateOrder(order))
- .untilAsserted(() -> assertThat(order.getStatus())
- .isNotIn(Status.PENDING, Status.PROCESSING, Status.READY));
-
+ order.waitForCompletion(TIMEOUT);
assertThat(order.getStatus()).isEqualTo(Status.VALID);
var certificate = order.getCertificate();
diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java
index 55622155..6dda5db5 100644
--- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java
+++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/pebble/OrderWildcardIT.java
@@ -13,10 +13,8 @@
*/
package org.shredzone.acme4j.it.pebble;
-import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.awaitility.Awaitility.await;
import java.time.Duration;
import java.time.Instant;
@@ -37,6 +35,7 @@ public class OrderWildcardIT extends PebbleITBase {
private static final String TEST_DOMAIN = "example.com";
private static final String TEST_WILDCARD_DOMAIN = "*.example.com";
+ private static final Duration TIMEOUT = Duration.ofSeconds(30L);
/**
* Test if a wildcard certificate can be ordered via dns-01 challenge.
@@ -82,27 +81,22 @@ public class OrderWildcardIT extends PebbleITBase {
try {
challenge.trigger();
- await().pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateAuth(auth))
- .untilAsserted(() -> assertThat(
- auth.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
+ challenge.waitForCompletion(TIMEOUT);
+ assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
} finally {
performCleanup();
}
+ auth.fetch();
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
}
+ order.waitUntilReady(TIMEOUT);
+ assertThat(order.getStatus()).isEqualTo(Status.READY);
+
order.execute(domainKeyPair);
-
- await()
- .pollInterval(1, SECONDS)
- .timeout(30, SECONDS)
- .conditionEvaluationListener(cond -> updateOrder(order))
- .untilAsserted(() -> assertThat(
- order.getStatus()).isNotIn(Status.PENDING, Status.PROCESSING));
-
+ order.waitForCompletion(TIMEOUT);
+ assertThat(order.getStatus()).isEqualTo(Status.VALID);
var cert = order.getCertificate().getCertificate();
assertThat(cert).isNotNull();
diff --git a/pom.xml b/pom.xml
index 640242b6..f185a995 100644
--- a/pom.xml
+++ b/pom.xml
@@ -230,12 +230,6 @@
5.12.0
test
-
- org.awaitility
- awaitility
- 4.2.1
- test
-
org.wiremock
diff --git a/src/doc/docs/example.md b/src/doc/docs/example.md
index a77833e8..7ecd7afa 100644
--- a/src/doc/docs/example.md
+++ b/src/doc/docs/example.md
@@ -37,7 +37,7 @@ The other constants should work with their default values, but can still be chan
* `DOMAIN_KEY_FILE`: File name where the generated domain key is stored. Default is `domain.key`.
* `DOMAIN_CHAIN_FILE`: File name where the ordered domain certificate chain is stored. Default is `domain-chain.crt`.
* `CHALLENGE_TYPE`: The challenge type you want to perform for domain validation. The default is `ChallengeType.HTTP` for [http-01](challenge/http-01.md) validation, but you can also use `ChallengeType.DNS` to perform a [dns-01](challenge/dns-01.md) validation. The example does not support other kind of challenges.
-* `MAX_ATTEMPTS`: Maximum number of poll attempts until a status poll is aborted.
+* `TIMEOUT`: Maximum time until an expected resource status must be reached. Default is 60 seconds. If you get frequent timeouts with your CA, increase the timeout.
## Running the Example
@@ -81,7 +81,7 @@ The `fetchCertificate()` method contains the main workflow. It expects a collect
```java
public void fetchCertificate(Collection domains)
- throws IOException, AcmeException {
+ throws IOException, AcmeException, InterruptedException {
// Load the user key file. If there is no key file, create a new one.
KeyPair userKeyPair = loadOrCreateUserKeyPair();
@@ -104,16 +104,18 @@ public void fetchCertificate(Collection domains)
authorize(auth);
}
+ // Wait for the order to become READY
+ order.waitUntilReady(TIMEOUT);
+
// Order the certificate
order.execute(domainKeyPair);
// Wait for the order to complete
- Status status = waitForCompletion(order::getStatus, order::fetch);
+ Status status = order.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString)
- .orElse("unknown")
- );
+ .orElse("unknown"));
throw new AcmeException("Order failed... Giving up.");
}
@@ -230,7 +232,7 @@ In order to get a certificate, you need to prove ownership of the domains. In th
```java
private void authorize(Authorization auth)
- throws AcmeException {
+ throws AcmeException, InterruptedException {
LOG.info("Authorization for domain {}", auth.getIdentifier().getDomain());
// The authorization is already valid.
@@ -265,7 +267,7 @@ private void authorize(Authorization auth)
challenge.trigger();
// Poll for the challenge to complete.
- Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
+ Status status = challenge.waitForCompletion(TIMEOUT);
if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString)
@@ -362,54 +364,11 @@ public Challenge dnsChallenge(Authorization auth) throws AcmeException {
The ACME protocol does not specify the sending of events. For this reason, resource status changes must be actively polled by the client.
-This example does a very simple polling in a synchronous busy loop. It updates the local copy of the resource and checks if the status is either `VALID` or `INVALID`. If it is not, it just sleeps for a certain amount of time, and then rechecks the current status.
+_acme4j_ offers very simple polling methods called `waitForStatus()`, `waitUntilReady()`, and `waitForCompletion()`. These methods check the status in a synchronous busy loop. It updates the local copy of the resource using the `fetch()` method, and then checks if the status is either `VALID` or `INVALID` (or `READY` on `waitUntilReady()`). If none of these states have been reached, it just sleeps for a certain amount of time, and then rechecks the resource status.
-Some CAs respond with a `Retry-After` HTTP header, which provides a recommendation when to check for a status change again. If this header is present, the updater function will return the given instant. If this header is not present, we will just wait a reasonable amount of time before checking again.
+Some CAs respond with a `Retry-After` HTTP header, which provides a recommendation when to check for a status change again. If this header is present, the `fetch()` method will return the given instant. If this header is not present, we will just wait a reasonable amount of time before checking again.
-An enterprise level implementation would do an asynchronous polling by storing the recheck time in a database or a queue with scheduled delivery.
-
-The following method will check if a resource reaches completion (by reaching either `VALID` or `INVALID` status). The first parameter provides the method that fetches the current status (e.g. `Order::getStatus`). The second parameter provides the method that updates the resource status (e.g. `Order::fetch`). It returned the terminating status once it has been reached, or will throw an exception if something went wrong.
-
-```java
-private Status waitForCompletion(Supplier statusSupplier,
- UpdateMethod statusUpdater) throws AcmeException {
- // A set of terminating status values
- Set acceptableStatus = EnumSet.of(Status.VALID, Status.INVALID);
-
- // Limit the number of checks, to avoid endless loops
- for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
-
- Instant now = Instant.now();
-
- // Update the status property
- Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
- .orElse(now.plusSeconds(3L));
-
- // Check the status
- Status currentStatus = statusSupplier.get();
- if (acceptableStatus.contains(currentStatus)) {
- // Reached VALID or INVALID, we're done here
- return currentStatus;
- }
-
- // Wait before checking again
- try {
- Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
- } catch (InterruptedException ex) {
- Thread.currentThread().interrupt();
- throw new AcmeException("interrupted");
- }
- }
-
- throw new AcmeException("Too many update attempts, status did not change");
-}
-
-@FunctionalInterface
-private interface UpdateMethod {
- Optional updateAndGetRetryAfter() throws AcmeException;
-}
-```
+An enterprise level implementation would do an asynchronous polling instead, by storing the recheck time in a database or a queue with scheduled delivery.
!!! note
Some CAs might provide a `Retry-After` even if the resource has reached a terminal state. For this reason, always check the status _before_ waiting for the recommended time, and leave the loop if a terminal status has been reached.
diff --git a/src/doc/docs/migration.md b/src/doc/docs/migration.md
index 7e1b9b5c..95da4f41 100644
--- a/src/doc/docs/migration.md
+++ b/src/doc/docs/migration.md
@@ -2,6 +2,11 @@
This document will help you migrate your code to the latest _acme4j_ version.
+## Migration to Version 3.4.0
+
+- To be futureproof, you should wait for your `Order` resource's state to become `READY` before invoking `Order.execute()`. Most CAs change to the `READY` state immediately, but this behavior is not specified in RFC8555. Future CA implementations may stay in `PENDING` state for a short while, and would return an error if `execute()` is invoked too early. Also see the [example](example.md#the-main-workflow) for how wait for the `READY` state.
+- There are new methods `waitForCompletion()` and `waitUntilReady()` that will do the synchronous busy wait for the resource state for you. It will remove a lot of boilerplate code that is also bug prone if implemented individually. If you use synchronous polling and waiting (like shown in the example code), I recommend to change to these methods instead of waiting for the correct state yourself. See the [example](example.md) for how to use the new methods.
+
## Migration to Version 3.3.0
- This version is unable to deserialize resource objects that were serialized by a previous version using Java's serialization mechanism. This shouldn't be a problem, as [it was not allowed](usage/persistence.md#serialization) to share serialized data between different versions anyway.
@@ -21,10 +26,7 @@ Although acme4j has made a major version bump, the migration of your code should
- The `acme4j-utils` module has been removed, and its classes moved into `acme4j-client` module. If you have used it before, just remove the dependency. If your project has a `module-info.java` file, remember to remove the `requires org.shredzone.acme4j.utils` line there as well.
- All `@Nullable` return values have been removed where possible. Returned collections may now be empty, but are never `null`. Most of the other return values are now either `Optional`, or are throwing an exception if more reasonable. If your code fails to compile because the return type has changed to `Optional`, you could simply add `.orElse(null)` to emulate the old behavior. But often your code will reveal a better way to handle the former `null` pointer instead.
-- `acme4j-client` now depends on Bouncy Castle, so you might need to register it as security provider at the start of your code:
- ```java
- Security.addProvider(new BouncyCastleProvider());
- ```
+- `acme4j-client` now depends on Bouncy Castle, so you might need to register it as security provider at the start of your code: `Security.addProvider(new BouncyCastleProvider())`.
What you might also need to know: