Skip to content

Commit

Permalink
sdk/java: accept multiple URLs in Client
Browse files Browse the repository at this point in the history
Allow the Java SDK Client to be configured with multiple cored or
signerd URLs for high availability in the absence of a load balancer.

Closes #486
  • Loading branch information
jbowens authored and iampogo committed Feb 8, 2017
1 parent a9924bf commit 66f8518
Showing 1 changed file with 100 additions and 39 deletions.
139 changes: 100 additions & 39 deletions sdk/java/src/main/java/com/chain/http/Client.java
Expand Up @@ -7,9 +7,13 @@
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.net.*;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.List;
import java.util.Objects;

import com.google.gson.Gson;

Expand All @@ -31,7 +35,8 @@
*/
public class Client {

private URL url;
private AtomicInteger urlIndex;
private List<URL> urls;
private String accessToken;
private OkHttpClient httpClient;
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
Expand All @@ -51,7 +56,17 @@ private static class BuildProperties {
}

public Client(Builder builder) {
this.url = builder.url;
List<URL> urls = new ArrayList<URL>(builder.urls);
if (urls.isEmpty()) {
try {
urls.add(new URL("http://localhost:1999"));
} catch (MalformedURLException e) {
throw new RuntimeException("invalid default development URL", e);
}
}

this.urlIndex = new AtomicInteger(0);
this.urls = urls;
this.accessToken = builder.accessToken;
this.httpClient = buildHttpClient(builder);
}
Expand Down Expand Up @@ -191,11 +206,19 @@ public T create(Response response, Gson deserializer) throws ChainException, IOE
}

/**
* Returns the base url stored in the client.
* @return the client's base url
* Returns the preferred base URL stored in the client.
* @return the client's base URL
*/
public URL url() {
return this.url;
return this.urls.get(0);
}

/**
* Returns the list of base URLs used by the client.
* @return the client's base URLs
*/
public List<URL> urls() {
return new ArrayList<>(this.urls);
}

/**
Expand Down Expand Up @@ -289,22 +312,31 @@ private <T> T post(String path, Object body, ResponseCreator<T> respCreator)
RequestBody requestBody = RequestBody.create(this.JSON, serializer.toJson(body));
Request req;

try {
ChainException exception = null;
for (int attempt = 1; attempt - 1 <= MAX_RETRIES; attempt++) {

int idx = this.urlIndex.get();
URL endpointURL;
try {
URI u = new URI(this.urls.get(idx).toString() + "/" + path);
u = u.normalize();
endpointURL = new URL(u.toString());
} catch (MalformedURLException ex) {
throw new BadURLException(ex.getMessage());
} catch (URISyntaxException ex) {
throw new BadURLException(ex.getMessage());
}

Request.Builder builder =
new Request.Builder()
.header("User-Agent", "chain-sdk-java/" + version)
.url(this.createEndpoint(path))
.url(endpointURL)
.method("POST", requestBody);
if (hasAccessToken()) {
builder = builder.header("Authorization", buildCredentials());
}
req = builder.build();
} catch (MalformedURLException ex) {
throw new BadURLException(ex.getMessage());
}

ChainException exception = null;
for (int attempt = 1; attempt - 1 <= MAX_RETRIES; attempt++) {
// Wait between retrys. The first attempt will not wait at all.
if (attempt > 1) {
int delayMillis = retryDelayMillis(attempt - 1);
Expand All @@ -318,14 +350,23 @@ private <T> T post(String path, Object body, ResponseCreator<T> respCreator)
Response resp = this.checkError(this.httpClient.newCall(req).execute());
return respCreator.create(resp, serializer);
} catch (IOException ex) {
// This URL's process might be unhealthy; move to the next.
this.nextURL(idx);

// The OkHttp library already performs retries for most
// I/O-related errors. We can add retries here too if this
// becomes a problem.
throw new HTTPException(ex.getMessage());
} catch (ConnectivityException ex) {
// This URL's process might be unhealthy; move to the next.
this.nextURL(idx);

// ConnectivityExceptions are always retriable.
exception = ex;
} catch (APIException ex) {
// This URL's process might be unhealthy; move to the next.
this.nextURL(idx);

// Check if this error is retriable (either it's a status code that's
// always retriable or the error is explicitly marked as temporary.
if (!isRetriableStatusCode(ex.statusCode) && !ex.temporary) {
Expand Down Expand Up @@ -414,14 +455,15 @@ private Response checkError(Response response) throws ChainException {
return response;
}

private URL createEndpoint(String path) throws MalformedURLException {
try {
URI u = new URI(this.url.toString() + "/" + path);
u = u.normalize();
return new URL(u.toString());
} catch (URISyntaxException e) {
throw new MalformedURLException();
private void nextURL(int failedIndex) {
if (this.urls.size() == 1) {
return; // No point contending on the CAS if there's only one URL.
}

// A request to the url at failedIndex just failed. Move to the next
// URL in the list.
int nextIndex = (failedIndex + 1) % this.urls.size();
this.urlIndex.compareAndSet(failedIndex, nextIndex);
}

private String buildCredentials() {
Expand All @@ -439,21 +481,17 @@ private String buildCredentials() {
return Credentials.basic(user, pass);
}

private String identifier() {
if (this.hasAccessToken()) {
return String.format(
"%s://%s@%s", this.url.getProtocol(), this.accessToken, this.url.getAuthority());
}
return String.format("%s://%s", this.url.getProtocol(), this.url.getAuthority());
}

/**
* Overrides {@link Object#hashCode()}
* @return the hash code
*/
@Override
public int hashCode() {
return identifier().hashCode();
int code = this.urls.hashCode();
if (this.hasAccessToken()) {
code = code * 31 + this.accessToken.hashCode();
}
return code;
}

/**
Expand All @@ -467,14 +505,17 @@ public boolean equals(Object o) {
if (!(o instanceof Client)) return false;

Client other = (Client) o;
return this.identifier().equals(other.identifier());
if (!this.urls.equals(other.urls)) {
return false;
}
return Objects.equals(this.accessToken, other.accessToken);
}

/**
* A builder class for creating client objects
*/
public static class Builder {
private URL url;
private List<URL> urls;
private String accessToken;
private CertificatePinner cp;
private long connectTimeout;
Expand All @@ -487,40 +528,60 @@ public static class Builder {
private ConnectionPool pool;

public Builder() {
this.urls = new ArrayList<URL>();
this.setDefaults();
}

private void setDefaults() {
try {
this.url = new URL("http://localhost:1999");
} catch (MalformedURLException e) {
throw new RuntimeException("invalid default development URL", e);
}
this.setReadTimeout(30, TimeUnit.SECONDS);
this.setWriteTimeout(30, TimeUnit.SECONDS);
this.setConnectTimeout(30, TimeUnit.SECONDS);
this.setConnectionPool(50, 2, TimeUnit.MINUTES);
}

/**
* Sets the URL for the client
* Adds a base URL for the client to use.
* @param url the URL of the Chain Core or HSM.
*/
public Builder addURL(String url) throws BadURLException {
try {
this.urls.add(new URL(url));
} catch (MalformedURLException e) {
throw new BadURLException(e.getMessage());
}
return this;
}

/**
* Adds a base URL for the client to use.
* @param url the URL of the Chain Core or HSM.
*/
public Builder addURL(URL url) {
this.urls.add(url);
return this;
}

/**
* Sets the URL for the client. It replaces all existing Chain Core
* URLs with the provided URL.
* @param url the URL of the Chain Core or HSM
*/
public Builder setURL(String url) throws BadURLException {
try {
this.url = new URL(url);
this.urls = new ArrayList<URL>(Arrays.asList(new URL(url)));
} catch (MalformedURLException e) {
throw new BadURLException(e.getMessage());
}
return this;
}

/**
* Sets the URL for the client
* Sets the URL for the client. It replaces all existing Chain Core
* URLs with the provided URL.
* @param url the URL of the Chain Core or HSM
*/
public Builder setURL(URL url) {
this.url = url;
this.urls = new ArrayList<URL>(Arrays.asList(url));
return this;
}

Expand Down

0 comments on commit 66f8518

Please sign in to comment.