diff --git a/sdk/pom.xml b/sdk/pom.xml index b146fe72f42..b1231173d11 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -372,6 +372,11 @@ ${junit-version} test + + com.squareup.okhttp3 + mockwebserver + ${okhttp-mockwebserver-version} + org.hamcrest hamcrest-library @@ -395,6 +400,7 @@ 1.0.0 2.11.1 4.9.1 + 4.11.0 2.10.0 2.8.5 1.8.3 diff --git a/sdk/src/main/java/com/finbourne/lusid/utilities/ApiClientBuilder.java b/sdk/src/main/java/com/finbourne/lusid/utilities/ApiClientBuilder.java index 6cd094e56e8..fdae1138d7d 100644 --- a/sdk/src/main/java/com/finbourne/lusid/utilities/ApiClientBuilder.java +++ b/sdk/src/main/java/com/finbourne/lusid/utilities/ApiClientBuilder.java @@ -2,9 +2,9 @@ import com.finbourne.lusid.ApiClient; import com.finbourne.lusid.utilities.auth.HttpLusidTokenProvider; +import com.finbourne.lusid.utilities.auth.RefreshingTokenProvider; import com.finbourne.lusid.utilities.auth.LusidToken; import com.finbourne.lusid.utilities.auth.LusidTokenException; -import com.finbourne.lusid.utilities.auth.RefreshingTokenProvider; import okhttp3.OkHttpClient; /** @@ -15,73 +15,88 @@ public class ApiClientBuilder { private static final int DEFAULT_TIMEOUT_SECONDS = 10; /** - * Builds an ApiClient implementation configured against a secrets file. Typically used - * for communicating with LUSID via the APIs (e.g. {@link com.finbourne.lusid.api.TransactionPortfoliosApi}, {@link com.finbourne.lusid.api.QuotesApi}. + * Builds an ApiClient implementation configured against a secrets file. + * Typically used + * for communicating with LUSID via the APIs (e.g. + * {@link com.finbourne.lusid.api.TransactionPortfoliosApi}, + * {@link com.finbourne.lusid.api.QuotesApi}. * - * ApiClient implementation enables use of REFRESH tokens (see https://support.finbourne.com/using-a-refresh-token) + * ApiClient implementation enables use of REFRESH tokens (see + * https://support.finbourne.com/using-a-refresh-token) * and automatically handles token refreshing on expiry. * * @param apiConfiguration configuration to connect to LUSID API * @return * - * @throws LusidTokenException on failing to authenticate and retrieve an initial {@link LusidToken} + * @throws LusidTokenException on failing to authenticate and retrieve an + * initial {@link LusidToken} */ public ApiClient build(ApiConfiguration apiConfiguration) throws LusidTokenException { - return this.build(apiConfiguration, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS); + return this.build(apiConfiguration, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS, + 3); } /** - * Builds an ApiClient implementation configured against a secrets file. Typically used + * Builds an ApiClient implementation configured against a secrets file. + * Typically used * for communicating with LUSID via the APIs * - * ApiClient implementation enables use of REFRESH tokens (see https://support.finbourne.com/using-a-refresh-token) + * ApiClient implementation enables use of REFRESH tokens (see + * https://support.finbourne.com/using-a-refresh-token) * and automatically handles token refreshing on expiry. * * @param apiConfiguration configuration to connect to {{application_camel}} API - * @param readTimeout read timeout in seconds - * @param writeTimeout write timeout in seconds + * @param readTimeout read timeout in seconds + * @param writeTimeout write timeout in seconds * @return * - * @throws LusidTokenException on failing to authenticate and retrieve an initial {@link LusidTokenException} + * @throws LusidTokenException on failing to authenticate and retrieve an + * initial {@link LusidTokenException} */ - public ApiClient build(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout) throws LusidTokenException { - return this.build(apiConfiguration, readTimeout, writeTimeout, DEFAULT_TIMEOUT_SECONDS); + public ApiClient build(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout) + throws LusidTokenException { + return build(apiConfiguration, readTimeout, writeTimeout, DEFAULT_TIMEOUT_SECONDS, 3); } /** - * Builds an ApiClient implementation configured against a secrets file. Typically used + * Builds an ApiClient implementation configured against a secrets file. + * Typically used * for communicating with LUSID via the APIs * - * ApiClient implementation enables use of REFRESH tokens (see https://support.finbourne.com/using-a-refresh-token) + * ApiClient implementation enables use of REFRESH tokens (see + * https://support.finbourne.com/using-a-refresh-token) * and automatically handles token refreshing on expiry. * * @param apiConfiguration configuration to connect to {{application_camel}} API - * @param readTimeout read timeout in seconds - * @param writeTimeout write timeout in seconds - * @param connectTimeout connection timeout in seconds + * @param readTimeout read timeout in seconds + * @param writeTimeout write timeout in seconds + * @param connectTimeout connection timeout in seconds * @return * - * @throws LusidTokenException on failing to authenticate and retrieve an initial {@link LusidTokenException} + * @throws LusidTokenException on failing to authenticate and retrieve an + * initial {@link LusidTokenException} */ - public ApiClient build(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout, int connectTimeout) throws LusidTokenException { - + public ApiClient build(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout, int connectTimeout, + int retryMaxAttempts) + throws LusidTokenException { // http client to use for api and auth calls OkHttpClient httpClient = createHttpClient(apiConfiguration, readTimeout, writeTimeout, connectTimeout); if (apiConfiguration.getPersonalAccessToken() != null && apiConfiguration.getApiUrl() != null) { - // use Personal Access Token + // use Personal Access Token LusidToken lusidToken = new LusidToken(apiConfiguration.getPersonalAccessToken(), null, null); - ApiClient defaultApiClient = createDefaultApiClient(apiConfiguration, httpClient, lusidToken); + ApiClient defaultApiClient = createDefaultApiClient(apiConfiguration, httpClient, lusidToken, + retryMaxAttempts); return defaultApiClient; - } - else { + } else { // token provider to keep client authenticated with automated token refreshing - RefreshingTokenProvider refreshingTokenProvider = new RefreshingTokenProvider(new HttpLusidTokenProvider(apiConfiguration, httpClient)); + RefreshingTokenProvider refreshingTokenProvider = new RefreshingTokenProvider( + new HttpLusidTokenProvider(apiConfiguration, httpClient)); LusidToken lusidToken = refreshingTokenProvider.get(); // setup api client that managed submissions with the latest token @@ -90,8 +105,14 @@ public ApiClient build(ApiConfiguration apiConfiguration, int readTimeout, int w } } - ApiClient createDefaultApiClient(ApiConfiguration apiConfiguration, OkHttpClient httpClient, LusidToken lusidToken) throws LusidTokenException { - ApiClient apiClient = createApiClient(); + ApiClient createDefaultApiClient(ApiConfiguration apiConfiguration, OkHttpClient httpClient, + LusidToken lusidToken) throws LusidTokenException { + return createDefaultApiClient(apiConfiguration, httpClient, lusidToken, 3); + } + + ApiClient createDefaultApiClient(ApiConfiguration apiConfiguration, OkHttpClient httpClient, + LusidToken lusidToken, int retryMaxAttempts) throws LusidTokenException { + ApiClient apiClient = createApiClient(retryMaxAttempts); apiClient.setHttpClient(httpClient); @@ -107,15 +128,20 @@ ApiClient createDefaultApiClient(ApiConfiguration apiConfiguration, OkHttpClient } apiClient.setBasePath(apiConfiguration.getApiUrl()); - return apiClient; + return apiClient; } - private OkHttpClient createHttpClient(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout, int connectTimeout){ + private OkHttpClient createHttpClient(ApiConfiguration apiConfiguration, int readTimeout, int writeTimeout, + int connectTimeout) { return new HttpClientFactory().build(apiConfiguration, readTimeout, writeTimeout, connectTimeout); } // allows us to mock out api client for testing purposes - ApiClient createApiClient(){ - return new ApiClient(); + ApiClient createApiClient() { + return new RetryingApiClient(3); + } + + ApiClient createApiClient(int maxAttempts) { + return new RetryingApiClient(maxAttempts); } } diff --git a/sdk/src/main/java/com/finbourne/lusid/utilities/LusidApiFactoryBuilder.java b/sdk/src/main/java/com/finbourne/lusid/utilities/LusidApiFactoryBuilder.java index 751c5b1cc1b..654bafc91de 100644 --- a/sdk/src/main/java/com/finbourne/lusid/utilities/LusidApiFactoryBuilder.java +++ b/sdk/src/main/java/com/finbourne/lusid/utilities/LusidApiFactoryBuilder.java @@ -8,40 +8,50 @@ public class LusidApiFactoryBuilder { private static final int DEFAULT_TIMEOUT_SECONDS = 10; /** - * Build a {@link LusidApiFactory} defining configuration using environment variables. For details on the environment arguments see https://support.lusid.com/getting-started-with-apis-sdks. + * Build a {@link LusidApiFactory} defining configuration using environment + * variables. For details on the environment arguments see + * https://support.lusid.com/getting-started-with-apis-sdks. * * @return */ public static LusidApiFactory build() throws ApiConfigurationException, LusidTokenException { if (!areRequiredEnvironmentVariablesSet()) { - throw new ApiConfigurationException("Environment variables to configure LUSID API client have not been set. See " + - " see https://support.lusid.com/getting-started-with-apis-sdks for details."); + throw new ApiConfigurationException( + "Environment variables to configure LUSID API client have not been set. See " + + " see https://support.lusid.com/getting-started-with-apis-sdks for details."); } return createApiFactory(null, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS); } /** - * Build a {@link LusidApiFactory} using the specified configuration file. For details on the format of the configuration file see https://support.lusid.com/getting-started-with-apis-sdks. + * Build a {@link LusidApiFactory} using the specified configuration file. For + * details on the format of the configuration file see + * https://support.lusid.com/getting-started-with-apis-sdks. */ - public static LusidApiFactory build(String configurationFile) throws ApiConfigurationException, LusidTokenException { + public static LusidApiFactory build(String configurationFile) + throws ApiConfigurationException, LusidTokenException { return build(configurationFile, 10, 10); } - public static LusidApiFactory build(String configurationFile, int readTimeout, int writeTimeout) throws ApiConfigurationException, LusidTokenException { + public static LusidApiFactory build(String configurationFile, int readTimeout, int writeTimeout) + throws ApiConfigurationException, LusidTokenException { return createApiFactory(configurationFile, readTimeout, writeTimeout, DEFAULT_TIMEOUT_SECONDS); } - public static LusidApiFactory build(String configurationFile, int readTimeout, int writeTimeout, int connectTimeout) throws ApiConfigurationException, LusidTokenException { + public static LusidApiFactory build(String configurationFile, int readTimeout, int writeTimeout, int connectTimeout) + throws ApiConfigurationException, LusidTokenException { return createApiFactory(configurationFile, readTimeout, writeTimeout, connectTimeout); } - private static LusidApiFactory createApiFactory(String configurationFile, int readTimeout, int writeTimeout, int connectTimeout) throws ApiConfigurationException, LusidTokenException { + private static LusidApiFactory createApiFactory(String configurationFile, int readTimeout, int writeTimeout, + int connectTimeout) throws ApiConfigurationException, LusidTokenException { ApiConfiguration apiConfiguration = new ApiConfigurationBuilder().build(configurationFile); - ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration, readTimeout, writeTimeout, connectTimeout); - return new LusidApiFactory(apiClient); + ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration, readTimeout, writeTimeout, connectTimeout, + 3); + return new LusidApiFactory(apiClient); } - private static boolean areRequiredEnvironmentVariablesSet(){ + private static boolean areRequiredEnvironmentVariablesSet() { return ((System.getenv("FBN_TOKEN_URL") != null && System.getenv("FBN_USERNAME") != null && System.getenv("FBN_PASSWORD") != null && @@ -49,6 +59,6 @@ private static boolean areRequiredEnvironmentVariablesSet(){ System.getenv("FBN_CLIENT_SECRET") != null && System.getenv("FBN_LUSID_API_URL") != null) || (System.getenv("FBN_TOKEN_URL") != null && - System.getenv("FBN_ACCESS_TOKEN") != null)); + System.getenv("FBN_ACCESS_TOKEN") != null)); } } diff --git a/sdk/src/main/java/com/finbourne/lusid/utilities/RetryingApiClient.java b/sdk/src/main/java/com/finbourne/lusid/utilities/RetryingApiClient.java new file mode 100644 index 00000000000..0e7c356e861 --- /dev/null +++ b/sdk/src/main/java/com/finbourne/lusid/utilities/RetryingApiClient.java @@ -0,0 +1,152 @@ +package com.finbourne.lusid.utilities; + +import java.lang.reflect.Type; + +import java.util.List; +import java.util.Map; + +import com.finbourne.lusid.ApiCallback; +import com.finbourne.lusid.ApiClient; +import com.finbourne.lusid.ApiException; +import com.finbourne.lusid.ApiResponse; + +import okhttp3.Call; + +public class RetryingApiClient extends ApiClient { + private int maxAttempts; + + private void setMaxAttempts(int maxAttempts) throws IllegalArgumentException { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be greater than 0"); + } + this.maxAttempts = maxAttempts; + + } + + /* + * Constructor for RetringApiClient + * specifying max number of attempts to retry on a 429 response + */ + public RetryingApiClient(int maxAttempts) throws IllegalArgumentException { + super(); + setMaxAttempts(maxAttempts); + } + + /** + * Execute HTTP call and deserialize the HTTP response body into the given + * return type. + * + * @param returnType The return type used to deserialize HTTP response body + * @param The return type corresponding to (same with) returnType + * @param call Call + * @return ApiResponse object containing response status, headers and + * data, which is a Java object deserialized from response body and + * would be null + * when returnType is null. + * @throws ApiException If fail to execute the call + */ + public ApiResponse execute(Call call, Type returnType) throws ApiException { + for (int attempt = 1; attempt < maxAttempts; attempt++) { + try { + return super.execute(call, returnType); + } catch (ApiException e) { + if (e.getCode() != 429 || maxAttempts == attempt) { + throw e; + } else { + call = call.clone(); + Map> responseHeaders = e.getResponseHeaders(); + try { + String retryAfterHeader = responseHeaders.get("retry-after").get(0); + long retryAfter = Long.parseLong(retryAfterHeader); + Thread.sleep(retryAfter); + } catch (InterruptedException exc) { + throw new ApiException("Failed to retry, thread interrupted", e, 429, responseHeaders, + e.getResponseBody()); + + } catch (NullPointerException exc) { + throw new ApiException("Retry-After header unavailable", e, + 429, responseHeaders, + e.getResponseBody()); + } catch (NumberFormatException exc) { + throw new ApiException("Failed to parse Retry-After header", e, + 429, responseHeaders, + e.getResponseBody()); + } + } + } + } + return super.execute(call, returnType); + } + + /* + * Wrap original callback in callback that retries on 429 + */ + private ApiCallback buildRetryingCallback(Call call, final Type returnType, final ApiCallback callback, + int attempt) { + + ApiCallback retryingCallback = new ApiCallback() { + @Override + public void onFailure(ApiException e, int statusCode, Map> responseHeaders) { + if (statusCode != 429 || attempt >= maxAttempts) { + callback.onFailure(e, statusCode, responseHeaders); + } else { + String retryAfterHeader = responseHeaders.get("retry-after").get(0); + long retryAfter = Long.parseLong(retryAfterHeader); + try { + Thread.sleep(retryAfter); + } catch (InterruptedException exc) { + callback.onFailure( + new ApiException("Failed to retry, thread interrupted", e, 429, responseHeaders, + e.getResponseBody()), + statusCode, responseHeaders); + } catch (NullPointerException exc) { + callback.onFailure( + new ApiException("Retry-After header unavailable", e, + 429, responseHeaders, + e.getResponseBody()), + statusCode, responseHeaders); + } catch (NumberFormatException exc) { + callback.onFailure( + new ApiException("Failed to parse Retry-After header", e, + 429, responseHeaders, + e.getResponseBody()), + statusCode, responseHeaders); + } + // Retry with new callback - maintain count of attempts + RetryingApiClient.super.executeAsync(call.clone(), returnType, + buildRetryingCallback(call, returnType, callback, attempt + 1)); + } + } + + @Override + public void onSuccess(T result, int statusCode, Map> responseHeaders) { + callback.onSuccess(result, statusCode, responseHeaders); + } + + @Override + public void onUploadProgress(long bytesWritten, long contentLength, boolean done) { + callback.onUploadProgress(bytesWritten, contentLength, done); + } + + @Override + public void onDownloadProgress(long bytesRead, long contentLength, boolean done) { + callback.onDownloadProgress(bytesRead, contentLength, done); + } + }; + return retryingCallback; + } + + /** + * Execute HTTP call asynchronously. Retry on 429s + * + * @param Type + * @param call The callback to be executed when the API call finishes + * @param returnType Return type + * @param callback ApiCallback + * @see #execute(Call, Type) + */ + public void executeAsync(Call call, final Type returnType, final ApiCallback callback) { + super.executeAsync(call, returnType, buildRetryingCallback(call, returnType, callback, 1)); + } + +} diff --git a/sdk/src/test/integration/java/com/finbourne/lusid/utilities/ApiClientBuilderTests.java b/sdk/src/test/integration/java/com/finbourne/lusid/utilities/ApiClientBuilderTests.java index 25ac3c29c2b..552ecff289c 100644 --- a/sdk/src/test/integration/java/com/finbourne/lusid/utilities/ApiClientBuilderTests.java +++ b/sdk/src/test/integration/java/com/finbourne/lusid/utilities/ApiClientBuilderTests.java @@ -26,27 +26,31 @@ public class ApiClientBuilderTests { public ExpectedException thrown = ExpectedException.none(); @Before - public void setUp(){ + public void setUp() { apiClientBuilder = new ApiClientBuilder(); } @Test - public void build_OnValidConfigurationFile_ShouldBuildKeepLiveApiClient() throws ApiConfigurationException, LusidTokenException { - // This test assumes default secrets file is valid without a PAT. Same assertion as all other integration tests. + public void build_OnValidConfigurationFile_ShouldBuildKeepLiveApiClient() + throws ApiConfigurationException, LusidTokenException { + // This test assumes default secrets file is valid without a PAT. Same assertion + // as all other integration tests. ApiConfiguration apiConfiguration = new ApiConfigurationBuilder().build(CredentialsSource.credentialsFile); ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration); - // running with no exceptions ensures client built correctly with no configuration or token creation exceptions - assertThat("Unexpected extended implementation of ApiClient for default build." , + // running with no exceptions ensures client built correctly with no + // configuration or token creation exceptions + assertThat("Unexpected extended implementation of ApiClient for default build.", apiClient, instanceOf(RefreshingTokenApiClient.class)); } @Test - public void build_WithValidPAT_ShouldBuildKeepLiveApiClient() throws ApiConfigurationException, LusidTokenException { + public void build_WithValidPAT_ShouldBuildKeepLiveApiClient() + throws ApiConfigurationException, LusidTokenException { ApiConfiguration apiConfiguration = new ApiConfigurationBuilder().build("secrets-pat.json"); ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration); - OAuth auth = (OAuth)apiClient.getAuthentication("oauth2"); + OAuth auth = (OAuth) apiClient.getAuthentication("oauth2"); assertThat(auth.getAccessToken(), equalTo(apiConfiguration.getPersonalAccessToken())); } @@ -58,7 +62,7 @@ public void build_BadTokenConfigurationFile_ShouldThrowException() throws LusidT ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration); } - private ApiConfiguration getBadTokenConfiguration(){ + private ApiConfiguration getBadTokenConfiguration() { return new ApiConfiguration( "https://some-non-existing-test-instance.doesnotexist.com/oauth2/doesnotexist/v1/token", "test.testing@finbourne.com", @@ -68,8 +72,7 @@ private ApiConfiguration getBadTokenConfiguration(){ "https://some-non-existing-test-instance.lusid.com/api", "non-existent", // proxy strs - "",8888,"","" - ); + "", 8888, "", ""); } @Test @@ -78,7 +81,8 @@ public void call_Api_With_Timeout() throws Exception { int timeoutInSeconds = 20; int defaultTimeout = 10; ApiConfiguration apiConfiguration = new ApiConfigurationBuilder().build(CredentialsSource.credentialsFile); - ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration, timeoutInSeconds, timeoutInSeconds, timeoutInSeconds); + ApiClient apiClient = new ApiClientBuilder().build(apiConfiguration, timeoutInSeconds, timeoutInSeconds, + timeoutInSeconds, 3); ScopesApi scopesApi = new ScopesApi(apiClient); Instant start = Instant.now(); @@ -87,13 +91,12 @@ public void call_Api_With_Timeout() throws Exception { start = Instant.now(); ResourceListOfScopeDefinition scopes = scopesApi.listScopes(null); - // successful call within timeout - } - catch (Exception ex) { + // successful call within timeout + } catch (Exception ex) { Instant finish = Instant.now(); long elapsed = Duration.between(start, finish).toMillis() / 1000; - assertThat(elapsed, greaterThanOrEqualTo((long)defaultTimeout)); + assertThat(elapsed, greaterThanOrEqualTo((long) defaultTimeout)); } } diff --git a/sdk/src/test/unit/java/com/finbourne/lusid/utilities/ApiClientBuilderTest.java b/sdk/src/test/unit/java/com/finbourne/lusid/utilities/ApiClientBuilderTest.java index 2a5f3abe7d5..0566f87f52a 100644 --- a/sdk/src/test/unit/java/com/finbourne/lusid/utilities/ApiClientBuilderTest.java +++ b/sdk/src/test/unit/java/com/finbourne/lusid/utilities/ApiClientBuilderTest.java @@ -15,7 +15,7 @@ public class ApiClientBuilderTest { private ApiClientBuilder apiClientBuilder; - //mock dependencies + // mock dependencies private ApiClient apiClient; private OkHttpClient httpClient; private ApiConfiguration apiConfiguration; @@ -26,7 +26,7 @@ public class ApiClientBuilderTest { public ExpectedException thrown = ExpectedException.none(); @Before - public void setUp(){ + public void setUp() { httpClient = mock(OkHttpClient.class); apiConfiguration = mock(ApiConfiguration.class); lusidToken = mock(LusidToken.class); @@ -34,7 +34,7 @@ public void setUp(){ apiClientBuilder = spy(new ApiClientBuilder()); // mock creation of default api client - doReturn(apiClient).when(apiClientBuilder).createApiClient(); + doReturn(apiClient).when(apiClientBuilder).createApiClient(any(Integer.class)); // mock default well formed lusid token doReturn("access_token_01").when(lusidToken).getAccessToken(); } @@ -50,7 +50,7 @@ public void createApiClient_OnProxyAddress_ShouldSetHttpClient() throws LusidTok public void createApiClient_OnNoApplicationName_ShouldNotSetApplicationHeader() throws LusidTokenException { doReturn(null).when(apiConfiguration).getApplicationName(); apiClientBuilder.createDefaultApiClient(apiConfiguration, httpClient, lusidToken); - verify(apiClient,times(0)).addDefaultHeader(eq("X-LUSID-Application"), any()); + verify(apiClient, times(0)).addDefaultHeader(eq("X-LUSID-Application"), any()); } @Test diff --git a/sdk/src/test/unit/java/com/finbourne/lusid/utilities/RetryingApiClientTest.java b/sdk/src/test/unit/java/com/finbourne/lusid/utilities/RetryingApiClientTest.java new file mode 100644 index 00000000000..c5dab7215ee --- /dev/null +++ b/sdk/src/test/unit/java/com/finbourne/lusid/utilities/RetryingApiClientTest.java @@ -0,0 +1,194 @@ +package com.finbourne.lusid.utilities; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import java.io.IOException; + +import org.junit.Test; + +import com.finbourne.lusid.ApiCallback; +import com.finbourne.lusid.ApiClient; +import com.finbourne.lusid.ApiException; +import com.finbourne.lusid.ApiResponse; + +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +public class RetryingApiClientTest { + @Test + public void constructApiClientWithLessThanOneAttemptsThrowsException() { + assertThrows(IllegalArgumentException.class, () -> new RetryingApiClient(0)); + } + + @Test + public void executeAttemptsNmkinus1TimesThenReturnsResult() throws IOException, ApiException { + final int MAX_ATTEMPTS = 2; + + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", 10); + MockResponse mockSuccessfulResponse = new MockResponse() + .setResponseCode(200); + + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockSuccessfulResponse); + server.start(); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + ApiResponse response = retryingApiClient.execute(call, null); + assertEquals(200, response.getStatusCode()); + server.close(); + } + + @Test + public void executeResponseHasNoRetryHeaderThrowsApiException() throws IOException, ApiException { + final int MAX_ATTEMPTS = 2; + + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429); + + server.enqueue(mockTooManyRequestsResponse); + server.start(); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + ApiException expectedException = new ApiException("Retry-After header unavailable", 429, + mockTooManyRequestsResponse.getHeaders().toMultimap(), ""); + ApiException exception = assertThrows(ApiException.class, () -> retryingApiClient.execute(call, null)); + assertEquals(expectedException.getMessage(), exception.getMessage()); + server.close(); + } + + @Test + public void executeResponseWithMalformedRetryHeaderThrowsApiException() throws IOException, ApiException { + final int MAX_ATTEMPTS = 2; + + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", "25s"); + + server.enqueue(mockTooManyRequestsResponse); + server.start(); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + ApiException expectedException = new ApiException("Failed to parse Retry-After header", 429, + mockTooManyRequestsResponse.getHeaders().toMultimap(), ""); + ApiException exception = assertThrows(ApiException.class, () -> retryingApiClient.execute(call, null)); + assertEquals(expectedException.getMessage(), exception.getMessage()); + + server.close(); + } + + @Test + public void executeAttemptsNTimesThenThrowsAPIException() throws IOException, ApiException { + final int MAX_ATTEMPTS = 2; + + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", 10); + MockResponse mockSuccessfulResponse = new MockResponse() + .setResponseCode(200); + + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockSuccessfulResponse); + server.start(); + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + assertThrows(ApiException.class, () -> retryingApiClient.execute(call, null)); + server.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void executeAsyncAttemptsNmkinus1TimesThenCallsOnSuccess() throws IOException, ApiException { + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", 10); + MockResponse mockSuccessfulResponse = new MockResponse() + .setResponseCode(200); + + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockSuccessfulResponse); + server.start(); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + + ApiCallback apiCallBackSpy = mock(ApiCallback.class); + + final int MAX_ATTEMPTS = 2; + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + retryingApiClient.executeAsync(call, null, apiCallBackSpy); + verify(apiCallBackSpy, timeout(100)).onSuccess(any(), eq(200), any()); + server.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void executeAsyncAttemptsNTimesThenCallsOnFailure() throws IOException, ApiException { + MockWebServer server = new MockWebServer(); + MockResponse mockTooManyRequestsResponse = new MockResponse() + .setResponseCode(429) + .addHeader("Retry-After", 10); + MockResponse mockSuccessfulResponse = new MockResponse() + .setResponseCode(200); + + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockTooManyRequestsResponse); + server.enqueue(mockSuccessfulResponse); + server.start(); + + OkHttpClient client = new OkHttpClient(); + Request request = new Request.Builder() + .url(server.url("")) + .build(); + Call call = client.newCall(request); + + ApiCallback apiCallBackSpy = mock(ApiCallback.class); + + final int MAX_ATTEMPTS = 2; + ApiClient retryingApiClient = new RetryingApiClient(MAX_ATTEMPTS); + retryingApiClient.executeAsync(call, null, apiCallBackSpy); + verify(apiCallBackSpy, timeout(100)).onFailure(any(), eq(429), any()); + verify(apiCallBackSpy, never()).onSuccess(any(), eq(200), any()); + server.close(); + } + +}