Simplify handling of Retry-After header

This commit is contained in:
Richard Körber
2024-02-26 18:24:55 +01:00
parent 908e11b152
commit f9d479a8f7
22 changed files with 182 additions and 192 deletions

View File

@@ -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.
* <p>
* 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.
* <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.
*
* @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<Instant> 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.
* <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 {
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<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
* 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<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
* 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.

View File

@@ -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<String> getNonce() {
var nonceHeaderOpt = getResponse().headers()

View File

@@ -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.
* <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 {
private static final long serialVersionUID = 4461979121063649905L;

View File

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

View File

@@ -96,11 +96,6 @@ public class AccountTest {
public Collection<URL> 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());

View File

@@ -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<Instant> 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();
}
}

View File

@@ -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<Instant> 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);

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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())

View File

@@ -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<Instant> 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);

View File

@@ -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)));

View File

@@ -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<String> getNonce() {
throw new UnsupportedOperationException();

View File

@@ -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<Instant> getRetryAfter() {
return Optional.empty();
}
@Override
public boolean accepts(URI serverUri) {
throw new UnsupportedOperationException();