Skip to content

Commit 5732544

Browse files
authored
feat: add ratelimiter to allow transparent retry of 429 (#89)
* feat: add ratelimiter to allow transparent retry of 429 * fix: change configuration to be done via HttpOptions
1 parent ab8692a commit 5732544

File tree

7 files changed

+356
-1
lines changed

7 files changed

+356
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
23
/.idea
34
/target/
45
.classpath
@@ -8,7 +9,7 @@ venv
89
.factorypath
910
/test-output/
1011
config
11-
.iml
12+
*.iml
1213
.pre-commit-config.yaml
1314
.secrets.baseline
1415
.vscode/

src/main/java/com/ibm/cloud/sdk/core/http/HttpClientSingleton.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
package com.ibm.cloud.sdk.core.http;
1515

1616
import com.ibm.cloud.sdk.core.http.HttpConfigOptions.LoggingLevel;
17+
import com.ibm.cloud.sdk.core.http.ratelimit.RateLimitInterceptor;
1718
import com.ibm.cloud.sdk.core.service.BaseService;
1819
import com.ibm.cloud.sdk.core.service.security.DelegatingSSLSocketFactory;
1920
import okhttp3.Authenticator;
@@ -308,6 +309,14 @@ public OkHttpClient configureClient(OkHttpClient client, HttpConfigOptions optio
308309
if (options.getLoggingLevel() != null) {
309310
client = setLoggingLevel(client, options.getLoggingLevel());
310311
}
312+
if (options.getDefaultRetryInterval() > 0) {
313+
client = client.newBuilder()
314+
.addInterceptor(new RateLimitInterceptor(
315+
options.getAuthenticator()
316+
, options.getDefaultRetryInterval()
317+
, options.getMaxRetries()))
318+
.build();
319+
}
311320
}
312321
return client;
313322
}

src/main/java/com/ibm/cloud/sdk/core/http/HttpConfigOptions.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
package com.ibm.cloud.sdk.core.http;
1515

16+
import com.ibm.cloud.sdk.core.http.ratelimit.RateLimitConstants;
1617
import okhttp3.Authenticator;
1718

1819
import java.net.Proxy;
@@ -37,6 +38,11 @@ public enum LoggingLevel {
3738
private Authenticator proxyAuthenticator;
3839
private LoggingLevel loggingLevel;
3940

41+
// Ratelimiting properties
42+
private com.ibm.cloud.sdk.core.security.Authenticator authenticator;
43+
private int defaultInterval = 0;
44+
private int maxRetries = 0;
45+
4046
public boolean shouldDisableSslVerification() {
4147
return this.disableSslVerification;
4248
}
@@ -53,12 +59,29 @@ public LoggingLevel getLoggingLevel() {
5359
return this.loggingLevel;
5460
}
5561

62+
public com.ibm.cloud.sdk.core.security.Authenticator getAuthenticator() {
63+
return authenticator;
64+
}
65+
66+
public int getDefaultRetryInterval() {
67+
return defaultInterval;
68+
}
69+
70+
public int getMaxRetries() {
71+
return maxRetries;
72+
}
73+
5674
public static class Builder {
5775
private boolean disableSslVerification;
5876
private Proxy proxy;
5977
private Authenticator proxyAuthenticator;
6078
private LoggingLevel loggingLevel;
6179

80+
// Ratelimiting properties
81+
private com.ibm.cloud.sdk.core.security.Authenticator authenticator;
82+
private int defaultInterval = 0;
83+
private int maxRetries = 0;
84+
6285
public HttpConfigOptions build() {
6386
return new HttpConfigOptions(this);
6487
}
@@ -75,6 +98,22 @@ public Builder disableSslVerification(boolean disableSslVerification) {
7598
return this;
7699
}
77100

101+
/**
102+
* Sets retry on rate limiting policy (429). See {@link RateLimitConstants} for defaults to use
103+
*
104+
* @param authenticator to use for retries, the {@link Authenticator} used by the client
105+
* @param defaultInterval if not specified in the response, how long to wait until the next attempt
106+
* @param maxRetries the maximum amount of retries for an request
107+
* @return the builder
108+
*/
109+
public Builder enableRateLimitRetry(com.ibm.cloud.sdk.core.security.Authenticator authenticator
110+
, int defaultInterval, int maxRetries) {
111+
this.authenticator = authenticator;
112+
this.defaultInterval = defaultInterval;
113+
this.maxRetries = maxRetries;
114+
return this;
115+
}
116+
78117
/**
79118
* Sets HTTP proxy to be used by connections with the current client.
80119
*
@@ -114,5 +153,9 @@ private HttpConfigOptions(Builder builder) {
114153
this.proxy = builder.proxy;
115154
this.proxyAuthenticator = builder.proxyAuthenticator;
116155
this.loggingLevel = builder.loggingLevel;
156+
// rate limiting related
157+
this.authenticator = builder.authenticator;
158+
this.defaultInterval = builder.defaultInterval;
159+
this.maxRetries = builder.maxRetries;
117160
}
118161
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.ibm.cloud.sdk.core.http.ratelimit;
2+
3+
4+
/**
5+
* This class encapsulate constants that can be passed as defaults to {@link RateLimitInterceptor}.
6+
*/
7+
public interface RateLimitConstants {
8+
/**
9+
* Time to wait before retrying in absence of information from server.
10+
*/
11+
int DEFAULT_INTERVAL = 5000;
12+
/**
13+
* Maximum amount of times a request will be retried.
14+
*/
15+
int MAX_RETRIES = Integer.MAX_VALUE;
16+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.ibm.cloud.sdk.core.http.ratelimit;
2+
3+
/**
4+
* Carries per request state for {@link RateLimitInterceptor}.
5+
*/
6+
public class RateLimitContext {
7+
private int remainingRetries;
8+
9+
public RateLimitContext(int maxRetries) {
10+
11+
this.remainingRetries = maxRetries;
12+
}
13+
14+
public boolean decrementAndCheck() {
15+
remainingRetries--;
16+
return remainingRetries > 0;
17+
}
18+
19+
@Override
20+
public String toString() {
21+
return "RateLimitContext{"
22+
+ "remainingRetries="
23+
+ remainingRetries + '}';
24+
}
25+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.ibm.cloud.sdk.core.http.ratelimit;
2+
3+
import com.ibm.cloud.sdk.core.security.Authenticator;
4+
import okhttp3.Interceptor;
5+
import okhttp3.Request;
6+
import okhttp3.Response;
7+
8+
import java.io.IOException;
9+
import java.util.logging.Logger;
10+
11+
/**
12+
* Provides means to retry requests on RateLimiting (429).
13+
*/
14+
public class RateLimitInterceptor implements Interceptor {
15+
16+
private static final Logger LOG = Logger.getLogger(RateLimitInterceptor.class.getName());
17+
18+
private int defaultInterval;
19+
private int maxRetries;
20+
private Authenticator authenticator;
21+
22+
public RateLimitInterceptor(Authenticator authenticator, int defaultInterval, int maxRetries) {
23+
this.defaultInterval = defaultInterval;
24+
this.maxRetries = maxRetries;
25+
this.authenticator = authenticator;
26+
}
27+
28+
29+
/**
30+
* Checks response and retries with delay in case of a 429, time between attempts is taken from
31+
* response, if no valid information is on the response, the default is used.
32+
*/
33+
@Override
34+
public Response intercept(Interceptor.Chain chain) throws IOException {
35+
Request request = chain.request();
36+
Response response = chain.proceed(request);
37+
38+
// 429 indicates a rate limit error
39+
while (shouldRetry(response, request)) {
40+
int interval = getInterval(response);
41+
42+
// wait & retry
43+
// A dispatcher based model may be more efficient.
44+
try {
45+
LOG.info("Will retry after: " + interval + "ms");
46+
Thread.sleep(interval);
47+
} catch (InterruptedException e) {
48+
LOG.fine("Thread was interrupted, likely call cancelled");
49+
}
50+
51+
// At this point, time has passed, so we want to ensure auth is up-to date,
52+
// as well as ensure we embed a context to carry state forward
53+
Request.Builder builder = request.newBuilder();
54+
55+
// if this is the first round, no tag exists yet
56+
if (request.tag(RateLimitContext.class) == null) {
57+
builder = builder.tag(RateLimitContext.class, new RateLimitContext(maxRetries));
58+
}
59+
if (authenticator != null) {
60+
authenticator.authenticate(builder);
61+
}
62+
response.close();
63+
request = builder.build();
64+
response = chain.proceed(request);
65+
}
66+
67+
return response;
68+
}
69+
70+
private int getInterval(Response response) {
71+
// if the server didn't provide details, we'll still wait default interval
72+
int interval = defaultInterval;
73+
74+
// Both headers can be used concurrently, but need to be consistent.
75+
// draft allows for more fine-grained control, we just cover the basics for now
76+
// https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html
77+
String headerVal = response.header("RateLimit-Reset");
78+
79+
// RFC 7231, section 7.1.3: Retry-After
80+
if (headerVal == null) {
81+
headerVal = response.header("Retry-After");
82+
}
83+
84+
// According to spec, this will be a integer, if it's not, we're falling back to default
85+
if (headerVal != null) {
86+
try {
87+
int responseInterval = Integer.parseInt(headerVal, 10) * 1000;
88+
// just in case it's a negative number
89+
if (responseInterval > 0) {
90+
interval = responseInterval;
91+
}
92+
} catch (NumberFormatException e) {
93+
LOG.info("Response included a non-numeric value for Retry-After/RateLimit-Reset");
94+
}
95+
}
96+
return interval;
97+
}
98+
99+
private boolean shouldRetry(Response response, Request request) {
100+
// if we got 429, and we didn't exhaust maxRetries, we should retry
101+
if (!response.isSuccessful() && response.code() == 429) {
102+
// the first attempt won't have a context yet, so we need to check
103+
RateLimitContext context = request.tag(RateLimitContext.class);
104+
if (context != null && !context.decrementAndCheck()) {
105+
LOG.info("Retries exhausted for RateLimit, giving up");
106+
return false;
107+
}
108+
if (context != null) {
109+
LOG.fine(context.toString());
110+
}
111+
return true;
112+
}
113+
return false;
114+
}
115+
}

0 commit comments

Comments
 (0)