From f9d479a8f7f454ecbfd170713233bedb0d730e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Mon, 26 Feb 2024 18:24:55 +0100 Subject: [PATCH] Simplify handling of Retry-After header --- .../shredzone/acme4j/AcmeJsonResource.java | 80 +++++++++++++++---- .../org/shredzone/acme4j/RenewalInfo.java | 34 +++----- .../acme4j/connector/Connection.java | 9 ++- .../acme4j/connector/DefaultConnection.java | 9 --- .../exception/AcmeRetryAfterException.java | 6 ++ .../shredzone/acme4j/AccountBuilderTest.java | 1 + .../org/shredzone/acme4j/AccountTest.java | 10 --- .../acme4j/AcmeJsonResourceTest.java | 32 +++++++- .../shredzone/acme4j/AuthorizationTest.java | 26 ++---- .../org/shredzone/acme4j/CertificateTest.java | 15 ++-- .../java/org/shredzone/acme4j/OrderTest.java | 25 ------ .../org/shredzone/acme4j/RenewalInfoTest.java | 6 +- .../acme4j/challenge/ChallengeTest.java | 16 ++-- .../connector/DefaultConnectionTest.java | 34 +++----- .../acme4j/connector/DummyConnection.java | 5 -- .../provider/TestableConnectionProvider.java | 7 ++ .../shredzone/acme4j/example/ClientTest.java | 25 +++--- src/doc/docs/ca/zerossl.md | 2 +- src/doc/docs/example.md | 21 ++--- src/doc/docs/migration.md | 1 + src/doc/docs/usage/exceptions.md | 2 +- src/doc/docs/usage/order.md | 8 +- 22 files changed, 182 insertions(+), 192 deletions(-) diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java index 48300fc4..5a589034 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/AcmeJsonResource.java @@ -14,7 +14,9 @@ package org.shredzone.acme4j; import java.net.URL; +import java.time.Instant; import java.util.Objects; +import java.util.Optional; import edu.umd.cs.findbugs.annotations.Nullable; import org.shredzone.acme4j.exception.AcmeException; @@ -34,6 +36,7 @@ public abstract class AcmeJsonResource extends AcmeResource { private static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class); private @Nullable JSON data = null; + private @Nullable Instant retryAfter = null; /** * Create a new {@link AcmeJsonResource}. @@ -62,10 +65,7 @@ public abstract class AcmeJsonResource extends AcmeResource { public JSON getJSON() { if (data == null) { try { - update(); - } catch (AcmeRetryAfterException ex) { - // ignore... The object was still updated. - LOG.debug("Retry-After", ex); + fetch(); } catch (AcmeException ex) { throw new AcmeLazyLoadingException(this, ex); } @@ -88,7 +88,7 @@ public abstract class AcmeJsonResource extends AcmeResource { * Checks if this resource is valid. * * @return {@code true} if the resource state has been loaded from the server. If - * {@code false}, {@link #getJSON()} would implicitly call {@link #update()} + * {@code false}, {@link #getJSON()} would implicitly call {@link #fetch()} * to fetch the current state from the server. */ protected boolean isValid() { @@ -96,7 +96,7 @@ public abstract class AcmeJsonResource extends AcmeResource { } /** - * Invalidates the state of this resource. Enforces an {@link #update()} when + * Invalidates the state of this resource. Enforces a {@link #fetch()} when * {@link #getJSON()} is invoked. *

* Subclasses can override this method to purge internal caches that are based on the @@ -104,28 +104,78 @@ public abstract class AcmeJsonResource extends AcmeResource { */ protected void invalidate() { data = null; + retryAfter = null; + } + + /** + * Updates this resource, by fetching the current resource data from the server. + *

+ * Note: Prefer to use {@link #fetch()} instead. It is working the same way, but + * returns the Retry-After instant instead of throwing an exception. This method will + * become deprecated in a future release. + * + * @throws AcmeException + * if the resource could not be fetched. + * @throws AcmeRetryAfterException + * the resource is still being processed, and the server returned an estimated + * date when the process will be completed. If you are polling for the + * resource to complete, you should wait for the date given in + * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status of + * the resource is updated even if this exception was thrown. + * @see #fetch() + */ + public void update() throws AcmeException { + var retryAfter = fetch(); + if (retryAfter.isPresent()) { + throw new AcmeRetryAfterException(getClass().getSimpleName() + " is not completed yet", retryAfter.get()); + } } /** * Updates this resource, by fetching the current resource data from the server. * + * @return An {@link Optional} estimation when the resource status will change. If you + * are polling for the resource to complete, you should wait for the given instant + * before trying again. Empty if the server did not return a "Retry-After" header. * @throws AcmeException - * if the resource could not be fetched. - * @throws AcmeRetryAfterException - * the resource is still being processed, and the server returned an - * estimated date when the process will be completed. If you are polling - * for the resource to complete, you should wait for the date given in - * {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status - * of the resource is updated even if this exception was thrown. + * if the resource could not be fetched. + * @see #update() + * @since 3.2.0 */ - public void update() throws AcmeException { + public Optional fetch() throws AcmeException { var resourceType = getClass().getSimpleName(); LOG.debug("update {}", resourceType); try (var conn = getSession().connect()) { conn.sendSignedPostAsGetRequest(getLocation(), getLogin()); setJSON(conn.readJsonResponse()); - conn.handleRetryAfter(resourceType + " is not completed yet"); + var retryAfterOpt = conn.getRetryAfter(); + retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant)); + setRetryAfter(retryAfterOpt.orElse(null)); + return retryAfterOpt; } } + /** + * Sets a Retry-After instant. + * + * @since 3.2.0 + */ + protected void setRetryAfter(@Nullable Instant retryAfter) { + this.retryAfter = retryAfter; + } + + /** + * Gets an estimation when the resource status will change. If you are polling for + * the resource to complete, you should wait for the given instant before trying + * a status refresh. + *

+ * This instant was sent with the Retry-After header at the last update. + * + * @return Retry-after {@link Instant}, or empty if there was no such header. + * @since 3.2.0 + */ + public Optional getRetryAfter() { + return Optional.ofNullable(retryAfter); + } + } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java b/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java index 721baf16..378b1c02 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/RenewalInfo.java @@ -38,8 +38,6 @@ import org.slf4j.LoggerFactory; public class RenewalInfo extends AcmeJsonResource { private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class); - private @Nullable Instant recheckAfter = null; - protected RenewalInfo(Login login, URL location) { super(login, location); } @@ -68,15 +66,6 @@ public class RenewalInfo extends AcmeJsonResource { return getJSON().get("explanationURL").optional().map(Value::asURL); } - /** - * An optional {@link Instant} that serves as recommendation when to re-check the - * renewal information of a certificate. - */ - public Optional getRecheckAfter() { - getJSON(); // make sure resource is loaded - return Optional.ofNullable(recheckAfter); - } - /** * Checks if the given {@link Instant} is before the suggested time window, so a * certificate renewal is not required yet. @@ -175,16 +164,6 @@ public class RenewalInfo extends AcmeJsonResource { end.toEpochMilli()))); } - @Override - public void update() throws AcmeException { - LOG.debug("update RenewalInfo"); - try (Connection conn = getSession().connect()) { - conn.sendRequest(getLocation(), getSession(), null); - setJSON(conn.readJsonResponse()); - recheckAfter = conn.getRetryAfter().orElse(null); - } - } - /** * Asserts that the end of the suggested time window is after the start. */ @@ -194,4 +173,17 @@ public class RenewalInfo extends AcmeJsonResource { } } + @Override + public Optional fetch() throws AcmeException { + LOG.debug("update RenewalInfo"); + try (Connection conn = getSession().connect()) { + conn.sendRequest(getLocation(), getSession(), null); + setJSON(conn.readJsonResponse()); + var retryAfterOpt = conn.getRetryAfter(); + retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant)); + setRetryAfter(retryAfterOpt.orElse(null)); + return retryAfterOpt; + } + } + } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java index 10b1b032..8299b0e1 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java @@ -162,8 +162,15 @@ public interface Connection extends AutoCloseable { * * @param message * Message to be sent along with the {@link AcmeRetryAfterException} + * @deprecated Prefer to use {@link #getRetryAfter()}. */ - void handleRetryAfter(String message) throws AcmeException; + @Deprecated + default void handleRetryAfter(String message) throws AcmeException { + var retryAfter = getRetryAfter(); + if (retryAfter.isPresent()) { + throw new AcmeRetryAfterException(message, retryAfter.get()); + } + } /** * Gets the nonce from the nonce header. diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java index 57b14087..8e4fdcdc 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java @@ -51,7 +51,6 @@ import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeRateLimitedException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.exception.AcmeServerException; import org.shredzone.acme4j.exception.AcmeUnauthorizedException; import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; @@ -231,14 +230,6 @@ public class DefaultConnection implements Connection { } } - @Override - public void handleRetryAfter(String message) throws AcmeException { - var retryAfter = getRetryAfter(); - if (retryAfter.isPresent()) { - throw new AcmeRetryAfterException(message, retryAfter.get()); - } - } - @Override public Optional getNonce() { var nonceHeaderOpt = getResponse().headers() diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java index b805d603..fd7f8931 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRetryAfterException.java @@ -16,9 +16,15 @@ package org.shredzone.acme4j.exception; import java.time.Instant; import java.util.Objects; +import org.shredzone.acme4j.AcmeJsonResource; + /** * A server side process has not been completed yet. The server also provides an estimate * of when the process is expected to complete. + *

+ * Note: Prefer to use {@link AcmeJsonResource#fetch()}. Invoking + * {@link AcmeJsonResource#update()} and catching this exception is unnecessary + * complicated and a legacy from acme4j v2 which will disappear in a future release. */ public class AcmeRetryAfterException extends AcmeException { private static final long serialVersionUID = 4461979121063649905L; diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java index a7cd2fd8..8a52cbe1 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountBuilderTest.java @@ -155,6 +155,7 @@ public class AccountBuilderTest { }; provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl); + provider.putMetadata("externalAccountRequired", true); var builder = new AccountBuilder(); builder.useKeyPair(accountKey); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java index 1c2854da..c36640fc 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java @@ -96,11 +96,6 @@ public class AccountTest { public Collection getLinks(String relation) { return emptyList(); } - - @Override - public void handleRetryAfter(String message) { - // do nothing - } }; var login = provider.createLogin(); @@ -156,11 +151,6 @@ public class AccountTest { default: return emptyList(); } } - - @Override - public void handleRetryAfter(String message) { - // do nothing - } }; var account = new Account(provider.createLogin()); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java index de4934c7..fd534d96 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AcmeJsonResourceTest.java @@ -18,8 +18,12 @@ import static org.shredzone.acme4j.toolbox.TestUtils.getJSON; import static org.shredzone.acme4j.toolbox.TestUtils.url; import java.net.URL; +import java.time.Instant; +import java.util.Optional; +import edu.umd.cs.findbugs.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.TestUtils; @@ -43,6 +47,7 @@ public class AcmeJsonResourceTest { assertThat(resource.getSession()).isEqualTo(login.getSession()); assertThat(resource.getLocation()).isEqualTo(LOCATION_URL); assertThat(resource.isValid()).isFalse(); + assertThat(resource.getRetryAfter()).isEmpty(); assertUpdateInvoked(resource, 0); assertThat(resource.getJSON()).isEqualTo(JSON_DATA); @@ -74,6 +79,25 @@ public class AcmeJsonResourceTest { assertUpdateInvoked(resource, 0); } + /** + * Test Retry-After + */ + @Test + public void testRetryAfter() { + var login = TestUtils.login(); + var retryAfter = Instant.now().plusSeconds(30L); + var jsonData = getJSON("requestOrderResponse"); + + var resource = new DummyJsonResource(login, LOCATION_URL, jsonData, retryAfter); + assertThat(resource.isValid()).isTrue(); + assertThat(resource.getJSON()).isEqualTo(jsonData); + assertThat(resource.getRetryAfter()).hasValue(retryAfter); + assertUpdateInvoked(resource, 0); + + resource.setRetryAfter(null); + assertThat(resource.getRetryAfter()).isEmpty(); + } + /** * Test {@link AcmeJsonResource#invalidate()}. */ @@ -124,17 +148,19 @@ public class AcmeJsonResourceTest { super(login, location); } - public DummyJsonResource(Login login, URL location, JSON json) { + public DummyJsonResource(Login login, URL location, JSON json, @Nullable Instant retryAfter) { super(login, location); setJSON(json); + setRetryAfter(retryAfter); } @Override - public void update() { - // update() is tested individually in all AcmeJsonResource subclasses. + public Optional fetch() throws AcmeException { + // fetch() is tested individually in all AcmeJsonResource subclasses. // Here we just simulate the update, by setting a JSON. updateCount++; setJSON(JSON_DATA); + return Optional.empty(); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index f4aa4e95..9b35ccd5 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -25,6 +25,7 @@ import java.net.URL; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; @@ -32,9 +33,7 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; -import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -128,11 +127,6 @@ public class AuthorizationTest { public JSON readJsonResponse() { return getJSON("updateAuthorizationResponse"); } - - @Override - public void handleRetryAfter(String message) { - // Just do nothing - } }; var login = provider.createLogin(); @@ -174,11 +168,6 @@ public class AuthorizationTest { public JSON readJsonResponse() { return getJSON("updateAuthorizationWildcardResponse"); } - - @Override - public void handleRetryAfter(String message) { - // Just do nothing - } }; var login = provider.createLogin(); @@ -219,11 +208,6 @@ public class AuthorizationTest { public JSON readJsonResponse() { return getJSON("updateAuthorizationResponse"); } - - @Override - public void handleRetryAfter(String message) { - // Just do nothing - } }; var login = provider.createLogin(); @@ -270,8 +254,8 @@ public class AuthorizationTest { } @Override - public void handleRetryAfter(String message) throws AcmeException { - throw new AcmeRetryAfterException(message, retryAfter); + public Optional getRetryAfter() { + return Optional.of(retryAfter); } }; @@ -282,8 +266,8 @@ public class AuthorizationTest { provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); var auth = new Authorization(login, locationUrl); - var ex = assertThrows(AcmeRetryAfterException.class, auth::update); - assertThat(ex.getRetryAfter()).isEqualTo(retryAfter); + var returnedRetryAfter = auth.fetch(); + assertThat(returnedRetryAfter).hasValue(retryAfter); assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org"); assertThat(auth.getStatus()).isEqualTo(Status.VALID); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java index de794548..23df6e93 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/CertificateTest.java @@ -340,13 +340,11 @@ public class CertificateTest { assertThat(cert.getCertID()).isEqualTo("MFgwCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCBQCHZUMh"); assertThat(cert.hasRenewalInfo()).isTrue(); assertThat(cert.getRenewalInfoLocation()) - .isNotEmpty() - .contains(certResourceUrl); + .hasValue(certResourceUrl); var renewalInfo = cert.getRenewalInfo(); - assertThat(renewalInfo.getRecheckAfter()) - .isNotEmpty() - .contains(retryAfterInstant); + assertThat(renewalInfo.getRetryAfter()) + .isEmpty(); assertThat(renewalInfo.getSuggestedWindowStart()) .isEqualTo("2021-01-03T00:00:00Z"); assertThat(renewalInfo.getSuggestedWindowEnd()) @@ -355,6 +353,9 @@ public class CertificateTest { .isNotEmpty() .contains(url("https://example.com/docs/example-mass-reissuance-event")); + assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant); + assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant); + provider.close(); } @@ -404,9 +405,7 @@ public class CertificateTest { var cert = new Certificate(provider.createLogin(), locationUrl); assertThat(cert.hasRenewalInfo()).isTrue(); - assertThat(cert.getRenewalInfoLocation()) - .isNotEmpty() - .contains(certResourceUrl); + assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl); provider.close(); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java index 76d8e488..ca4962cc 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java @@ -56,11 +56,6 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("updateOrderResponse"); } - - @Override - public void handleRetryAfter(String message) { - assertThat(message).isNotNull(); - } }; var login = provider.createLogin(); @@ -133,11 +128,6 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("updateOrderResponse"); } - - @Override - public void handleRetryAfter(String message) { - assertThat(message).isNotNull(); - } }; var login = provider.createLogin(); @@ -192,11 +182,6 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse"); } - - @Override - public void handleRetryAfter(String message) { - assertThat(message).isNotNull(); - } }; var login = provider.createLogin(); @@ -250,11 +235,6 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("updateAutoRenewOrderResponse"); } - - @Override - public void handleRetryAfter(String message) { - assertThat(message).isNotNull(); - } }; provider.putMetadata("auto-renewal", JSON.empty()); @@ -298,11 +278,6 @@ public class OrderTest { public JSON readJsonResponse() { return getJSON("finalizeAutoRenewResponse"); } - - @Override - public void handleRetryAfter(String message) { - assertThat(message).isNotNull(); - } }; var login = provider.createLogin(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java index d64adfd8..c4b31d56 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/RenewalInfoTest.java @@ -68,14 +68,12 @@ public class RenewalInfoTest { var login = provider.createLogin(); var renewalInfo = new RenewalInfo(login, locationUrl); - renewalInfo.update(); + var recheckAfter = renewalInfo.fetch(); + assertThat(recheckAfter).hasValue(retryAfterInstant); // Check getters try (var softly = new AutoCloseableSoftAssertions()) { softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl); - softly.assertThat(renewalInfo.getRecheckAfter()) - .isNotEmpty() - .contains(retryAfterInstant); softly.assertThat(renewalInfo.getSuggestedWindowStart()) .isEqualTo(startWindow); softly.assertThat(renewalInfo.getSuggestedWindowEnd()) diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java index 37662511..512efd48 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/challenge/ChallengeTest.java @@ -26,14 +26,13 @@ import java.net.URL; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Optional; import org.assertj.core.api.AutoCloseableSoftAssertions; import org.junit.jupiter.api.Test; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Status; -import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeProtocolException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSONBuilder; @@ -141,11 +140,6 @@ public class ChallengeTest { public JSON readJsonResponse() { return getJSON("updateHttpChallengeResponse"); } - - @Override - public void handleRetryAfter(String message) { - // Just do nothing - } }; var login = provider.createLogin(); @@ -179,17 +173,17 @@ public class ChallengeTest { return getJSON("updateHttpChallengeResponse"); } - @Override - public void handleRetryAfter(String message) throws AcmeException { - throw new AcmeRetryAfterException(message, retryAfter); + public Optional getRetryAfter() { + return Optional.of(retryAfter); } }; var login = provider.createLogin(); var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse")); - assertThrows(AcmeRetryAfterException.class, challenge::update); + var returnedRetryAfter = challenge.fetch(); + assertThat(returnedRetryAfter).hasValue(retryAfter); assertThat(challenge.getStatus()).isEqualTo(Status.VALID); assertThat(challenge.getLocation()).isEqualTo(locationUrl); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index 8ea4f69b..57659158 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -52,7 +52,6 @@ import org.shredzone.acme4j.Session; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeRateLimitedException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.exception.AcmeServerException; import org.shredzone.acme4j.exception.AcmeUnauthorizedException; import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; @@ -310,30 +309,24 @@ public class DefaultConnectionTest { * Test if Retry-After header with absolute date is correctly parsed. */ @Test - public void testHandleRetryAfterHeaderDate() { + public void testHandleRetryAfterHeaderDate() throws AcmeException { var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS); - var retryMsg = "absolute date"; stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() .withHeader("Retry-After", DATE_FORMATTER.format(retryDate)) )); - var ex = assertThrows(AcmeRetryAfterException.class, () -> { - try (var conn = session.connect()) { - conn.sendRequest(requestUrl, session, null); - conn.handleRetryAfter(retryMsg); - } - }); - - assertThat(ex.getRetryAfter()).isEqualTo(retryDate); - assertThat(ex.getMessage()).isEqualTo(retryMsg); + try (var conn = session.connect()) { + conn.sendRequest(requestUrl, session, null); + assertThat(conn.getRetryAfter()).hasValue(retryDate); + } } /** * Test if Retry-After header with relative timespan is correctly parsed. */ @Test - public void testHandleRetryAfterHeaderDelta() { + public void testHandleRetryAfterHeaderDelta() throws AcmeException { var delta = 10 * 60 * 60; var now = Instant.now().truncatedTo(SECONDS); var retryMsg = "relative time"; @@ -343,15 +336,10 @@ public class DefaultConnectionTest { .withHeader("Date", DATE_FORMATTER.format(now)) )); - var ex = assertThrows(AcmeRetryAfterException.class, () -> { - try (var conn = session.connect()) { - conn.sendRequest(requestUrl, session, null); - conn.handleRetryAfter(retryMsg); - } - }); - - assertThat(ex.getRetryAfter()).isEqualTo(now.plusSeconds(delta)); - assertThat(ex.getMessage()).isEqualTo(retryMsg); + try (var conn = session.connect()) { + conn.sendRequest(requestUrl, session, null); + assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta)); + } } /** @@ -365,7 +353,7 @@ public class DefaultConnectionTest { try (var conn = session.connect()) { conn.sendRequest(requestUrl, session, null); - conn.handleRetryAfter("no header"); + assertThat(conn.getRetryAfter()).isEmpty(); } verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java index 299758ad..9c206212 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java @@ -80,11 +80,6 @@ public class DummyConnection implements Connection { throw new UnsupportedOperationException(); } - @Override - public void handleRetryAfter(String message) throws AcmeException { - throw new UnsupportedOperationException(); - } - @Override public Optional getNonce() { throw new UnsupportedOperationException(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java index 1203e009..505e92c7 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/TestableConnectionProvider.java @@ -16,8 +16,10 @@ package org.shredzone.acme4j.provider; import java.io.IOException; import java.net.URI; import java.net.URL; +import java.time.Instant; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.function.BiFunction; import org.shredzone.acme4j.Login; @@ -109,6 +111,11 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP return session.login(new URL(TestUtils.ACCOUNT_URL), TestUtils.createKeyPair()); } + @Override + public Optional getRetryAfter() { + return Optional.empty(); + } + @Override public boolean accepts(URI serverUri) { throw new UnsupportedOperationException(); 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 0e32ec5a..14a40e2c 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 @@ -45,7 +45,6 @@ import org.shredzone.acme4j.challenge.Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.exception.AcmeException; -import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.util.KeyPairUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -142,7 +141,7 @@ public class ClientTest { order.execute(domainKeyPair); // Wait for the order to complete - Status status = waitForCompletion(order::getStatus, order::update); + Status status = waitForCompletion(order::getStatus, order::fetch); if (status != Status.VALID) { LOG.error("Order has failed, reason: {}", order.getError() .map(Problem::toString) @@ -293,7 +292,7 @@ public class ClientTest { challenge.trigger(); // Poll for the challenge to complete. - Status status = waitForCompletion(challenge::getStatus, challenge::update); + Status status = waitForCompletion(challenge::getStatus, challenge::fetch); if (status != Status.VALID) { LOG.error("Challenge has failed, reason: {}", challenge.getError() .map(Problem::toString) @@ -397,7 +396,8 @@ public class ClientTest { * 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 + * 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 @@ -412,18 +412,11 @@ public class ClientTest { for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS); - // A reasonable default retry-after delay Instant now = Instant.now(); - Instant retryAfter = now.plusSeconds(3L); // Update the status property - try { - statusUpdater.update(); - } catch (AcmeRetryAfterException ex) { - // Server sent a retry-after header, use this instant instead - LOG.info("Server asks to try again at: {}", ex.getRetryAfter()); - retryAfter = ex.getRetryAfter(); - } + Instant retryAfter = statusUpdater.updateAndGetRetryAfter() + .orElse(now.plusSeconds(3L)); // Check the status Status currentStatus = statusSupplier.get(); @@ -445,12 +438,12 @@ public class ClientTest { } /** - * Functional interface that refers to a resource update method that is able to - * throw an {@link AcmeException}. + * 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 { - void update() throws AcmeException; + Optional updateAndGetRetryAfter() throws AcmeException; } /** diff --git a/src/doc/docs/ca/zerossl.md b/src/doc/docs/ca/zerossl.md index 6d03edda..b15a375c 100644 --- a/src/doc/docs/ca/zerossl.md +++ b/src/doc/docs/ca/zerossl.md @@ -13,7 +13,7 @@ ZeroSSL does not provide a staging server (as of Feburary 2024). ## Note * ZeroSSL requires account creation with [key identifier](../usage/account.md#external-account-binding). -* ZeroSSL makes use of the retry-after header, so expect [AcmeRetryAfterException](../usage/exceptions.md#acmeretryafterexception)s to be thrown, and handle them accordingly (see example). +* ZeroSSL makes use of the retry-after header, so expect that the `fetch()` methods return an `Instant`, and wait until this moment has passed (see [example](../example.md)). * Certificate creation can take a considerable amount of time (up to 24h). The retry-after header still gives a short retry period, resulting in a very high number of status update reattempts. * Server response can be very slow sometimes. It is recommended to set a timeout of 30 seconds or higher in the [network settings](../usage/advanced.md#network-settings). diff --git a/src/doc/docs/example.md b/src/doc/docs/example.md index 41b712c0..a77833e8 100644 --- a/src/doc/docs/example.md +++ b/src/doc/docs/example.md @@ -108,7 +108,7 @@ public void fetchCertificate(Collection domains) order.execute(domainKeyPair); // Wait for the order to complete - Status status = waitForCompletion(order::getStatus, order::update); + Status status = waitForCompletion(order::getStatus, order::fetch); if (status != Status.VALID) { LOG.error("Order has failed, reason: {}", order.getError() .map(Problem::toString) @@ -265,7 +265,7 @@ private void authorize(Authorization auth) challenge.trigger(); // Poll for the challenge to complete. - Status status = waitForCompletion(challenge::getStatus, challenge::update); + Status status = waitForCompletion(challenge::getStatus, challenge::fetch); if (status != Status.VALID) { LOG.error("Challenge has failed, reason: {}", challenge.getError() .map(Problem::toString) @@ -364,11 +364,11 @@ The ACME protocol does not specify the sending of events. For this reason, resou 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. -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, _acme4j_ will throw an `AcmeRetryAfterException` (and will still update the resource state). If this header is not present, 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 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. 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::update()`). It returned the terminating status once it has been reached, or will throw an exception if something went wrong. +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, @@ -380,18 +380,11 @@ private Status waitForCompletion(Supplier statusSupplier, for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS); - // A reasonable default retry-after delay Instant now = Instant.now(); - Instant retryAfter = now.plusSeconds(3L); // Update the status property - try { - statusUpdater.update(); - } catch (AcmeRetryAfterException ex) { - // Server sent a retry-after header, use this instant instead - LOG.info("Server asks to try again at: {}", ex.getRetryAfter()); - retryAfter = ex.getRetryAfter(); - } + Instant retryAfter = statusUpdater.updateAndGetRetryAfter() + .orElse(now.plusSeconds(3L)); // Check the status Status currentStatus = statusSupplier.get(); @@ -414,7 +407,7 @@ private Status waitForCompletion(Supplier statusSupplier, @FunctionalInterface private interface UpdateMethod { - void update() throws AcmeException; + Optional updateAndGetRetryAfter() throws AcmeException; } ``` diff --git a/src/doc/docs/migration.md b/src/doc/docs/migration.md index aef9b385..a847264c 100644 --- a/src/doc/docs/migration.md +++ b/src/doc/docs/migration.md @@ -8,6 +8,7 @@ This document will help you migrate your code to the latest _acme4j_ version. - acme4j was updated to support the latest [draft-ietf-acme-ari-03](https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html) now. It is a breaking change! If you use ARI, make sure your server supports the latest draft before updating to this version of acme4j. - `Certificate.markAsReplace()` has been removed, because this method is not supported by [draft-ietf-acme-ari-03](https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html) anymore. To mark an existing certificate as replaced, use the new method `OrderBuilder.replaces()` now. - `Certificate.getCertID()` is not needed in the ACME context anymore. This method has been marked as deprecated. In a future version of acme4j, it will be removed without replacement. Refer to the source code to see how the certificate ID is computed. +- Instead of invoking `update()` and then handling an `AcmeRetryAfterException`, you should now prefer to invoke `fetch()`. It gives an optional retry-after `Instant` as return value, which makes the retry-after handling less complex. In a future version, `update()` will be fully replaced by `fetch()`. ## Migration to Version 3.0.0 diff --git a/src/doc/docs/usage/exceptions.md b/src/doc/docs/usage/exceptions.md index e28dc65e..a113bac6 100644 --- a/src/doc/docs/usage/exceptions.md +++ b/src/doc/docs/usage/exceptions.md @@ -31,7 +31,7 @@ The exception provides the causing `IOException`. This `AcmeException` shows that a server-side process has not been completed yet, and gives an estimation when the process might be completed. -It can only be thrown when invoking `update()` or one of the getters. +It can only be thrown when invoking `update()`. However, it is preferred to invoke `fetch()`, which returns the retry-after instant directly, instead of throwing this exception. !!! note The internal state of the resource is still updated. diff --git a/src/doc/docs/usage/order.md b/src/doc/docs/usage/order.md index f049e106..ec538ba3 100644 --- a/src/doc/docs/usage/order.md +++ b/src/doc/docs/usage/order.md @@ -72,7 +72,7 @@ Now you have to wait for the server to check your response. If the checks are co ```java while (!EnumSet.of(Status.VALID, Status.INVALID).contains(auth.getStatus())) { Thread.sleep(3000L); - auth.update(); + auth.fetch(); } ``` @@ -81,7 +81,7 @@ This is a very simple example which can be improved in many ways: * Limit the number of checks, to avoid endless loops if an authorization is stuck on server side. * Wait with the status checks until the CA has accessed the response for the first time (e.g. after an incoming HTTP request to the response file). * Use an asynchronous architecture instead of a blocking `Thread.sleep()`. -* Check if `auth.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. See the [example](../example.md) for a simple way to do that. +* Check if `auth.fetch()` returns a retry-after `Instant`, and wait for the next update at least until this moment is reached. See the [example](../example.md) for a simple way to do that. The CA server may start with the validation immediately after `trigger()` is invoked, so make sure your server is ready to respond to requests before invoking `trigger()`. Otherwise the challenge might fail instantly. @@ -135,7 +135,7 @@ Order order = ... // your Order object from the previous step while (!EnumSet.of(Status.VALID, Status.INVALID).contains(order.getStatus())) { Thread.sleep(3000L); - order.update(); + order.fetch(); } ``` @@ -143,7 +143,7 @@ This is a very simple example which can be improved in many ways: * Limit the number of checks, to avoid endless loops if the order is stuck on server side. * Use an asynchronous architecture instead of a blocking `Thread.sleep()`. -* Check if `order.update()` throws an `AcmeRetryAfterException`, and wait for the next update until `AcmeRetryAfterException.getRetryAfter()`. See the [example](../example.md) for a simple way to do that. +* Check if `order.fetch()` returns a retry-after `Instant`, and wait for the next update at least until this moment is reached. See the [example](../example.md) for a simple way to do that. !!! tip If the status is `PENDING`, you have not completed all authorizations yet.