From b0ca63b257611794a675c6a76e28fe0665cf7b4c Mon Sep 17 00:00:00 2001 From: Gokhan Baris Aker Date: Thu, 18 Feb 2016 19:58:22 +0400 Subject: [PATCH] Add httpMaxRetryCount && Simplify http fallback flow --- lib/build.gradle | 2 + lib/src/main/java/io/ably/lib/http/Http.java | 315 ++++++--- .../io/ably/lib/realtime/AblyRealtime.java | 1 + .../main/java/io/ably/lib/rest/AblyRest.java | 4 +- .../ably/lib/transport/ConnectionManager.java | 64 +- .../java/io/ably/lib/transport/Defaults.java | 59 +- .../java/io/ably/lib/transport/Hosts.java | 56 ++ .../java/io/ably/lib/types/AblyException.java | 12 +- .../java/io/ably/lib/types/ClientOptions.java | 23 +- .../test/java/io/ably/lib/http/HttpTest.java | 637 ++++++++++++++++++ .../lib/test/realtime/RealtimeInitTest.java | 5 +- .../io/ably/lib/test/rest/RestInitTest.java | 6 +- .../io/ably/lib/test/util/StatusHandler.java | 52 ++ .../java/io/ably/lib/transport/HostsTest.java | 31 + 14 files changed, 1084 insertions(+), 183 deletions(-) create mode 100644 lib/src/main/java/io/ably/lib/transport/Hosts.java create mode 100644 lib/src/test/java/io/ably/lib/http/HttpTest.java create mode 100644 lib/src/test/java/io/ably/lib/test/util/StatusHandler.java create mode 100644 lib/src/test/java/io/ably/lib/transport/HostsTest.java diff --git a/lib/build.gradle b/lib/build.gradle index 04160e536..55c34da1c 100644 --- a/lib/build.gradle +++ b/lib/build.gradle @@ -10,6 +10,8 @@ dependencies { compile 'com.google.code.gson:gson:2.5' testCompile 'junit:junit:4.12' testCompile 'com.nanohttpd:nanohttpd:2.2.0' + testCompile 'org.nanohttpd:nanohttpd-nanolets:2.2.0' + testCompile 'org.mockito:mockito-all:2.0.2-beta' testCompile 'org.hamcrest:hamcrest-all:1.3' } diff --git a/lib/src/main/java/io/ably/lib/http/Http.java b/lib/src/main/java/io/ably/lib/http/Http.java index dfa464c78..ed276bac9 100644 --- a/lib/src/main/java/io/ably/lib/http/Http.java +++ b/lib/src/main/java/io/ably/lib/http/Http.java @@ -5,22 +5,19 @@ import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.Field; -import java.net.ConnectException; -import java.net.HttpURLConnection; -import java.net.MalformedURLException; -import java.net.NoRouteToHostException; -import java.net.URL; -import java.net.UnknownHostException; +import java.net.*; +import java.nio.charset.Charset; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; -import io.ably.lib.realtime.AblyRealtime; import io.ably.lib.realtime.Connection; -import io.ably.lib.realtime.ConnectionState; -import io.ably.lib.rest.AblyRest; import io.ably.lib.rest.Auth; import io.ably.lib.rest.Auth.AuthMethod; import io.ably.lib.transport.Defaults; +import io.ably.lib.transport.Hosts; import io.ably.lib.types.AblyException; import io.ably.lib.types.ClientOptions; import io.ably.lib.types.ErrorInfo; @@ -41,16 +38,65 @@ public class Http { public interface ResponseHandler { - public T handleResponse(int statusCode, String contentType, Collection linkHeaders, byte[] body) throws AblyException; + T handleResponse(int statusCode, String contentType, Collection linkHeaders, byte[] body) throws AblyException; } public interface BodyHandler { - public T[] handleResponseBody(String contentType, byte[] body) throws AblyException; + T[] handleResponseBody(String contentType, byte[] body) throws AblyException; } public interface RequestBody { - public byte[] getEncoded(); - public String getContentType(); + byte[] getEncoded(); + String getContentType(); + } + + private static class Response { + int statusCode; + String statusLine; + Map> headers; + String contentType; + int contentLength; + byte[] body; + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + public String getHeaderField(String name) { + List fields = getHeaderFields(name); + + if (fields == null || fields.isEmpty()) { + return null; + } + + return fields.get(fields.size() - 1); + } + + /** + * Returns the value of the named header field. + *

+ * If called on a connection that sets the same header multiple times + * with possibly different values, only the last value is returned. + * + * + * @param name the name of a header field. + * @return the value of the named header field, or {@code null} + * if there is no such field in the header. + */ + public List getHeaderFields(String name) { + if(headers == null) { + return null; + } + + return headers.get(name.toLowerCase()); + } } public static class JSONRequestBody implements RequestBody { @@ -81,19 +127,30 @@ public static class ByteArrayRequestBody implements RequestBody { * Public API *************************/ - public Http(AblyRest ably, ClientOptions options) { - this.ably = ably; + public Http(ClientOptions options, Auth auth) { + this.options = options; + this.auth = auth; + this.host = options.restHost; this.scheme = options.tls ? "https://" : "http://"; this.port = Defaults.getPort(options); } - private String getPrefHost() { - if(ably instanceof AblyRealtime) { - Connection connection = ((AblyRealtime)ably).connection; - if(connection.state == ConnectionState.connected) - return connection.connectionManager.getHost(); - } - return Defaults.getHost(ably.options); + /** + * Sets host for this HTTP client + * + * @param host URL string + */ + public void setHost(String host) { + this.host = host; + } + + /** + * Gets host for this HTTP client + * + * @return + */ + public String getHost() { + return host; } /** @@ -185,7 +242,6 @@ private String getAuthorizationHeader(boolean renew) throws AblyException { if(authHeader != null && !renew) { return authHeader; } - Auth auth = ably.auth; if(auth.getAuthMethod() == AuthMethod.basic) { authHeader = "Basic " + Base64Coder.encodeString(auth.getBasicCredentials()); } else { @@ -210,37 +266,38 @@ public void finalize() { } T ablyHttpExecute(String path, String method, Param[] headers, Param[] params, RequestBody requestBody, ResponseHandler responseHandler) throws AblyException { - try { - URL url = buildURL(scheme, getPrefHost(), path, params); - return httpExecute(url, method, headers, requestBody, true, responseHandler); - } catch(AblyException.HostFailedException bhe) { - /* one of the exceptions occurred that signifies a problem reaching the host */ - String[] fallbackHosts = Defaults.getFallbackHosts(ably.options); - if(fallbackHosts != null) { - for(String host : fallbackHosts) { - try { - URL url = buildURL(scheme, host, path, params); - return httpExecute(url, method, headers, requestBody, true, responseHandler); - } catch(AblyException.HostFailedException bhe2) {} + int retryCountRemaining = Hosts.isRestFallbackSupported(this.host)?options.httpMaxRetryCount:0; + String hostCurrent = this.host; + URL url; + + do { + url = buildURL(scheme, hostCurrent, path, params); + + try { + return httpExecute(url, method, headers, requestBody, true, responseHandler); + } catch (AblyException.HostFailedException e) { + retryCountRemaining--; + + if (retryCountRemaining >= 0) { + Log.d(TAG, "Connection failed to host `" + hostCurrent + "`. Searching for new host..."); + hostCurrent = Hosts.getFallback(hostCurrent); + Log.d(TAG, "Switched to `" + hostCurrent + "`."); } } - throw AblyException.fromErrorInfo(new ErrorInfo("Connection failed; no host available", 404, 80000)); - } + } while (retryCountRemaining >= 0); + + throw AblyException.fromErrorInfo(new ErrorInfo("Connection failed; no host available", 404, 80000)); } T httpExecute(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { + Response response; HttpURLConnection conn = null; - InputStream is = null; - int statusCode = 0; - String statusLine = null; - String contentType = null; - byte[] responseBody = null; - List linkHeaders = null; boolean credentialsIncluded = false; try { - /* prepare connection */ conn = (HttpURLConnection)url.openConnection(); + conn.setConnectTimeout(options.httpOpenTimeout); + conn.setReadTimeout(options.httpRequestTimeout); conn.setDoInput(true); if(method != null) { conn.setRequestMethod(method); @@ -260,62 +317,30 @@ T httpExecute(URL url, String method, Param[] headers, RequestBody requestBo /* send request body */ if(requestBody != null) { - conn.setDoOutput(true); - byte[] body = requestBody.getEncoded(); - int length = body.length; - conn.setFixedLengthStreamingMode(length); - conn.setRequestProperty(CONTENT_TYPE, requestBody.getContentType()); - conn.setRequestProperty(CONTENT_LENGTH, Integer.toString(length)); - OutputStream os = conn.getOutputStream(); - os.write(body); + writeRequestBody(requestBody, conn); } - /* get response */ - statusCode = conn.getResponseCode(); - statusLine = conn.getResponseMessage(); - if(statusCode != HttpURLConnection.HTTP_NO_CONTENT) { - contentType = conn.getContentType(); - int contentLength = conn.getContentLength(); - int successStatusCode = (method == POST) ? HttpURLConnection.HTTP_CREATED : HttpURLConnection.HTTP_OK; - is = (statusCode == successStatusCode) ? conn.getInputStream() : conn.getErrorStream(); - if(is != null) { - int read; - if(contentLength == -1) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - byte[] buf = new byte[4 * 1024]; - while((read = is.read(buf)) > -1) { - baos.write(buf, 0, read); - } - responseBody = baos.toByteArray(); - } else { - int idx = 0; - responseBody = new byte[contentLength]; - while((read = is.read(responseBody, idx, contentLength - idx)) > -1) { - idx += read; - } - } - } - } + response = readResponse(conn); } catch(IOException ioe) { throw AblyException.fromThrowable(ioe); } finally { - try { - if(is != null) - is.close(); - if(conn != null) - conn.disconnect(); - } catch(IOException ioe) {} + if(conn != null) + conn.disconnect(); } - if(statusCode == 0) { + if (response.statusCode == 0) { return null; } - if(statusCode < 200 || statusCode >= 300) { + if (response.statusCode >=500 && response.statusCode <= 504) { + throw AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus(response.statusLine, response.statusCode)); + } + + if(response.statusCode < 200 || response.statusCode >= 300) { /* get any in-body error details */ ErrorInfo error = null; - if(responseBody != null && responseBody.length > 0) { - ErrorResponse errorResponse = ErrorResponse.fromJSON(new String(responseBody)); + if(response.body != null && response.body.length > 0) { + ErrorResponse errorResponse = ErrorResponse.fromJSON(new String(response.body)); if(errorResponse != null) { error = errorResponse.error; } @@ -327,14 +352,14 @@ T httpExecute(URL url, String method, Param[] headers, RequestBody requestBo String errorMessageHeader = conn.getHeaderField("X-Ably-ErrorMessage"); if(errorCodeHeader != null) { try { - error = new ErrorInfo(errorMessageHeader, statusCode, Integer.parseInt(errorCodeHeader)); + error = new ErrorInfo(errorMessageHeader, response.statusCode, Integer.parseInt(errorCodeHeader)); } catch(NumberFormatException e) {} } } /* handle www-authenticate */ - if(statusCode == 401) { - String wwwAuthHeader = conn.getHeaderField(WWW_AUTHENTICATE); + if(response.statusCode == 401) { + String wwwAuthHeader = response.getHeaderField(WWW_AUTHENTICATE); if(wwwAuthHeader != null) { boolean stale = (wwwAuthHeader.indexOf("stale") > -1) || (error != null && error.code == 40140); if(withCredentials && (stale || !credentialsIncluded)) { @@ -348,8 +373,8 @@ T httpExecute(URL url, String method, Param[] headers, RequestBody requestBo Log.e(TAG, "Error response from server: " + error); throw AblyException.fromErrorInfo(error); } else { - Log.e(TAG, "Error response from server: statusCode = " + statusCode + "; statusLine = " + statusLine); - throw AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus(statusLine, statusCode)); + Log.e(TAG, "Error response from server: statusCode = " + response.statusCode + "; statusLine = " + response.statusLine); + throw AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus(response.statusLine, response.statusCode)); } } @@ -357,8 +382,87 @@ T httpExecute(URL url, String method, Param[] headers, RequestBody requestBo return null; } - linkHeaders = conn.getHeaderFields().get(LINK); - return responseHandler.handleResponse(statusCode, contentType, linkHeaders, responseBody); + List linkHeaders = response.getHeaderFields(LINK); + return responseHandler.handleResponse(response.statusCode, response.contentType, linkHeaders, response.body); + } + + private void writeRequestBody(RequestBody requestBody, HttpURLConnection conn) throws IOException { + conn.setDoOutput(true); + byte[] body = requestBody.getEncoded(); + int length = body.length; + conn.setFixedLengthStreamingMode(length); + conn.setRequestProperty(CONTENT_TYPE, requestBody.getContentType()); + conn.setRequestProperty(CONTENT_LENGTH, Integer.toString(length)); + OutputStream os = conn.getOutputStream(); + os.write(body); + } + + private Response readResponse(HttpURLConnection connection) throws IOException { + Response response = new Response(); + response.statusCode = connection.getResponseCode(); + response.statusLine = connection.getResponseMessage(); + + /* Store all header field names in lower-case to eliminate case insensitivity */ + Map> caseSensitiveHeaders = connection.getHeaderFields(); + response.headers = new HashMap<>(caseSensitiveHeaders.size(), 1f); + + for (Map.Entry> entry : caseSensitiveHeaders.entrySet()) { + if (entry.getKey() != null) { + response.headers.put(entry.getKey().toLowerCase(), entry.getValue()); + } + } + + if(response.statusCode == HttpURLConnection.HTTP_NO_CONTENT) { + return response; + } + + response.contentType = connection.getContentType(); + response.contentLength = connection.getContentLength(); + + int successStatusCode = (POST.equals(connection.getRequestMethod())) ? HttpURLConnection.HTTP_CREATED : HttpURLConnection.HTTP_OK; + InputStream is = (response.statusCode == successStatusCode) ? connection.getInputStream() : connection.getErrorStream(); + + try { + response.body = readInputStream(is, response.contentLength); + } catch (NullPointerException e) { + /* nothing to read */ + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) {} + } + } + + return response; + } + + private byte[] readInputStream(InputStream inputStream, int bytes) throws IOException { + /* If there is nothing to read */ + if (inputStream == null) { + throw new NullPointerException("inputStream == null"); + } + + int bytesRead = 0; + + if (bytes == -1) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[4 * 1024]; + while((bytesRead = inputStream.read(buffer)) > -1) { + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } + else { + int idx = 0; + byte[] output = new byte[bytes]; + while((bytesRead = inputStream.read(output, idx, bytes - idx)) > -1) { + idx += bytesRead; + } + + return output; + } } private void appendParams(StringBuilder uri, Param[] params) { @@ -392,6 +496,7 @@ private URL buildURL(String uri, Param[] params) { return result; } + /************************* * Private state *************************/ @@ -410,20 +515,22 @@ private URL buildURL(String uri, Param[] params) { } } - private final AblyRest ably; + private final ClientOptions options; + private final Auth auth; private final String scheme; + private String host; private final int port; private String authHeader; private boolean isDisposed; - private static final String TAG = Http.class.getName(); - private static final String LINK = "Link"; - private static final String ACCEPT = "Accept"; - private static final String CONTENT_TYPE = "Content-Type"; - private static final String CONTENT_LENGTH = "Content-Length"; - private static final String JSON = "application/json"; - private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; - private static final String AUTHORIZATION = "Authorization"; + private static final String TAG = Http.class.getName(); + private static final String LINK = "Link"; + private static final String ACCEPT = "Accept"; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String CONTENT_LENGTH = "Content-Length"; + private static final String JSON = "application/json"; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String AUTHORIZATION = "Authorization"; static final String GET = "GET"; static final String POST = "POST"; diff --git a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java index 208dc9407..4e6df1190 100644 --- a/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java +++ b/lib/src/main/java/io/ably/lib/realtime/AblyRealtime.java @@ -5,6 +5,7 @@ import java.util.Map; import io.ably.lib.rest.AblyRest; +import io.ably.lib.transport.Defaults; import io.ably.lib.transport.ITransport; import io.ably.lib.types.AblyException; import io.ably.lib.types.ChannelOptions; diff --git a/lib/src/main/java/io/ably/lib/rest/AblyRest.java b/lib/src/main/java/io/ably/lib/rest/AblyRest.java index 700cca073..f0e321042 100644 --- a/lib/src/main/java/io/ably/lib/rest/AblyRest.java +++ b/lib/src/main/java/io/ably/lib/rest/AblyRest.java @@ -59,9 +59,9 @@ public AblyRest(ClientOptions options) throws AblyException { Log.i(getClass().getName(), "started"); this.clientId = options.clientId; - http = new Http(this, options); - asyncHttp = new AsyncHttp(http); auth = new Auth(this, options); + http = new Http(options, auth); + asyncHttp = new AsyncHttp(http); channels = new Channels(); } diff --git a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java index b06c5eba1..d1ec041f4 100644 --- a/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java +++ b/lib/src/main/java/io/ably/lib/transport/ConnectionManager.java @@ -21,7 +21,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Random; + public class ConnectionManager implements Runnable, ConnectListener { @@ -51,10 +51,16 @@ public class ConnectionManager implements Runnable, ConnectListener { public static class StateIndication { final ConnectionState state; final ErrorInfo reason; - boolean useFallbackHost; + final boolean fallback; + public StateIndication(ConnectionState state, ErrorInfo reason) { + this(state, reason, false); + } + + public StateIndication(ConnectionState state, ErrorInfo reason, boolean fallback) { this.state = state; this.reason = reason; + this.fallback = fallback; } } @@ -91,11 +97,11 @@ public static class StateInfo { @SuppressWarnings("serial") public static final HashMap states = new HashMap() {{ put(ConnectionState.initialized, new StateInfo(ConnectionState.initialized, true, false, false, false, 0, null)); - put(ConnectionState.connecting, new StateInfo(ConnectionState.connecting, true, false, false, false, Defaults.connectTimeout, null)); + put(ConnectionState.connecting, new StateInfo(ConnectionState.connecting, true, false, false, false, Defaults.TIMEOUT_CONNECT, null)); put(ConnectionState.connected, new StateInfo(ConnectionState.connected, false, true, false, false, 0, null)); - put(ConnectionState.disconnected, new StateInfo(ConnectionState.disconnected, true, false, false, true, Defaults.disconnectTimeout, REASON_DISCONNECTED)); - put(ConnectionState.suspended, new StateInfo(ConnectionState.suspended, false, false, false, true, Defaults.suspendedTimeout, REASON_SUSPENDED)); - put(ConnectionState.closing, new StateInfo(ConnectionState.closing, false, false, false, false, Defaults.connectTimeout, REASON_CLOSED)); + put(ConnectionState.disconnected, new StateInfo(ConnectionState.disconnected, true, false, false, true, Defaults.TIMEOUT_DISCONNECT, REASON_DISCONNECTED)); + put(ConnectionState.suspended, new StateInfo(ConnectionState.suspended, false, false, false, true, Defaults.TIMEOUT_SUSPEND, REASON_SUSPENDED)); + put(ConnectionState.closing, new StateInfo(ConnectionState.closing, false, false, false, false, Defaults.TIMEOUT_CONNECT, REASON_CLOSED)); put(ConnectionState.closed, new StateInfo(ConnectionState.closed, false, false, true, false, 0, REASON_CLOSED)); put(ConnectionState.failed, new StateInfo(ConnectionState.failed, false, false, true, false, 0, REASON_FAILED)); }}; @@ -119,7 +125,7 @@ public ConnectionManager(final AblyRealtime ably, Connection connection) { queuedMessages = new ArrayList(); pendingMessages = new PendingMessageQueue(); state = states.get(ConnectionState.initialized); - String transportClass = Defaults.transport; + String transportClass = Defaults.TRANSPORT; /* debug options */ if(options instanceof DebugOptions) protocolListener = ((DebugOptions)options).protocolListener; @@ -467,7 +473,7 @@ private void handleStateChange(StateIndication stateChange) { } private void setSuspendTime() { - suspendTime = (System.currentTimeMillis() + Defaults.suspendedTimeout); + suspendTime = (System.currentTimeMillis() + Defaults.TIMEOUT_SUSPEND); } private StateIndication checkSuspend(StateIndication stateChange) { @@ -483,16 +489,11 @@ private StateIndication checkSuspend(StateIndication stateChange) { /* FIXME: we might want to limit this behaviour to only a specific * set of error codes */ - if(pendingConnect != null && !pendingConnect.fallback && checkConnectivity()) { - String[] fallbackHosts = Defaults.getFallbackHosts(options); - if(fallbackHosts != null && fallbackHosts.length > 0) { - /* we will try a fallback host */ - StateIndication fallbackConnectRequest = new StateIndication(ConnectionState.connecting, null); - fallbackConnectRequest.useFallbackHost = true; - requestState(fallbackConnectRequest); - /* returning null ensures we stay in the connecting state */ - return null; - } + if(pendingConnect != null && checkConnectivity()) { + /* we will try a fallback host */ + requestState(new StateIndication(ConnectionState.connecting, null, true)); + /* returning null ensures we stay in the connecting state */ + return null; } boolean suspendMode = System.currentTimeMillis() > suspendTime; ConnectionState expiredState = suspendMode ? ConnectionState.suspended : ConnectionState.disconnected; @@ -570,19 +571,11 @@ public synchronized void onTransportUnavailable(ITransport transport, TransportP } private class ConnectParams extends TransportParams { - private boolean fallback; - ConnectParams(ClientOptions options, boolean fallback) { + ConnectParams(ClientOptions options) { this.options = options; - this.fallback = fallback; this.connectionKey = connection.key; this.connectionSerial = String.valueOf(connection.serial); - String[] fallbackHosts; - if(fallback && (fallbackHosts = Defaults.getFallbackHosts(options)).length > 0) { - fallbackHosts = Defaults.getFallbackHosts(options); - this.host = fallbackHosts[random.nextInt(fallbackHosts.length)]; - } else { - this.host = Defaults.getHost(options, host, true); - } + this.host = options.realtimeHost; this.port = Defaults.getPort(options); } } @@ -594,7 +587,17 @@ private void connectImpl(StateIndication request) { * Second, choose the host. ConnectParams will use the default * (or requested) host, unless fallback=true, in which case * it will choose a fallback host at random */ - pendingConnect = new ConnectParams(options, request.useFallbackHost); + pendingConnect = new ConnectParams(options); + + if (request.fallback && Hosts.isRealtimeFallbackSupported(options.realtimeHost)) { + String hostFallback = Hosts.getFallback(getHost()); + pendingConnect.host = hostFallback; + ably.http.setHost(hostFallback); + } + else { + pendingConnect.host = options.realtimeHost; + ably.http.setHost(options.restHost); + } /* enter the connecting state */ notifyState(request); @@ -856,9 +859,6 @@ private boolean isFatalError(ErrorInfo err) { private long suspendTime; private long msgSerial; - /* for choosing fallback host*/ - private static final Random random = new Random(); - /* for debug/test only */ private RawProtocolListener protocolListener; diff --git a/lib/src/main/java/io/ably/lib/transport/Defaults.java b/lib/src/main/java/io/ably/lib/transport/Defaults.java index 4afff8cb8..a611402bf 100644 --- a/lib/src/main/java/io/ably/lib/transport/Defaults.java +++ b/lib/src/main/java/io/ably/lib/transport/Defaults.java @@ -2,43 +2,32 @@ import io.ably.lib.types.ClientOptions; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + public class Defaults { - public static final int protocolVersion = 1; - public static final String[] FALLBACK_HOSTS = new String[] {"A.ably-realtime.com", "B.ably-realtime.com", "C.ably-realtime.com", "D.ably-realtime.com", "E.ably-realtime.com"}; - public static final String REST_HOST = "rest.ably.io"; - public static final String REALTIME_HOST = "realtime.ably.io"; - public static final int PORT = 80; - public static final int TLS_PORT = 443; - public static final int connectTimeout = 15000; - public static final int disconnectTimeout = 30000; - public static final int suspendedTimeout = 120000; - public static final int cometRecvTimeout = 90000; - public static final int cometSendTimeout = 10000; - public static final String[] transports = new String[]{"web_socket"}; - public static final String transport = "io.ably.lib.transport.WebSocketTransport$Factory"; + public static final int PROTOCOL_VERSION = 1; + static final List HOST_FALLBACKS = Arrays.asList("A.ably-realtime.com", "B.ably-realtime.com", "C.ably-realtime.com", "D.ably-realtime.com", "E.ably-realtime.com"); + public static final String HOST_REST = "rest.ably.io"; + public static final String HOST_REALTIME = "realtime.ably.io"; + public static final int PORT = 80; + public static final int TLS_PORT = 443; + public static final int TIMEOUT_CONNECT = 15000; + public static final int TIMEOUT_DISCONNECT = 30000; + public static final int TIMEOUT_SUSPEND = 120000; + /* TO313 */ + public static final int TIMEOUT_HTTP_OPEN = 4000; + /* TO314 */ + public static final int TIMEOUT_HTTP_REQUEST = 15000; - public static String getHost(ClientOptions options) { - String host; - host = options.restHost; - if(host == null) - host = Defaults.REST_HOST; - return host; - } - public static String getHost(ClientOptions options, String host, boolean ws) { - if(host == null) { - host = options.restHost; - if(host == null) - host = Defaults.REST_HOST; - } + public static final String[] TRANSPORTS = new String[]{"web_socket"}; + public static final String TRANSPORT = "io.ably.lib.transport.WebSocketTransport$Factory"; + public static final int HTTP_MAX_RETRY_COUNT = 3; - if(ws) { - if(host.equals(options.restHost) && options.realtimeHost != null) - host = options.realtimeHost; - else if(host.equals(Defaults.REST_HOST)) - host = Defaults.REALTIME_HOST; - } - return host; + static { + Collections.shuffle(HOST_FALLBACKS); } public static int getPort(ClientOptions options) { @@ -46,8 +35,4 @@ public static int getPort(ClientOptions options) { ? ((options.tlsPort != 0) ? options.tlsPort : Defaults.TLS_PORT) : ((options.port != 0) ? options.port : Defaults.PORT); } - - public static String[] getFallbackHosts(ClientOptions options) { - return (options.restHost == null) ? Defaults.FALLBACK_HOSTS : null; - } } diff --git a/lib/src/main/java/io/ably/lib/transport/Hosts.java b/lib/src/main/java/io/ably/lib/transport/Hosts.java new file mode 100644 index 000000000..93a08a5e5 --- /dev/null +++ b/lib/src/main/java/io/ably/lib/transport/Hosts.java @@ -0,0 +1,56 @@ +package io.ably.lib.transport; + +import java.util.List; + +/** + * Created by gokhanbarisaker on 2/1/16. + */ +public class Hosts { + private static final List FALLBACKS = Defaults.HOST_FALLBACKS; + private static final String PATTERN_REST_PROD = "^rest.ably.io$"; + private static final String PATTERN_REALTIME_PROD = "^realtime.ably.io$"; + + private Hosts() { /* Restrict new instance creation */ } + + /** + * Provides fallback host alternative for given host + * + * @param host + * @return Successor host that can be used as a fallback + */ + public static String getFallback(String host) { + int size = FALLBACKS.size(); + int indexCurrent = FALLBACKS.indexOf(host); + return FALLBACKS.get((indexCurrent + 1) % size); + } + + /** + *

+ * Determines whether given rest host is qualified for a retry against a fallback host, or not. + *

+ *

+ * Spec: RSC15b + *

+ * + * @param host + * @return true, if the given host is qualified for a retry against a fallback host. Otherwise, false. + */ + public static boolean isRestFallbackSupported(String host) { + return host.matches(PATTERN_REST_PROD); + } + + /** + *

+ * Determines whether given realtime host is qualified for a retry against a fallback host, or not. + *

+ *

+ * Spec: RTN17b + *

+ * + * @param host + * @return true, if given host is qualified for a retry against a fallback host. Otherwise, false. + */ + public static boolean isRealtimeFallbackSupported(String host) { + return host.matches(PATTERN_REALTIME_PROD); + } +} diff --git a/lib/src/main/java/io/ably/lib/types/AblyException.java b/lib/src/main/java/io/ably/lib/types/AblyException.java index 57f09a0c8..bc3fcf194 100644 --- a/lib/src/main/java/io/ably/lib/types/AblyException.java +++ b/lib/src/main/java/io/ably/lib/types/AblyException.java @@ -2,6 +2,7 @@ import java.net.ConnectException; import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; import java.net.UnknownHostException; /** @@ -21,6 +22,15 @@ public class AblyException extends Exception { } public static AblyException fromErrorInfo(ErrorInfo errorInfo) { + /* If status code is one of server error HTTP response codes */ + if (errorInfo.statusCode >= 500 && + errorInfo.statusCode <= 504) { + return new HostFailedException( + new Exception(errorInfo.message), + errorInfo + ); + } + return new AblyException( new Exception(errorInfo.message), errorInfo); @@ -34,7 +44,7 @@ public static AblyException fromErrorInfo(ErrorInfo errorInfo) { public static AblyException fromThrowable(Throwable t) { if(t instanceof AblyException) return (AblyException)t; - if(t instanceof ConnectException || t instanceof UnknownHostException || t instanceof NoRouteToHostException) + if(t instanceof ConnectException || t instanceof SocketTimeoutException || t instanceof UnknownHostException || t instanceof NoRouteToHostException) return new HostFailedException(t, ErrorInfo.fromThrowable(t)); return new AblyException(t, ErrorInfo.fromThrowable(t)); diff --git a/lib/src/main/java/io/ably/lib/types/ClientOptions.java b/lib/src/main/java/io/ably/lib/types/ClientOptions.java index 5caa48c76..f29fba5bf 100644 --- a/lib/src/main/java/io/ably/lib/types/ClientOptions.java +++ b/lib/src/main/java/io/ably/lib/types/ClientOptions.java @@ -1,6 +1,7 @@ package io.ably.lib.types; import io.ably.lib.rest.Auth.AuthOptions; +import io.ably.lib.transport.Defaults; import io.ably.lib.util.Log; import io.ably.lib.util.Log.LogHandler; @@ -62,13 +63,13 @@ public ClientOptions(String key) throws AblyException { /** * For development environments only; allows a non-default Ably host to be specified. */ - public String restHost; + public String restHost = Defaults.HOST_REST; /** * For development environments only; allows a non-default Ably host to be specified for * websocket connections. */ - public String realtimeHost; + public String realtimeHost = Defaults.HOST_REALTIME; /** * For development environments only; allows a non-default Ably port to be specified. @@ -110,4 +111,20 @@ public ClientOptions(String key) throws AblyException { * Realtime API documentation for further information on connection state recovery. */ public String recover; -} \ No newline at end of file + + /** + * Spec: TO313 + */ + public int httpOpenTimeout = Defaults.TIMEOUT_HTTP_OPEN; + + /** + * Spec: TO314 + */ + public int httpRequestTimeout = Defaults.TIMEOUT_HTTP_REQUEST; + + /** + * Max number of fallback hosts to use as a fallback when an HTTP request to + * the primary host is unreachable or indicates that it is unserviceable + */ + public int httpMaxRetryCount = Defaults.HTTP_MAX_RETRY_COUNT; +} diff --git a/lib/src/test/java/io/ably/lib/http/HttpTest.java b/lib/src/test/java/io/ably/lib/http/HttpTest.java new file mode 100644 index 000000000..b11a71cf6 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/http/HttpTest.java @@ -0,0 +1,637 @@ +package io.ably.lib.http; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.router.RouterNanoHTTPD; +import io.ably.lib.test.util.StatusHandler; +import io.ably.lib.transport.Defaults; +import io.ably.lib.types.AblyException; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ErrorInfo; +import io.ably.lib.types.Param; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; +import org.junit.*; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyBoolean; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Created by gokhanbarisaker on 2/2/16. + */ +public class HttpTest { + + private static final String PATTERN_HOST_FALLBACK = "(?i)[a-e]\\.ably-realtime.com"; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + private static RouterNanoHTTPD server; + + + @BeforeClass + public static void setUp() throws IOException { + server = new RouterNanoHTTPD(27331); + server.addRoute("/status/:code", StatusHandler.class); + server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); + + while (!server.wasStarted()) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + @AfterClass + public static void tearDown() { + server.stop(); + } + + + /******************************************* + * Spec: RSC15 + *******************************************/ + + /** + *

+ * Validates {@code Http} performs fallback behavior httpMaxRetryCount number of times at most, + * when host & fallback hosts are unreachable. Then, finally throws an error. + *

+ *

+ * Spec: RSC15a + *

+ * + * @throws Exception + */ + @Test + public void http_ably_execute_fallback() throws AblyException { + ClientOptions options = new ClientOptions(); + options.tls = false; + /* Select a port that will be refused immediately by the production host */ + options.port = 7777; + + /* Create a list to capture the host of URL arguments that get called with httpExecute method. + * This will later be used to validate hosts used for requests + */ + ArrayList urlHostArgumentStack = new ArrayList<>(4); + + /* + * Extend the http, so that we can capture provided url arguments without mocking and changing its organic behavior. + */ + Http http = new Http(options, null) { + /* Store only string representations to avoid try/catch blocks */ + List urlArgumentStack; + + @Override + T httpExecute(URL url, String method, Param[] headers, RequestBody requestBody, boolean withCredentials, ResponseHandler responseHandler) throws AblyException { + // Store a copy of given argument + urlArgumentStack.add(url.getHost()); + + // Execute the original method without changing behavior + return super.httpExecute(url, method, headers, requestBody, withCredentials, responseHandler); + } + + public Http setUrlArgumentStack(List urlArgumentStack) { + this.urlArgumentStack = urlArgumentStack; + return this; + } + }.setUrlArgumentStack(urlHostArgumentStack); + + http.setHost(Defaults.HOST_REST); + + try { + http.ablyHttpExecute( + "/path/to/fallback", /* Ignore path */ + Http.GET, /* Ignore method */ + new Param[0], /* Ignore headers */ + new Param[0], /* Ignore params */ + null, /* Ignore requestBody */ + null /* Ignore requestHandler */ + ); + } catch (AblyException e) { + /* Verify that, + * - an {@code AblyException} with {@code ErrorInfo} having a `404` status code is thrown. + */ + ErrorInfo expectedErrorInfo = new ErrorInfo("Connection failed; no host available", 404, 80000); + assertThat(e, new ErrorInfoMatcher(expectedErrorInfo)); + } + + /* Verify that, + * - {code Http#httpExecute} have been called with (httpMaxRetryCount + 1) URLs + * - first call executed against production rest host + * - other calls executed against a random fallback host + */ + int expectedCallCount = options.httpMaxRetryCount + 1; + assertThat(urlHostArgumentStack.size(), is(equalTo(expectedCallCount))); + assertThat(urlHostArgumentStack.get(0), is(equalTo(Defaults.HOST_REST))); + + for (int i = 1; i < expectedCallCount; i++) { + urlHostArgumentStack.get(i).matches(PATTERN_HOST_FALLBACK); + } + } + + /** + * This method mocks the API behavior + *

+ * Validates http is not using any fallback host when we receive valid response from http's host + *

+ *

+ * Spec: RSC15a + *

+ * + * @throws Exception + */ + @Test + public void http_execute_nofallback() throws Exception { + Http http = Mockito.spy(new Http(new ClientOptions(), null)); + + String responseExpected = "Lorem Ipsum"; + String hostExpected = Defaults.HOST_REST; + http.setHost(hostExpected); + ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); + + /* Partially mock http */ + doReturn(responseExpected) /* Provide response */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid rest host */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + String responseActual = (String) http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + + + /* Verify + * - http call executed once, + * - with given host, + * - and delivered expected response */ + verify(http, times(1)) + .httpExecute( /* Just validating call counter. Ignore following parameters */ + url.capture(), /* Ignore */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + assertThat(url.getValue().getHost(), is(equalTo(hostExpected))); + assertThat(responseActual, is(equalTo(responseExpected))); + } + + /** + * This method mocks the API behavior + *

+ * Validates http is using a fallback host when HostFailedException thrown + *

+ *

+ * Spec: RSC15a + *

+ * + * @throws Exception + */ + @Test + public void http_execute_singlefallback() throws Exception { + Http http = Mockito.spy(new Http(new ClientOptions(), null)); + + String hostExpectedPattern = PATTERN_HOST_FALLBACK; + String responseExpected = "Lorem Ipsum"; + ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); + http.setHost(Defaults.HOST_REST); + + /* Partially mock http */ + Answer answer = new GrumpyAnswer( + 1, /* Throw exception once (1) */ + AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus("Internal Server Error", 500)), /* That is HostFailedException */ + responseExpected /* Then return a valid response with the second call */ + ); + + doAnswer(answer) /* Behave as defined above */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + /* Call method with real parameters */ + String responseActual = (String) http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + + + /* Verify + * - http call executed twice (one for prod host and 1 for fallback), + * - last call performed against a fallback host, + * - and fallback host delivered expected response */ + + verify(http, times(2)) + .httpExecute( /* Just validating call counter. Ignore following parameters */ + url.capture(), /* Ignore */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + assertThat(url.getValue().getHost().matches(hostExpectedPattern), is(true)); + assertThat(responseActual, is(equalTo(responseExpected))); + } + + /** + * This method mocks the API behavior + *

+ * Validates http is using different hosts when HostFailedException happened multiple times. + *

+ *

+ * Spec: RSC15a + *

+ * + * @throws Exception + */ + @Test + public void http_execute_multiplefallback() throws Exception { + Http http = Mockito.spy(new Http(new ClientOptions(), null)); + + String hostExpectedPattern = PATTERN_HOST_FALLBACK; + String responseExpected = "Lorem Ipsum"; + ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); + http.setHost(Defaults.HOST_REST); + + /* Partially mock http */ + Answer answer = new GrumpyAnswer( + 2, /* Throw exception twice (2) */ + AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus("Internal Server Error", 500)), /* That is HostFailedException */ + responseExpected /* Then return a valid response with third call */ + ); + + doAnswer(answer) /* Behave as defined above */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + String responseActual = (String) http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + + + /* Verify + * - http call executed thrice, + * - with 2 fallback hosts, + * - each host having a unique value, + * - and delivered expected response */ + + assertThat(url.getAllValues().get(1).getHost().matches(hostExpectedPattern), is(true)); + assertThat(url.getAllValues().get(2).getHost().matches(hostExpectedPattern), is(true)); + assertThat(url.getAllValues().get(1), is(not(equalTo(url.getAllValues().get(2))))); + + assertThat(responseActual, is(equalTo(responseExpected))); + + /* Verify call causes captor to capture same arguments twice. + * Do the validation, after we completed the {@code ArgumentCaptor} related assertions */ + verify(http, times(3)) + .httpExecute( /* Just validating call counter. Ignore following parameters */ + url.capture(), /* Ignore */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + } + + /** + * This method mocks the API behavior + *

+ * Validates {@code Http} is using its host (non-fallback-host) first + * when a consecutive call happens + *

+ *

+ * Spec: - + *

+ * + * @throws Exception + */ + @Test + public void http_execute_consecutivecall() throws Exception { + Http http = Mockito.spy(new Http(new ClientOptions(), null)); + + String hostExpected = Defaults.HOST_REST; + http.setHost(hostExpected); + ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); + + /* Partially mock http */ + Answer answer = new GrumpyAnswer( + 1, /* Throw exception once (1) */ + AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus("Internal Server Error", 500)), /* That is HostFailedException */ + "Lorem Ipsum" /* Ignore */ + ); + + doAnswer(answer) /* Behave as defined above */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* capture url arguments passed down httpExecute to assert fallback behavior executed with valid fallback url */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + + /* Verify there was a fallback with first call */ + assertThat(url.getValue().getHost().matches(PATTERN_HOST_FALLBACK), is(true)); + + /* Update behavior to perform a call without a fallback */ + url = ArgumentCaptor.forClass(URL.class); + doReturn("Lorem Ipsum") /* Return some response string that we will ignore */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* capture url arguments passed down httpExecute to assert http call successfully executed against `rest.ably.io` host */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + + /* Verify second call was called with http host */ + assertThat(url.getValue().getHost().equals(hostExpected), is(true)); + } + + /** + * This method mocks the API behavior + *

+ * Validates {@code Http} is throwing an exception, + * when connection to host failed more than allowed count ({@code Defaults.HTTP_MAX_RETRY_COUNT}) + *

+ *

+ * Spec: - + *

+ * + * @throws Exception + */ + @Test + public void http_execute_excessivefallback() throws AblyException { + ClientOptions options = new ClientOptions(); + Http http = Mockito.spy(new Http(options, null)); + + ArgumentCaptor url = ArgumentCaptor.forClass(URL.class); + int excessiveFallbackCount = options.httpMaxRetryCount + 1; + + /* Partially mock http */ + Answer answer = new GrumpyAnswer( + excessiveFallbackCount, /* Throw exception more than httpMaxRetryCount number of times */ + AblyException.fromErrorInfo(ErrorInfo.fromResponseStatus("Internal Server Error", 500)), /* That is HostFailedException */ + "Lorem Ipsum" /* Ignore */ + ); + + doAnswer(answer) /* Behave as defined above */ + .when(http) /* when following method is executed on {@code Http} instance */ + .httpExecute( + url.capture(), /* Ignore */ + anyString(), /* Ignore */ + aryEq(new Param[0]), /* Ignore */ + any(Http.RequestBody.class), /* Ignore */ + anyBoolean(), /* Ignore */ + any(Http.ResponseHandler.class) /* Ignore */ + ); + + + /* Verify + * - ably exception with 404 status code is thrown + */ + ErrorInfo expectedErrorInfo = new ErrorInfo("Connection failed; no host available", 404, 80000); + thrown.expect(AblyException.class); + thrown.expect(new ErrorInfoMatcher(expectedErrorInfo)); + + http.ablyHttpExecute( + "", /* Ignore */ + "", /* Ignore */ + new Param[0], /* Ignore */ + new Param[0], /* Ignore */ + mock(Http.RequestBody.class), /* Ignore */ + mock(Http.ResponseHandler.class) /* Ignore */ + ); + } + + /** + *

+ * Validates {@code Http#httpExecute} is throwing an {@code HostFailedException}, + * when api returns a response code between 500 and 504 + *

+ *

+ * Spec: RSC15d + *

+ * + * @throws Exception + */ + @Test + public void http_execute_response_50x() throws AblyException, MalformedURLException { + URL url; + Http http = new Http(new ClientOptions(), null); + Http.RequestBody requestBody = new Http.ByteArrayRequestBody(new byte[0], NanoHTTPD.MIME_PLAINTEXT); + + AblyException.HostFailedException hfe; + + for (int statusCode = 500; statusCode <= 504; statusCode++) { + url = new URL("http://localhost:" + server.getListeningPort() + "/status/" + statusCode); + hfe = null; + + try { + http.httpExecute(url, Http.GET, new Param[0], requestBody, false, null); + } catch (AblyException.HostFailedException e) { + hfe = e; + } + + Assert.assertNotNull("Status code " + statusCode + " should throw an exception", hfe); + } + } + + /** + *

+ * Validates {@code Http#httpExecute} isn't throwing an {@code HostFailedException}, + * when api returns a non-server-error response code (Informational 1xx, + * Multiple Choices 3xx, Client Error 4xx) + *

+ *

+ * Spec: RSC15d + *

+ * + * @throws Exception + */ + @Test + public void http_execute_response_non5xx() throws AblyException, MalformedURLException { + URL url; + Http http = new Http(new ClientOptions(), null); + Http.RequestBody requestBody = new Http.ByteArrayRequestBody(new byte[0], NanoHTTPD.MIME_PLAINTEXT); + + + /* Informational 1xx */ + + for (int statusCode = 100; statusCode <= 101; statusCode++) { + url = new URL("http://localhost:" + server.getListeningPort() + "/status/" + statusCode); + + try { + http.httpExecute(url, Http.GET, new Param[0], requestBody, false, null); + } catch (AblyException.HostFailedException e) { + Assert.fail("Informal status code " + statusCode + " shouldn't throw an exception"); + } catch (Exception e) { + /* non HostFailedExceptions are ignored */ + } + } + + + /* Informational 3xx */ + + for (int statusCode = 300; statusCode <= 307; statusCode++) { + url = new URL("http://localhost:" + server.getListeningPort() + "/status/" + statusCode); + + try { + http.httpExecute(url, Http.GET, new Param[0], requestBody, false, null); + } catch (AblyException.HostFailedException e) { + Assert.fail("Multiple choices status code " + statusCode + " shouldn't throw an exception"); + } catch (Exception e) { + /* non HostFailedExceptions are ignored */ + } + } + + + /* Informational 4xx */ + + for (int statusCode = 400; statusCode <= 417; statusCode++) { + url = new URL("http://localhost:" + server.getListeningPort() + "/status/" + statusCode); + + try { + http.httpExecute(url, Http.GET, new Param[0], requestBody, false, null); + } catch (AblyException.HostFailedException e) { + Assert.fail("Client error status code " + statusCode + " shouldn't throw an exception"); + } catch (Exception e) { + /* non HostFailedExceptions are ignored */ + } + } + } + + + /********************************************* + * Minions + *********************************************/ + + + static class GrumpyAnswer implements Answer { + private int grumpinessLevel; + private Throwable nope; + private String value; + + /** + * Throws grumpinessLevel number of nope to you and then gives its response properly. + * + * @param grumpinessLevel Quantity of nope that will be thrown into your face, each time. + * @param nope Expected nope + * @param value Expected value that will be returned after grumpiness level goes below or equal to 0. + */ + public GrumpyAnswer(int grumpinessLevel, Throwable nope, String value) { + this.grumpinessLevel = grumpinessLevel; + this.nope = nope; + this.value = value; + } + + @Override + public String answer(InvocationOnMock invocation) throws Throwable { + if (grumpinessLevel-- > 0) { + throw nope; + } + + return value; + } + } + + static class ErrorInfoMatcher extends TypeSafeMatcher { + ErrorInfo errorInfo; + + public ErrorInfoMatcher(ErrorInfo errorInfo) { + super(); + this.errorInfo = errorInfo; + } + + @Override + protected boolean matchesSafely(AblyException item) { + return errorInfo.code == item.errorInfo.code && + errorInfo.statusCode == item.errorInfo.statusCode; + } + + @Override + protected void describeMismatchSafely(AblyException item, Description mismatchDescription) { + mismatchDescription.appendText(item.errorInfo.toString()); + } + + @Override + public void describeTo(Description description) { + description.appendText(errorInfo.toString()); + } + } +} diff --git a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeInitTest.java b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeInitTest.java index 67c16e2de..4438e0772 100644 --- a/lib/src/test/java/io/ably/lib/test/realtime/RealtimeInitTest.java +++ b/lib/src/test/java/io/ably/lib/test/realtime/RealtimeInitTest.java @@ -92,9 +92,10 @@ public void init_host() { try { TestVars testVars = Setup.getTestVars(); ClientOptions opts = new ClientOptions(testVars.keys[0].keyStr); - opts.restHost = "some.other.host"; + String hostExpected = "some.other.host"; + opts.restHost = hostExpected; ably = new AblyRealtime(opts); - assertEquals("Unexpected host mismatch", Defaults.getHost(opts), opts.restHost); + assertEquals("Unexpected host mismatch", hostExpected, ably.http.getHost()); } catch (AblyException e) { e.printStackTrace(); fail("init4: Unexpected exception instantiating library"); diff --git a/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java b/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java index b64c067df..0b4a56377 100644 --- a/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java +++ b/lib/src/test/java/io/ably/lib/test/rest/RestInitTest.java @@ -96,11 +96,13 @@ public void init_key() { @Test public void init_host() { try { + String hostExpected = "some.other.host"; + TestVars testVars = Setup.getTestVars(); ClientOptions opts = new ClientOptions(testVars.keys[0].keyStr); - opts.restHost = "some.other.host"; + opts.restHost = hostExpected; AblyRest ably = new AblyRest(opts); - assertEquals("Unexpected host mismatch", Defaults.getHost(opts), ably.options.restHost); + assertEquals("Unexpected host mismatch", hostExpected, ably.options.restHost); } catch (AblyException e) { e.printStackTrace(); fail("init4: Unexpected exception instantiating library"); diff --git a/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java b/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java new file mode 100644 index 000000000..8c1ae31a3 --- /dev/null +++ b/lib/src/test/java/io/ably/lib/test/util/StatusHandler.java @@ -0,0 +1,52 @@ +package io.ably.lib.test.util; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.router.RouterNanoHTTPD; + +import java.io.InputStream; +import java.util.Map; + +import static fi.iki.elonen.NanoHTTPD.newFixedLengthResponse; + +/** + * Created by gokhanbarisaker on 2/11/16. + */ +public class StatusHandler extends RouterNanoHTTPD.DefaultStreamHandler { + + @Override + public String getMimeType() { + return NanoHTTPD.MIME_PLAINTEXT; + } + + @Override + public NanoHTTPD.Response.IStatus getStatus() { + throw new IllegalStateException("this method should not be called in a status handler"); + } + + @Override + public NanoHTTPD.Response get(RouterNanoHTTPD.UriResource uriResource, Map urlParams, NanoHTTPD.IHTTPSession session) { + String codeParam = urlParams.get("code"); + int code = Integer.parseInt(codeParam); + + return newFixedLengthResponse(newStatus(code, ""), getMimeType(), ""); + } + + @Override + public InputStream getData() { + throw new IllegalStateException("this method should not be called in a status handler"); + } + + private static NanoHTTPD.Response.IStatus newStatus(final int status, final String description) { + return new NanoHTTPD.Response.IStatus() { + @Override + public String getDescription() { + return "" + status + " " + description; + } + + @Override + public int getRequestStatus() { + return status; + } + }; + } +} diff --git a/lib/src/test/java/io/ably/lib/transport/HostsTest.java b/lib/src/test/java/io/ably/lib/transport/HostsTest.java new file mode 100644 index 000000000..fb959fbec --- /dev/null +++ b/lib/src/test/java/io/ably/lib/transport/HostsTest.java @@ -0,0 +1,31 @@ +package io.ably.lib.transport; + +import org.hamcrest.Matchers; +import org.junit.Test; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +/** + * Created by gokhanbarisaker on 2/2/16. + */ +public class HostsTest { + + /** + * Expect given fallback host string is (relatively) valid + */ + @Test + public void hosts_fallback_single() { + String host = Hosts.getFallback(null); + assertThat(host, is(not(isEmptyOrNullString()))); + } + + /** + * Expect multiple calls will provide different (relatively) valid fallback hosts + */ + @Test + public void hosts_fallback_multiple() { + String host = Hosts.getFallback(null); + assertThat(Hosts.getFallback(host), is(not(allOf(isEmptyOrNullString(), equalTo(host))))); + } +} \ No newline at end of file