mirror of https://github.com/shred/acme4j
Evaluate Retry-After header on rate limit excess
parent
5dc1b9314e
commit
279e0f3993
|
@ -16,6 +16,7 @@ package org.shredzone.acme4j.connector;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.shredzone.acme4j.Registration;
|
import org.shredzone.acme4j.Registration;
|
||||||
|
@ -100,6 +101,13 @@ public interface Connection extends AutoCloseable {
|
||||||
*/
|
*/
|
||||||
URI getLink(String relation);
|
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
|
* Handles a problem by throwing an exception. If a JSON problem was returned, an
|
||||||
* {@link AcmeServerException} will be thrown. Otherwise a generic
|
* {@link AcmeServerException} will be thrown. Otherwise a generic
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
*/
|
*/
|
||||||
package org.shredzone.acme4j.exception;
|
package org.shredzone.acme4j.exception;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An exception that is thrown when a rate limit was exceeded.
|
* 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 {
|
public class AcmeRateLimitExceededException extends AcmeServerException {
|
||||||
private static final long serialVersionUID = 4150484059796413069L;
|
private static final long serialVersionUID = 4150484059796413069L;
|
||||||
|
|
||||||
|
private final Date retryAfter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new {@link AcmeRateLimitExceededException}.
|
* Creates a new {@link AcmeRateLimitExceededException}.
|
||||||
*
|
*
|
||||||
|
@ -29,9 +33,21 @@ public class AcmeRateLimitExceededException extends AcmeServerException {
|
||||||
* {@code "urn:ietf:params:acme:error:rateLimited"})
|
* {@code "urn:ietf:params:acme:error:rateLimited"})
|
||||||
* @param detail
|
* @param detail
|
||||||
* Human readable error message
|
* 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import java.security.KeyPair;
|
||||||
import java.security.cert.CertificateException;
|
import java.security.cert.CertificateException;
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -314,6 +315,29 @@ public class DefaultConnection implements Connection {
|
||||||
return null;
|
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
|
@Override
|
||||||
public void throwAcmeException() throws AcmeException, IOException {
|
public void throwAcmeException() throws AcmeException, IOException {
|
||||||
assertConnectionIsOpen();
|
assertConnectionIsOpen();
|
||||||
|
@ -338,7 +362,7 @@ public class DefaultConnection implements Connection {
|
||||||
|
|
||||||
case "urn:acme:error:rateLimited":
|
case "urn:acme:error:rateLimited":
|
||||||
case "urn:ietf:params:acme:error:rateLimited":
|
case "urn:ietf:params:acme:error:rateLimited":
|
||||||
throw new AcmeRateLimitExceededException(type, detail);
|
throw new AcmeRateLimitExceededException(type, detail, getRetryAfterHeader());
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new AcmeServerException(type, detail);
|
throw new AcmeServerException(type, detail);
|
||||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j.impl;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.*;
|
import static org.hamcrest.Matchers.*;
|
||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
|
import static org.mockito.Matchers.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
|
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@ import java.net.URISyntaxException;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
@ -189,6 +191,43 @@ public class DefaultConnectionTest {
|
||||||
verifyNoMoreInteractions(mockUrlConnection);
|
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.
|
* Test if an {@link AcmeServerException} is thrown on an acme problem.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -15,6 +15,7 @@ package org.shredzone.acme4j.impl;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.shredzone.acme4j.Registration;
|
import org.shredzone.acme4j.Registration;
|
||||||
|
@ -72,6 +73,11 @@ public class DummyConnection implements Connection {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Date getRetryAfterHeader() {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void throwAcmeException() throws AcmeException {
|
public void throwAcmeException() throws AcmeException {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
|
|
Loading…
Reference in New Issue