Evaluate Retry-After header on rate limit excess

pull/18/head
Richard Körber 2016-06-21 00:00:16 +02:00
parent 5dc1b9314e
commit 279e0f3993
5 changed files with 95 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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