Skip to content

Commit 2704b09

Browse files
committed
Add support for automatic request retries (#900)
1 parent 1042795 commit 2704b09

File tree

5 files changed

+334
-4
lines changed

5 files changed

+334
-4
lines changed

src/main/java/com/stripe/net/HttpClient.java

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
11
package com.stripe.net;
22

33
import com.stripe.Stripe;
4+
import com.stripe.exception.ApiConnectionException;
45
import com.stripe.exception.StripeException;
56
import com.stripe.util.Stopwatch;
7+
import java.net.ConnectException;
8+
import java.time.Duration;
69
import java.util.HashMap;
710
import java.util.Map;
11+
import java.util.concurrent.ThreadLocalRandom;
812

913
/** Base abstract class for HTTP clients used to send requests to Stripe's API. */
1014
public abstract class HttpClient {
11-
private static final RequestTelemetry requestTelemetry = new RequestTelemetry();
15+
/** Maximum sleep time between tries to send HTTP requests after network failure. */
16+
public static final Duration maxNetworkRetriesDelay = Duration.ofSeconds(5);
17+
18+
/** Minimum sleep time between tries to send HTTP requests after network failure. */
19+
public static final Duration minNetworkRetriesDelay = Duration.ofMillis(500);
20+
21+
private final int maxNetworkRetries;
22+
23+
private final RequestTelemetry requestTelemetry = new RequestTelemetry();
24+
25+
/** A value indicating whether the client should sleep between automatic request retries. */
26+
boolean networkRetriesSleep = true;
27+
28+
/** Initializes a new instance of the {@link HttpClient} class with default parameters. */
29+
public HttpClient() {
30+
this(0);
31+
}
32+
33+
/**
34+
* Initializes a new instance of the {@link HttpClient} class.
35+
*
36+
* @param maxNetworkRetries the maximum number of times the client will retry requests that fail
37+
* due to an intermittent problem.
38+
*/
39+
public HttpClient(int maxNetworkRetries) {
40+
this.maxNetworkRetries = maxNetworkRetries;
41+
}
1242

1343
/**
1444
* Sends the given request to Stripe's API.
@@ -40,6 +70,50 @@ public StripeResponse requestWithTelemetry(StripeRequest request) throws StripeE
4070
return response;
4171
}
4272

73+
/**
74+
* Sends the given request to Stripe's API, retrying the request in cases of intermittent
75+
* problems.
76+
*
77+
* @param request the request
78+
* @return the response
79+
* @throws StripeException If the request fails for any reason
80+
*/
81+
public StripeResponse requestWithRetries(StripeRequest request) throws StripeException {
82+
ApiConnectionException requestException = null;
83+
StripeResponse response = null;
84+
int retry = 0;
85+
86+
while (true) {
87+
requestException = null;
88+
89+
try {
90+
response = this.requestWithTelemetry(request);
91+
} catch (ApiConnectionException e) {
92+
requestException = e;
93+
}
94+
95+
if (!this.shouldRetry(retry, requestException, response)) {
96+
break;
97+
}
98+
99+
retry += 1;
100+
101+
try {
102+
Thread.sleep(this.sleepTime(retry).toMillis());
103+
} catch (InterruptedException e) {
104+
Thread.currentThread().interrupt();
105+
}
106+
}
107+
108+
if (requestException != null) {
109+
throw requestException;
110+
}
111+
112+
response.setNumRetries(retry);
113+
114+
return response;
115+
}
116+
43117
/**
44118
* Builds the value of the {@code User-Agent} header.
45119
*
@@ -98,4 +172,76 @@ private static String formatAppInfo(Map<String, String> info) {
98172

99173
return str;
100174
}
175+
176+
private boolean shouldRetry(int numRetries, StripeException exception, StripeResponse response) {
177+
// Do not retry if we are out of retries.
178+
if (numRetries >= this.maxNetworkRetries) {
179+
return false;
180+
}
181+
182+
// Retry on connection error.
183+
if ((exception != null)
184+
&& (exception.getCause() != null)
185+
&& (exception.getCause() instanceof ConnectException)) {
186+
return true;
187+
}
188+
189+
// The API may ask us not to retry (eg; if doing so would be a no-op)
190+
// or advise us to retry (eg; in cases of lock timeouts); we defer to that.
191+
if ((response != null) && (response.headers() != null)) {
192+
String value = response.headers().get("Stripe-Should-Retry");
193+
194+
if ("true".equals(value)) {
195+
return true;
196+
}
197+
198+
if ("false".equals(value)) {
199+
return false;
200+
}
201+
}
202+
203+
// Retry on conflict errors.
204+
if ((response != null) && (response.code() == 409)) {
205+
return true;
206+
}
207+
208+
// Retry on 500, 503, and other internal errors.
209+
//
210+
// Note that we expect the Stripe-Should-Retry header to be false
211+
// in most cases when a 500 is returned, since our idempotency framework
212+
// would typically replay it anyway.
213+
if ((response != null) && (response.code() >= 500)) {
214+
return true;
215+
}
216+
217+
return false;
218+
}
219+
220+
private Duration sleepTime(int numRetries) {
221+
// We disable sleeping in some cases for tests.
222+
if (!this.networkRetriesSleep) {
223+
return Duration.ZERO;
224+
}
225+
226+
// Apply exponential backoff with MinNetworkRetriesDelay on the number of numRetries
227+
// so far as inputs.
228+
Duration delay =
229+
Duration.ofNanos((long) (minNetworkRetriesDelay.toNanos() * Math.pow(2, numRetries - 1)));
230+
231+
// Do not allow the number to exceed MaxNetworkRetriesDelay
232+
if (delay.compareTo(maxNetworkRetriesDelay) > 0) {
233+
delay = maxNetworkRetriesDelay;
234+
}
235+
236+
// Apply some jitter by randomizing the value in the range of 75%-100%.
237+
double jitter = ThreadLocalRandom.current().nextDouble(0.75, 1.0);
238+
delay = Duration.ofNanos((long) (delay.toNanos() * jitter));
239+
240+
// But never sleep less than the base sleep seconds.
241+
if (delay.compareTo(minNetworkRetriesDelay) < 0) {
242+
delay = minNetworkRetriesDelay;
243+
}
244+
245+
return delay;
246+
}
101247
}

src/main/java/com/stripe/net/HttpURLConnectionClient.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@
1515
import lombok.Cleanup;
1616

1717
public class HttpURLConnectionClient extends HttpClient {
18+
/**
19+
* Initializes a new instance of the {@link HttpURLConnectionClient} class with default
20+
* parameters.
21+
*/
22+
public HttpURLConnectionClient() {
23+
super();
24+
}
25+
26+
/**
27+
* Initializes a new instance of the {@link HttpURLConnectionClient} class.
28+
*
29+
* @param maxNetworkRetries the maximum number of times the client will retry requests that fail
30+
* due to an intermittent problem.
31+
*/
32+
public HttpURLConnectionClient(int maxNetworkRetries) {
33+
super(maxNetworkRetries);
34+
}
35+
1836
/**
1937
* Sends the given request to Stripe's API.
2038
*

src/main/java/com/stripe/net/LiveStripeResponseGetter.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,24 @@
2222
import java.util.Map;
2323

2424
public class LiveStripeResponseGetter implements StripeResponseGetter {
25-
private final HttpClient httpClient = new HttpURLConnectionClient();
25+
private final HttpClient httpClient;
26+
27+
/**
28+
* Initializes a new instance of the {@link LiveStripeResponseGetter} class with default
29+
* parameters.
30+
*/
31+
public LiveStripeResponseGetter() {
32+
this(null);
33+
}
34+
35+
/**
36+
* Initializes a new instance of the {@link LiveStripeResponseGetter} class.
37+
*
38+
* @param httpClient the HTTP client to use
39+
*/
40+
public LiveStripeResponseGetter(HttpClient httpClient) {
41+
this.httpClient = (httpClient != null) ? httpClient : buildDefaultHttpClient();
42+
}
2643

2744
@Override
2845
public <T> T request(
@@ -33,7 +50,7 @@ public <T> T request(
3350
RequestOptions options)
3451
throws StripeException {
3552
StripeRequest request = new StripeRequest(method, url, params, options);
36-
StripeResponse response = httpClient.requestWithTelemetry(request);
53+
StripeResponse response = httpClient.requestWithRetries(request);
3754

3855
int responseCode = response.code();
3956
String responseBody = response.body();
@@ -67,7 +84,7 @@ public <T> T oauthRequest(
6784
RequestOptions options)
6885
throws StripeException {
6986
StripeRequest request = new StripeRequest(method, url, params, options);
70-
StripeResponse response = this.httpClient.requestWithTelemetry(request);
87+
StripeResponse response = this.httpClient.requestWithRetries(request);
7188

7289
int responseCode = response.code();
7390
String responseBody = response.body();
@@ -92,6 +109,10 @@ public <T> T oauthRequest(
92109
return resource;
93110
}
94111

112+
private static HttpClient buildDefaultHttpClient() {
113+
return new HttpURLConnectionClient();
114+
}
115+
95116
private static void raiseMalformedJsonError(
96117
String responseBody, int responseCode, String requestId) throws ApiException {
97118
throw new ApiException(

src/main/java/com/stripe/net/StripeResponse.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public class StripeResponse {
88
int code;
99
String body;
1010
StripeHeaders headers;
11+
int numRetries;
1112

1213
/** Constructs a Stripe response with the specified status code and body. */
1314
public StripeResponse(int code, String body) {
@@ -42,4 +43,12 @@ public String idempotencyKey() {
4243
public String requestId() {
4344
return (headers != null) ? headers.get("Request-Id") : null;
4445
}
46+
47+
public int numRetries() {
48+
return this.numRetries;
49+
}
50+
51+
void setNumRetries(int numRetries) {
52+
this.numRetries = numRetries;
53+
}
4554
}

0 commit comments

Comments
 (0)