Simplify handling of Retry-After header

pull/168/head
Richard Körber 2024-02-26 18:24:55 +01:00
parent 908e11b152
commit f9d479a8f7
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
22 changed files with 182 additions and 192 deletions

View File

@ -14,7 +14,9 @@
package org.shredzone.acme4j; package org.shredzone.acme4j;
import java.net.URL; import java.net.URL;
import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable; import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.exception.AcmeException; 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 static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class);
private @Nullable JSON data = null; private @Nullable JSON data = null;
private @Nullable Instant retryAfter = null;
/** /**
* Create a new {@link AcmeJsonResource}. * Create a new {@link AcmeJsonResource}.
@ -62,10 +65,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
public JSON getJSON() { public JSON getJSON() {
if (data == null) { if (data == null) {
try { try {
update(); fetch();
} catch (AcmeRetryAfterException ex) {
// ignore... The object was still updated.
LOG.debug("Retry-After", ex);
} catch (AcmeException ex) { } catch (AcmeException ex) {
throw new AcmeLazyLoadingException(this, ex); throw new AcmeLazyLoadingException(this, ex);
} }
@ -88,7 +88,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
* Checks if this resource is valid. * Checks if this resource is valid.
* *
* @return {@code true} if the resource state has been loaded from the server. If * @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. * to fetch the current state from the server.
*/ */
protected boolean isValid() { 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. * {@link #getJSON()} is invoked.
* <p> * <p>
* Subclasses can override this method to purge internal caches that are based on the * 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() { protected void invalidate() {
data = null; data = null;
retryAfter = null;
}
/**
* Updates this resource, by fetching the current resource data from the server.
* <p>
* 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. * 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 * @throws AcmeException
* if the resource could not be fetched. * if the resource could not be fetched.
* @throws AcmeRetryAfterException * @see #update()
* the resource is still being processed, and the server returned an * @since 3.2.0
* 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.
*/ */
public void update() throws AcmeException { public Optional<Instant> fetch() throws AcmeException {
var resourceType = getClass().getSimpleName(); var resourceType = getClass().getSimpleName();
LOG.debug("update {}", resourceType); LOG.debug("update {}", resourceType);
try (var conn = getSession().connect()) { try (var conn = getSession().connect()) {
conn.sendSignedPostAsGetRequest(getLocation(), getLogin()); conn.sendSignedPostAsGetRequest(getLocation(), getLogin());
setJSON(conn.readJsonResponse()); 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.
* <p>
* 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<Instant> getRetryAfter() {
return Optional.ofNullable(retryAfter);
}
} }

View File

@ -38,8 +38,6 @@ import org.slf4j.LoggerFactory;
public class RenewalInfo extends AcmeJsonResource { public class RenewalInfo extends AcmeJsonResource {
private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class); private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class);
private @Nullable Instant recheckAfter = null;
protected RenewalInfo(Login login, URL location) { protected RenewalInfo(Login login, URL location) {
super(login, location); super(login, location);
} }
@ -68,15 +66,6 @@ public class RenewalInfo extends AcmeJsonResource {
return getJSON().get("explanationURL").optional().map(Value::asURL); 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<Instant> 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 * Checks if the given {@link Instant} is before the suggested time window, so a
* certificate renewal is not required yet. * certificate renewal is not required yet.
@ -175,16 +164,6 @@ public class RenewalInfo extends AcmeJsonResource {
end.toEpochMilli()))); 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. * 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<Instant> 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;
}
}
} }

View File

@ -162,8 +162,15 @@ public interface Connection extends AutoCloseable {
* *
* @param message * @param message
* Message to be sent along with the {@link AcmeRetryAfterException} * 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. * Gets the nonce from the nonce header.

View File

@ -51,7 +51,6 @@ import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitedException; import org.shredzone.acme4j.exception.AcmeRateLimitedException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.exception.AcmeServerException; import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException; import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; 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 @Override
public Optional<String> getNonce() { public Optional<String> getNonce() {
var nonceHeaderOpt = getResponse().headers() var nonceHeaderOpt = getResponse().headers()

View File

@ -16,9 +16,15 @@ package org.shredzone.acme4j.exception;
import java.time.Instant; import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import org.shredzone.acme4j.AcmeJsonResource;
/** /**
* A server side process has not been completed yet. The server also provides an estimate * A server side process has not been completed yet. The server also provides an estimate
* of when the process is expected to complete. * of when the process is expected to complete.
* <p>
* 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 { public class AcmeRetryAfterException extends AcmeException {
private static final long serialVersionUID = 4461979121063649905L; private static final long serialVersionUID = 4461979121063649905L;

View File

@ -155,6 +155,7 @@ public class AccountBuilderTest {
}; };
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl); provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);
provider.putMetadata("externalAccountRequired", true);
var builder = new AccountBuilder(); var builder = new AccountBuilder();
builder.useKeyPair(accountKey); builder.useKeyPair(accountKey);

View File

@ -96,11 +96,6 @@ public class AccountTest {
public Collection<URL> getLinks(String relation) { public Collection<URL> getLinks(String relation) {
return emptyList(); return emptyList();
} }
@Override
public void handleRetryAfter(String message) {
// do nothing
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -156,11 +151,6 @@ public class AccountTest {
default: return emptyList(); default: return emptyList();
} }
} }
@Override
public void handleRetryAfter(String message) {
// do nothing
}
}; };
var account = new Account(provider.createLogin()); var account = new Account(provider.createLogin());

View File

@ -18,8 +18,12 @@ import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url; import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.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.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.TestUtils; import org.shredzone.acme4j.toolbox.TestUtils;
@ -43,6 +47,7 @@ public class AcmeJsonResourceTest {
assertThat(resource.getSession()).isEqualTo(login.getSession()); assertThat(resource.getSession()).isEqualTo(login.getSession());
assertThat(resource.getLocation()).isEqualTo(LOCATION_URL); assertThat(resource.getLocation()).isEqualTo(LOCATION_URL);
assertThat(resource.isValid()).isFalse(); assertThat(resource.isValid()).isFalse();
assertThat(resource.getRetryAfter()).isEmpty();
assertUpdateInvoked(resource, 0); assertUpdateInvoked(resource, 0);
assertThat(resource.getJSON()).isEqualTo(JSON_DATA); assertThat(resource.getJSON()).isEqualTo(JSON_DATA);
@ -74,6 +79,25 @@ public class AcmeJsonResourceTest {
assertUpdateInvoked(resource, 0); 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()}. * Test {@link AcmeJsonResource#invalidate()}.
*/ */
@ -124,17 +148,19 @@ public class AcmeJsonResourceTest {
super(login, location); 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); super(login, location);
setJSON(json); setJSON(json);
setRetryAfter(retryAfter);
} }
@Override @Override
public void update() { public Optional<Instant> fetch() throws AcmeException {
// update() is tested individually in all AcmeJsonResource subclasses. // fetch() is tested individually in all AcmeJsonResource subclasses.
// Here we just simulate the update, by setting a JSON. // Here we just simulate the update, by setting a JSON.
updateCount++; updateCount++;
setJSON(JSON_DATA); setJSON(JSON_DATA);
return Optional.empty();
} }
} }

View File

@ -25,6 +25,7 @@ import java.net.URL;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test; 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.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge; import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder; import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -128,11 +127,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse"); return getJSON("updateAuthorizationResponse");
} }
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -174,11 +168,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateAuthorizationWildcardResponse"); return getJSON("updateAuthorizationWildcardResponse");
} }
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -219,11 +208,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse"); return getJSON("updateAuthorizationResponse");
} }
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -270,8 +254,8 @@ public class AuthorizationTest {
} }
@Override @Override
public void handleRetryAfter(String message) throws AcmeException { public Optional<Instant> getRetryAfter() {
throw new AcmeRetryAfterException(message, retryAfter); return Optional.of(retryAfter);
} }
}; };
@ -282,8 +266,8 @@ public class AuthorizationTest {
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new); provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl); var auth = new Authorization(login, locationUrl);
var ex = assertThrows(AcmeRetryAfterException.class, auth::update); var returnedRetryAfter = auth.fetch();
assertThat(ex.getRetryAfter()).isEqualTo(retryAfter); assertThat(returnedRetryAfter).hasValue(retryAfter);
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org"); assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID); assertThat(auth.getStatus()).isEqualTo(Status.VALID);

View File

@ -340,13 +340,11 @@ public class CertificateTest {
assertThat(cert.getCertID()).isEqualTo("MFgwCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCBQCHZUMh"); assertThat(cert.getCertID()).isEqualTo("MFgwCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCBQCHZUMh");
assertThat(cert.hasRenewalInfo()).isTrue(); assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation()) assertThat(cert.getRenewalInfoLocation())
.isNotEmpty() .hasValue(certResourceUrl);
.contains(certResourceUrl);
var renewalInfo = cert.getRenewalInfo(); var renewalInfo = cert.getRenewalInfo();
assertThat(renewalInfo.getRecheckAfter()) assertThat(renewalInfo.getRetryAfter())
.isNotEmpty() .isEmpty();
.contains(retryAfterInstant);
assertThat(renewalInfo.getSuggestedWindowStart()) assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo("2021-01-03T00:00:00Z"); .isEqualTo("2021-01-03T00:00:00Z");
assertThat(renewalInfo.getSuggestedWindowEnd()) assertThat(renewalInfo.getSuggestedWindowEnd())
@ -355,6 +353,9 @@ public class CertificateTest {
.isNotEmpty() .isNotEmpty()
.contains(url("https://example.com/docs/example-mass-reissuance-event")); .contains(url("https://example.com/docs/example-mass-reissuance-event"));
assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant);
assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant);
provider.close(); provider.close();
} }
@ -404,9 +405,7 @@ public class CertificateTest {
var cert = new Certificate(provider.createLogin(), locationUrl); var cert = new Certificate(provider.createLogin(), locationUrl);
assertThat(cert.hasRenewalInfo()).isTrue(); assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation()) assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl);
.isNotEmpty()
.contains(certResourceUrl);
provider.close(); provider.close();
} }

View File

@ -56,11 +56,6 @@ public class OrderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateOrderResponse"); return getJSON("updateOrderResponse");
} }
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -133,11 +128,6 @@ public class OrderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateOrderResponse"); return getJSON("updateOrderResponse");
} }
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -192,11 +182,6 @@ public class OrderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse"); return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse");
} }
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -250,11 +235,6 @@ public class OrderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateAutoRenewOrderResponse"); return getJSON("updateAutoRenewOrderResponse");
} }
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
}; };
provider.putMetadata("auto-renewal", JSON.empty()); provider.putMetadata("auto-renewal", JSON.empty());
@ -298,11 +278,6 @@ public class OrderTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("finalizeAutoRenewResponse"); return getJSON("finalizeAutoRenewResponse");
} }
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();

View File

@ -68,14 +68,12 @@ public class RenewalInfoTest {
var login = provider.createLogin(); var login = provider.createLogin();
var renewalInfo = new RenewalInfo(login, locationUrl); var renewalInfo = new RenewalInfo(login, locationUrl);
renewalInfo.update(); var recheckAfter = renewalInfo.fetch();
assertThat(recheckAfter).hasValue(retryAfterInstant);
// Check getters // Check getters
try (var softly = new AutoCloseableSoftAssertions()) { try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl); softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl);
softly.assertThat(renewalInfo.getRecheckAfter())
.isNotEmpty()
.contains(retryAfterInstant);
softly.assertThat(renewalInfo.getSuggestedWindowStart()) softly.assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo(startWindow); .isEqualTo(startWindow);
softly.assertThat(renewalInfo.getSuggestedWindowEnd()) softly.assertThat(renewalInfo.getSuggestedWindowEnd())

View File

@ -26,14 +26,13 @@ import java.net.URL;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.assertj.core.api.AutoCloseableSoftAssertions; import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Status; import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON; import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder; import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -141,11 +140,6 @@ public class ChallengeTest {
public JSON readJsonResponse() { public JSON readJsonResponse() {
return getJSON("updateHttpChallengeResponse"); return getJSON("updateHttpChallengeResponse");
} }
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
}; };
var login = provider.createLogin(); var login = provider.createLogin();
@ -179,17 +173,17 @@ public class ChallengeTest {
return getJSON("updateHttpChallengeResponse"); return getJSON("updateHttpChallengeResponse");
} }
@Override @Override
public void handleRetryAfter(String message) throws AcmeException { public Optional<Instant> getRetryAfter() {
throw new AcmeRetryAfterException(message, retryAfter); return Optional.of(retryAfter);
} }
}; };
var login = provider.createLogin(); var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse")); 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.getStatus()).isEqualTo(Status.VALID);
assertThat(challenge.getLocation()).isEqualTo(locationUrl); assertThat(challenge.getLocation()).isEqualTo(locationUrl);

View File

@ -52,7 +52,6 @@ import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitedException; import org.shredzone.acme4j.exception.AcmeRateLimitedException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.exception.AcmeServerException; import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException; import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException; 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 if Retry-After header with absolute date is correctly parsed.
*/ */
@Test @Test
public void testHandleRetryAfterHeaderDate() { public void testHandleRetryAfterHeaderDate() throws AcmeException {
var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS); var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS);
var retryMsg = "absolute date";
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok() stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Retry-After", DATE_FORMATTER.format(retryDate)) .withHeader("Retry-After", DATE_FORMATTER.format(retryDate))
)); ));
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
try (var conn = session.connect()) { try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null); conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter(retryMsg); assertThat(conn.getRetryAfter()).hasValue(retryDate);
} }
});
assertThat(ex.getRetryAfter()).isEqualTo(retryDate);
assertThat(ex.getMessage()).isEqualTo(retryMsg);
} }
/** /**
* Test if Retry-After header with relative timespan is correctly parsed. * Test if Retry-After header with relative timespan is correctly parsed.
*/ */
@Test @Test
public void testHandleRetryAfterHeaderDelta() { public void testHandleRetryAfterHeaderDelta() throws AcmeException {
var delta = 10 * 60 * 60; var delta = 10 * 60 * 60;
var now = Instant.now().truncatedTo(SECONDS); var now = Instant.now().truncatedTo(SECONDS);
var retryMsg = "relative time"; var retryMsg = "relative time";
@ -343,15 +336,10 @@ public class DefaultConnectionTest {
.withHeader("Date", DATE_FORMATTER.format(now)) .withHeader("Date", DATE_FORMATTER.format(now))
)); ));
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
try (var conn = session.connect()) { try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null); conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter(retryMsg); assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta));
} }
});
assertThat(ex.getRetryAfter()).isEqualTo(now.plusSeconds(delta));
assertThat(ex.getMessage()).isEqualTo(retryMsg);
} }
/** /**
@ -365,7 +353,7 @@ public class DefaultConnectionTest {
try (var conn = session.connect()) { try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null); conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter("no header"); assertThat(conn.getRetryAfter()).isEmpty();
} }
verify(getRequestedFor(urlEqualTo(REQUEST_PATH))); verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));

View File

@ -80,11 +80,6 @@ public class DummyConnection implements Connection {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public void handleRetryAfter(String message) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override @Override
public Optional<String> getNonce() { public Optional<String> getNonce() {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();

View File

@ -16,8 +16,10 @@ package org.shredzone.acme4j.provider;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import org.shredzone.acme4j.Login; 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()); return session.login(new URL(TestUtils.ACCOUNT_URL), TestUtils.createKeyPair());
} }
@Override
public Optional<Instant> getRetryAfter() {
return Optional.empty();
}
@Override @Override
public boolean accepts(URI serverUri) { public boolean accepts(URI serverUri) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();

View File

@ -45,7 +45,6 @@ import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.util.KeyPairUtils; import org.shredzone.acme4j.util.KeyPairUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -142,7 +141,7 @@ public class ClientTest {
order.execute(domainKeyPair); order.execute(domainKeyPair);
// Wait for the order to complete // Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::update); Status status = waitForCompletion(order::getStatus, order::fetch);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError() LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString) .map(Problem::toString)
@ -293,7 +292,7 @@ public class ClientTest {
challenge.trigger(); challenge.trigger();
// Poll for the challenge to complete. // Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::update); Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError() LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString) .map(Problem::toString)
@ -397,7 +396,8 @@ public class ClientTest {
* Method of the resource that returns the current status * Method of the resource that returns the current status
* @param statusUpdater * @param statusUpdater
* Method of the resource that updates the internal state and fetches the * 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} * @return The final status, either {@link Status#VALID} or {@link Status#INVALID}
* @throws AcmeException * @throws AcmeException
* If an error occured, or if the status did not reach one of the accepted * 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++) { for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS); LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
// A reasonable default retry-after delay
Instant now = Instant.now(); Instant now = Instant.now();
Instant retryAfter = now.plusSeconds(3L);
// Update the status property // Update the status property
try { Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
statusUpdater.update(); .orElse(now.plusSeconds(3L));
} 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();
}
// Check the status // Check the status
Status currentStatus = statusSupplier.get(); Status currentStatus = statusSupplier.get();
@ -445,12 +438,12 @@ public class ClientTest {
} }
/** /**
* Functional interface that refers to a resource update method that is able to * Functional interface that refers to a resource update method that returns an
* throw an {@link AcmeException}. * optional retry-after instant and is able to throw an {@link AcmeException}.
*/ */
@FunctionalInterface @FunctionalInterface
private interface UpdateMethod { private interface UpdateMethod {
void update() throws AcmeException; Optional<Instant> updateAndGetRetryAfter() throws AcmeException;
} }
/** /**

View File

@ -13,7 +13,7 @@ ZeroSSL does not provide a staging server (as of Feburary 2024).
## Note ## Note
* ZeroSSL requires account creation with [key identifier](../usage/account.md#external-account-binding). * 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. * 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). * 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).

View File

@ -108,7 +108,7 @@ public void fetchCertificate(Collection<String> domains)
order.execute(domainKeyPair); order.execute(domainKeyPair);
// Wait for the order to complete // Wait for the order to complete
Status status = waitForCompletion(order::getStatus, order::update); Status status = waitForCompletion(order::getStatus, order::fetch);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Order has failed, reason: {}", order.getError() LOG.error("Order has failed, reason: {}", order.getError()
.map(Problem::toString) .map(Problem::toString)
@ -265,7 +265,7 @@ private void authorize(Authorization auth)
challenge.trigger(); challenge.trigger();
// Poll for the challenge to complete. // Poll for the challenge to complete.
Status status = waitForCompletion(challenge::getStatus, challenge::update); Status status = waitForCompletion(challenge::getStatus, challenge::fetch);
if (status != Status.VALID) { if (status != Status.VALID) {
LOG.error("Challenge has failed, reason: {}", challenge.getError() LOG.error("Challenge has failed, reason: {}", challenge.getError()
.map(Problem::toString) .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. 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. 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 ```java
private Status waitForCompletion(Supplier<Status> statusSupplier, private Status waitForCompletion(Supplier<Status> statusSupplier,
@ -380,18 +380,11 @@ private Status waitForCompletion(Supplier<Status> statusSupplier,
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS); LOG.info("Checking current status, attempt {} of {}", attempt, MAX_ATTEMPTS);
// A reasonable default retry-after delay
Instant now = Instant.now(); Instant now = Instant.now();
Instant retryAfter = now.plusSeconds(3L);
// Update the status property // Update the status property
try { Instant retryAfter = statusUpdater.updateAndGetRetryAfter()
statusUpdater.update(); .orElse(now.plusSeconds(3L));
} 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();
}
// Check the status // Check the status
Status currentStatus = statusSupplier.get(); Status currentStatus = statusSupplier.get();
@ -414,7 +407,7 @@ private Status waitForCompletion(Supplier<Status> statusSupplier,
@FunctionalInterface @FunctionalInterface
private interface UpdateMethod { private interface UpdateMethod {
void update() throws AcmeException; Optional<Instant> updateAndGetRetryAfter() throws AcmeException;
} }
``` ```

View File

@ -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. - 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.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. - `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 ## Migration to Version 3.0.0

View File

@ -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. 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 !!! note
The internal state of the resource is still updated. The internal state of the resource is still updated.

View File

@ -72,7 +72,7 @@ Now you have to wait for the server to check your response. If the checks are co
```java ```java
while (!EnumSet.of(Status.VALID, Status.INVALID).contains(auth.getStatus())) { while (!EnumSet.of(Status.VALID, Status.INVALID).contains(auth.getStatus())) {
Thread.sleep(3000L); 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. * 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). * 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()`. * 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. 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())) { while (!EnumSet.of(Status.VALID, Status.INVALID).contains(order.getStatus())) {
Thread.sleep(3000L); 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. * 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()`. * 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 !!! tip
If the status is `PENDING`, you have not completed all authorizations yet. If the status is `PENDING`, you have not completed all authorizations yet.