From 279e0f3993284a11641e82347e91e00d935ebfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Tue, 21 Jun 2016 00:00:16 +0200 Subject: [PATCH] Evaluate Retry-After header on rate limit excess --- .../acme4j/connector/Connection.java | 8 ++++ .../AcmeRateLimitExceededException.java | 18 ++++++++- .../acme4j/impl/DefaultConnection.java | 26 ++++++++++++- .../acme4j/impl/DefaultConnectionTest.java | 39 +++++++++++++++++++ .../acme4j/impl/DummyConnection.java | 6 +++ 5 files changed, 95 insertions(+), 2 deletions(-) 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 d3e8e536..b19a7059 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 @@ -16,6 +16,7 @@ package org.shredzone.acme4j.connector; import java.io.IOException; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.Map; import org.shredzone.acme4j.Registration; @@ -100,6 +101,13 @@ public interface Connection extends AutoCloseable { */ URI getLink(String relation); + /** + * Returns the moment returned in a "Retry-After" header. + * + * @return Moment, or {@code null} if no "Retry-After" header was set. + */ + Date getRetryAfterHeader(); + /** * Handles a problem by throwing an exception. If a JSON problem was returned, an * {@link AcmeServerException} will be thrown. Otherwise a generic diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRateLimitExceededException.java b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRateLimitExceededException.java index 883db085..3917cf3f 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRateLimitExceededException.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/exception/AcmeRateLimitExceededException.java @@ -13,6 +13,8 @@ */ package org.shredzone.acme4j.exception; +import java.util.Date; + /** * An exception that is thrown when a rate limit was exceeded. * @@ -21,6 +23,8 @@ package org.shredzone.acme4j.exception; public class AcmeRateLimitExceededException extends AcmeServerException { private static final long serialVersionUID = 4150484059796413069L; + private final Date retryAfter; + /** * Creates a new {@link AcmeRateLimitExceededException}. * @@ -29,9 +33,21 @@ public class AcmeRateLimitExceededException extends AcmeServerException { * {@code "urn:ietf:params:acme:error:rateLimited"}) * @param detail * Human readable error message + * @param retryAfter + * The moment the request is expected to succeed again, may be {@code null} + * if not known */ - public AcmeRateLimitExceededException(String type, String detail) { + public AcmeRateLimitExceededException(String type, String detail, Date retryAfter) { super(type, detail); + this.retryAfter = retryAfter; + } + + /** + * Returns the moment the request is expected to succeed again. {@code null} if this + * moment is not known. + */ + public Date getRetryAfter() { + return (retryAfter != null ? new Date(retryAfter.getTime()) : null); } } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/impl/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/DefaultConnection.java index ea0a7617..46c59b60 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/impl/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/impl/DefaultConnection.java @@ -25,6 +25,7 @@ import java.security.KeyPair; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -314,6 +315,29 @@ public class DefaultConnection implements Connection { return null; } + @Override + public Date getRetryAfterHeader() { + assertConnectionIsOpen(); + + // See RFC 2616 section 14.37 + String header = conn.getHeaderField("Retry-After"); + + try { + // delta-seconds + if (header.matches("^\\d+$")) { + int delta = Integer.parseInt(header); + long date = conn.getHeaderFieldDate("Date", System.currentTimeMillis()); + return new Date(date + delta * 1000L); + } + + // HTTP-date + long date = conn.getHeaderFieldDate("Retry-After", 0L); + return (date != 0 ? new Date(date) : null); + } catch (Exception ex) { + throw new AcmeProtocolException("Bad retry-after header value: " + header, ex); + } + } + @Override public void throwAcmeException() throws AcmeException, IOException { assertConnectionIsOpen(); @@ -338,7 +362,7 @@ public class DefaultConnection implements Connection { case "urn:acme:error:rateLimited": case "urn:ietf:params:acme:error:rateLimited": - throw new AcmeRateLimitExceededException(type, detail); + throw new AcmeRateLimitExceededException(type, detail, getRetryAfterHeader()); default: throw new AcmeServerException(type, detail); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DefaultConnectionTest.java index 8932d146..8b979eb3 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DefaultConnectionTest.java @@ -15,6 +15,7 @@ package org.shredzone.acme4j.impl; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import static org.mockito.Matchers.*; import static org.mockito.Mockito.*; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; @@ -27,6 +28,7 @@ import java.net.URISyntaxException; import java.security.KeyPair; import java.security.cert.X509Certificate; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -189,6 +191,43 @@ public class DefaultConnectionTest { verifyNoMoreInteractions(mockUrlConnection); } + /** + * Test if Retry-After header with absolute date is correctly parsed. + */ + @Test + public void testGetRetryAfterHeaderDate() { + Date retryDate = new Date(System.currentTimeMillis() + 10 * 60 * 60 * 1000L); + + when(mockUrlConnection.getHeaderField("Retry-After")).thenReturn(retryDate.toString()); + when(mockUrlConnection.getHeaderFieldDate("Retry-After", 0L)).thenReturn(retryDate.getTime()); + + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { + conn.conn = mockUrlConnection; + assertThat(conn.getRetryAfterHeader(), is(retryDate)); + } + + verify(mockUrlConnection, atLeastOnce()).getHeaderField("Retry-After"); + } + + /** + * Test if Retry-After header with relative timespan is correctly parsed. + */ + @Test + public void testGetRetryAfterHeaderDelta() { + int delta = 10 * 60 * 60; + long now = System.currentTimeMillis(); + + when(mockUrlConnection.getHeaderField("Retry-After")).thenReturn(String.valueOf(delta)); + when(mockUrlConnection.getHeaderFieldDate(eq("Date"), anyLong())).thenReturn(now); + + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { + conn.conn = mockUrlConnection; + assertThat(conn.getRetryAfterHeader(), is(new Date(now + delta * 1000L))); + } + + verify(mockUrlConnection, atLeastOnce()).getHeaderField("Retry-After"); + } + /** * Test if an {@link AcmeServerException} is thrown on an acme problem. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DummyConnection.java b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DummyConnection.java index 97e718a0..d66de2ab 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DummyConnection.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/impl/DummyConnection.java @@ -15,6 +15,7 @@ package org.shredzone.acme4j.impl; import java.net.URI; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.Map; import org.shredzone.acme4j.Registration; @@ -72,6 +73,11 @@ public class DummyConnection implements Connection { throw new UnsupportedOperationException(); } + @Override + public Date getRetryAfterHeader() { + throw new UnsupportedOperationException(); + } + @Override public void throwAcmeException() throws AcmeException { throw new UnsupportedOperationException();