package org.kohsuke.github; import com.github.tomakehurst.wiremock.http.Fault; import com.github.tomakehurst.wiremock.stubbing.Scenario; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; import org.kohsuke.github.extras.ImpatientHttpConnector; import org.kohsuke.github.extras.okhttp3.OkHttpConnector; import org.mockito.Mockito; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.ProtocolException; import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.URL; import java.security.Permission; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import java.util.logging.SimpleFormatter; import java.util.logging.StreamHandler; import javax.net.ssl.SSLHandshakeException; import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.hamcrest.Matchers.*; /** * @author Victor Martinez */ public class RequesterRetryTest extends AbstractGitHubWireMockTest { private static Logger log = Logger.getLogger(GitHubClient.class.getName()); // matches the logger in the affected // class private static OutputStream logCapturingStream; private static StreamHandler customLogHandler; HttpURLConnection connection; int baseRequestCount; public RequesterRetryTest() { useDefaultGitHub = false; } protected GHRepository getRepository() throws IOException { return getRepository(gitHub); } private GHRepository getRepository(GitHub gitHub) throws IOException { return gitHub.getOrganization("hub4j-test-org").getRepository("github-api"); } @Before public void attachLogCapturer() { logCapturingStream = new ByteArrayOutputStream(); customLogHandler = new StreamHandler(logCapturingStream, new SimpleFormatter()); Logger.getLogger(GitHubClient.class.getName()).addHandler(customLogHandler); Logger.getLogger(OkHttpClient.class.getName()).addHandler(customLogHandler); } public String getTestCapturedLog() throws IOException { customLogHandler.flush(); return logCapturingStream.toString(); } public void resetTestCapturedLog() throws IOException { Logger.getLogger(GitHubClient.class.getName()).removeHandler(customLogHandler); Logger.getLogger(OkHttpClient.class.getName()).removeHandler(customLogHandler); customLogHandler.close(); attachLogCapturer(); } @Ignore("Used okhttp3 and this to verify connection closing. To variable for CI system.") @Test public void testGitHubIsApiUrlValid() throws Exception { OkHttpClient client = new OkHttpClient().newBuilder() .connectionPool(new ConnectionPool(2, 100, TimeUnit.MILLISECONDS)) .build(); OkHttpConnector connector = new OkHttpConnector(client); for (int x = 0; x < 100; x++) { this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); try { gitHub.checkApiUrlValidity(); } catch (IOException ioe) { assertTrue(ioe.getMessage().contains("private mode enabled")); } Thread.sleep(100); } String capturedLog = getTestCapturedLog(); assertThat(capturedLog, not(containsString("leaked"))); } // Issue #539 @Test public void testSocketConnectionAndRetry() throws Exception { // CONNECTION_RESET_BY_PEER errors result in two requests each // to get this failure for "3" tries we have to do 6 queries. this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")) .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build(); GHRepository repo = getRepository(); baseRequestCount = this.mockGitHub.getRequestCount(); try { repo.getBranch("test/timeout"); fail(); } catch (Exception e) { assertThat(e, instanceOf(HttpException.class)); } String capturedLog = getTestCapturedLog(); assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 6)); } // Issue #539 @Test public void testSocketConnectionAndRetry_StatusCode() throws Exception { // CONNECTION_RESET_BY_PEER errors result in two requests each // to get this failure for "3" tries we have to do 6 queries. this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")) .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build(); baseRequestCount = this.mockGitHub.getRequestCount(); try { // status code is a different code path that should also be covered by this. gitHub.createRequest() .withUrlPath("/repos/hub4j-test-org/github-api/branches/test/timeout") .fetchHttpStatusCode(); fail(); } catch (Exception e) { assertThat(e, instanceOf(HttpException.class)); } String capturedLog = getTestCapturedLog(); assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 6)); } @Test public void testSocketConnectionAndRetry_Success() throws Exception { // CONNECTION_RESET_BY_PEER errors result in two requests each // to get this failure for "3" tries we have to do 6 queries. // If there are only 5 errors we succeed. this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")).atPriority(0) .inScenario("Retry") .whenScenarioStateIs(Scenario.STARTED) .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))) .setNewScenarioState("Retry-1"); this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")).atPriority(0) .inScenario("Retry") .whenScenarioStateIs("Retry-1") .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))) .setNewScenarioState("Retry-2"); this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")).atPriority(0) .inScenario("Retry") .whenScenarioStateIs("Retry-2") .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))) .setNewScenarioState("Retry-3"); this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")).atPriority(0) .inScenario("Retry") .whenScenarioStateIs("Retry-3") .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))) .setNewScenarioState("Retry-4"); this.mockGitHub.apiServer() .stubFor(get(urlMatching(".+/branches/test/timeout")).atPriority(0) .atPriority(0) .inScenario("Retry") .whenScenarioStateIs("Retry-4") .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))) .setNewScenarioState("Retry-5"); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build(); GHRepository repo = getRepository(); baseRequestCount = this.mockGitHub.getRequestCount(); GHBranch branch = repo.getBranch("test/timeout"); assertThat(branch, notNullValue()); String capturedLog = getTestCapturedLog(); assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 6)); } @Test public void testResponseCodeFailureExceptions() throws Exception { // No retry for these Exceptions HttpConnector connector = new ResponseCodeThrowingHttpConnector<>(() -> { throw new IOException("Custom"); }); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); try { this.gitHub.getOrganization(GITHUB_API_TEST_ORG); fail(); } catch (Exception e) { assertThat(e, instanceOf(HttpException.class)); assertThat(e.getCause(), instanceOf(IOException.class)); assertThat(e.getCause().getMessage(), is("Custom")); String capturedLog = getTestCapturedLog(); assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount)); } connector = new ResponseCodeThrowingHttpConnector<>(() -> { throw new FileNotFoundException("Custom"); }); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); try { this.gitHub.getOrganization(GITHUB_API_TEST_ORG); fail(); } catch (Exception e) { assertThat(e, instanceOf(FileNotFoundException.class)); assertThat(e.getMessage(), is("Custom")); String capturedLog = getTestCapturedLog(); assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount)); } } @Test public void testInputStreamFailureExceptions() throws Exception { // No retry for these Exceptions HttpConnector connector = new InputStreamThrowingHttpConnector<>(() -> { throw new IOException("Custom"); }); this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); try { this.gitHub.getOrganization(GITHUB_API_TEST_ORG); fail(); } catch (Exception e) { assertThat(e, instanceOf(HttpException.class)); assertThat(e.getCause(), instanceOf(IOException.class)); assertThat(e.getCause().getMessage(), is("Custom")); String capturedLog = getTestCapturedLog(); assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 1)); } // FileNotFound doesn't need a special connector this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); try { this.gitHub.getOrganization(GITHUB_API_TEST_ORG + "-missing"); fail(); } catch (Exception e) { assertThat(e, instanceOf(GHFileNotFoundException.class)); assertThat(e.getCause(), instanceOf(FileNotFoundException.class)); assertThat(e.getCause().getMessage(), containsString("hub4j-test-org-missing")); String capturedLog = getTestCapturedLog(); assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 1)); } // FileNotFound doesn't need a special connector this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); assertThat( this.gitHub.createRequest() .withUrlPath("/orgs/" + GITHUB_API_TEST_ORG + "-missing") .fetchHttpStatusCode(), equalTo(404)); String capturedLog = getTestCapturedLog(); assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 1)); } @Test public void testResponseCodeConnectionExceptions() throws Exception { // Because the test throws at the very start of getResponseCode, there is only one connection for 3 retries HttpConnector connector = new ResponseCodeThrowingHttpConnector<>(() -> { throw new SocketException(); }); runConnectionExceptionTest(connector, 1); runConnectionExceptionStatusCodeTest(connector, 1); connector = new ResponseCodeThrowingHttpConnector<>(() -> { throw new SocketTimeoutException(); }); runConnectionExceptionTest(connector, 1); runConnectionExceptionStatusCodeTest(connector, 1); connector = new ResponseCodeThrowingHttpConnector<>(() -> { throw new SSLHandshakeException("TestFailure"); }); runConnectionExceptionTest(connector, 1); runConnectionExceptionStatusCodeTest(connector, 1); } @Test public void testInputStreamConnectionExceptions() throws Exception { // InputStream is where most exceptions get thrown whether connection or simple FNF // Because the test throws after getResponseCode, there is one connection for each retry // However, getStatusCode never calls that and so it does succeed HttpConnector connector = new InputStreamThrowingHttpConnector<>(() -> { throw new SocketException(); }); runConnectionExceptionTest(connector, 3); runConnectionExceptionStatusCodeTest(connector, 0); connector = new InputStreamThrowingHttpConnector<>(() -> { throw new SocketTimeoutException(); }); runConnectionExceptionTest(connector, 3); runConnectionExceptionStatusCodeTest(connector, 0); connector = new InputStreamThrowingHttpConnector<>(() -> { throw new SSLHandshakeException("TestFailure"); }); runConnectionExceptionTest(connector, 3); runConnectionExceptionStatusCodeTest(connector, 0); } private void runConnectionExceptionTest(HttpConnector connector, int expectedRequestCount) throws IOException { this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); assertThat(this.gitHub.getOrganization(GITHUB_API_TEST_ORG), is(notNullValue())); String capturedLog = getTestCapturedLog(); assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + expectedRequestCount)); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); this.gitHub.createRequest().withUrlPath("/orgs/" + GITHUB_API_TEST_ORG).send(); capturedLog = getTestCapturedLog(); assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + expectedRequestCount)); } private void runConnectionExceptionStatusCodeTest(HttpConnector connector, int expectedRequestCount) throws IOException { // now wire in the connector this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()) .withConnector(connector) .build(); resetTestCapturedLog(); baseRequestCount = this.mockGitHub.getRequestCount(); assertThat(this.gitHub.createRequest().withUrlPath("/orgs/" + GITHUB_API_TEST_ORG).fetchHttpStatusCode(), equalTo(200)); String capturedLog = getTestCapturedLog(); if (expectedRequestCount > 0) { assertTrue(capturedLog.contains("will try 2 more time")); assertTrue(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + expectedRequestCount)); } else { // Success without retries assertFalse(capturedLog.contains("will try 2 more time")); assertFalse(capturedLog.contains("will try 1 more time")); assertThat(this.mockGitHub.getRequestCount(), equalTo(baseRequestCount + 1)); } } class ResponseCodeThrowingHttpConnector extends ImpatientHttpConnector { ResponseCodeThrowingHttpConnector(final Thrower thrower) { super(new HttpConnector() { final int[] count = { 0 }; @Override public HttpURLConnection connect(URL url) throws IOException { if (url.toString().contains(GITHUB_API_TEST_ORG)) { count[0]++; } connection = Mockito.spy(new HttpURLConnectionWrapper(url) { @Override public int getResponseCode() throws IOException { // While this is not the way this would go in the real world, it is a fine test // to show that exception handling and retries are working as expected if (getURL().toString().contains(GITHUB_API_TEST_ORG)) { if (count[0] % 3 != 0) { thrower.throwError(); } } return super.getResponseCode(); } }); return connection; } }); } } class InputStreamThrowingHttpConnector extends ImpatientHttpConnector { InputStreamThrowingHttpConnector(final Thrower thrower) { super(new HttpConnector() { final int[] count = { 0 }; @Override public HttpURLConnection connect(URL url) throws IOException { if (url.toString().contains(GITHUB_API_TEST_ORG)) { count[0]++; } connection = Mockito.spy(new HttpURLConnectionWrapper(url) { @Override public InputStream getInputStream() throws IOException { // getResponseMessage throwing even though getResponseCode doesn't. // While this is not the way this would go in the real world, it is a fine test // to show that exception handling and retries are working as expected if (getURL().toString().contains(GITHUB_API_TEST_ORG)) { if (count[0] % 3 != 0) { thrower.throwError(); } } return super.getInputStream(); } }); return connection; } }); } } @FunctionalInterface public interface Thrower { void throwError() throws E; } /** * This is not great but it get the job done. Tried to do a spy of HttpURLConnection but it wouldn't work right. * Trying to stub methods caused the spy to say it was already connected. */ static class HttpURLConnectionWrapper extends HttpURLConnection { protected final HttpURLConnection httpURLConnection; HttpURLConnectionWrapper(URL url) throws IOException { super(new URL("http://nonexistant")); httpURLConnection = (HttpURLConnection) url.openConnection(); } public void connect() throws IOException { httpURLConnection.connect(); } public void setConnectTimeout(int timeout) { httpURLConnection.setConnectTimeout(timeout); } public int getConnectTimeout() { return httpURLConnection.getConnectTimeout(); } public void setReadTimeout(int timeout) { httpURLConnection.setReadTimeout(timeout); } public int getReadTimeout() { return httpURLConnection.getReadTimeout(); } public URL getURL() { return httpURLConnection.getURL(); } public int getContentLength() { return httpURLConnection.getContentLength(); } public long getContentLengthLong() { return httpURLConnection.getContentLengthLong(); } public String getContentType() { return httpURLConnection.getContentType(); } public String getContentEncoding() { return httpURLConnection.getContentEncoding(); } public long getExpiration() { return httpURLConnection.getExpiration(); } public long getDate() { return httpURLConnection.getDate(); } public long getLastModified() { return httpURLConnection.getLastModified(); } public String getHeaderField(String name) { return httpURLConnection.getHeaderField(name); } public Map> getHeaderFields() { return httpURLConnection.getHeaderFields(); } public int getHeaderFieldInt(String name, int Default) { return httpURLConnection.getHeaderFieldInt(name, Default); } public long getHeaderFieldLong(String name, long Default) { return httpURLConnection.getHeaderFieldLong(name, Default); } public Object getContent() throws IOException { return httpURLConnection.getContent(); } @Override public Object getContent(Class[] classes) throws IOException { return httpURLConnection.getContent(classes); } public InputStream getInputStream() throws IOException { return httpURLConnection.getInputStream(); } public OutputStream getOutputStream() throws IOException { return httpURLConnection.getOutputStream(); } public String toString() { return httpURLConnection.toString(); } public void setDoInput(boolean doinput) { httpURLConnection.setDoInput(doinput); } public boolean getDoInput() { return httpURLConnection.getDoInput(); } public void setDoOutput(boolean dooutput) { httpURLConnection.setDoOutput(dooutput); } public boolean getDoOutput() { return httpURLConnection.getDoOutput(); } public void setAllowUserInteraction(boolean allowuserinteraction) { httpURLConnection.setAllowUserInteraction(allowuserinteraction); } public boolean getAllowUserInteraction() { return httpURLConnection.getAllowUserInteraction(); } public void setUseCaches(boolean usecaches) { httpURLConnection.setUseCaches(usecaches); } public boolean getUseCaches() { return httpURLConnection.getUseCaches(); } public void setIfModifiedSince(long ifmodifiedsince) { httpURLConnection.setIfModifiedSince(ifmodifiedsince); } public long getIfModifiedSince() { return httpURLConnection.getIfModifiedSince(); } public boolean getDefaultUseCaches() { return httpURLConnection.getDefaultUseCaches(); } public void setDefaultUseCaches(boolean defaultusecaches) { httpURLConnection.setDefaultUseCaches(defaultusecaches); } public void setRequestProperty(String key, String value) { httpURLConnection.setRequestProperty(key, value); } public void addRequestProperty(String key, String value) { httpURLConnection.addRequestProperty(key, value); } public String getRequestProperty(String key) { return httpURLConnection.getRequestProperty(key); } public Map> getRequestProperties() { return httpURLConnection.getRequestProperties(); } public String getHeaderFieldKey(int n) { return httpURLConnection.getHeaderFieldKey(n); } public void setFixedLengthStreamingMode(int contentLength) { httpURLConnection.setFixedLengthStreamingMode(contentLength); } public void setFixedLengthStreamingMode(long contentLength) { httpURLConnection.setFixedLengthStreamingMode(contentLength); } public void setChunkedStreamingMode(int chunklen) { httpURLConnection.setChunkedStreamingMode(chunklen); } public String getHeaderField(int n) { return httpURLConnection.getHeaderField(n); } public void setInstanceFollowRedirects(boolean followRedirects) { httpURLConnection.setInstanceFollowRedirects(followRedirects); } public boolean getInstanceFollowRedirects() { return httpURLConnection.getInstanceFollowRedirects(); } public void setRequestMethod(String method) throws ProtocolException { httpURLConnection.setRequestMethod(method); } public String getRequestMethod() { return httpURLConnection.getRequestMethod(); } public int getResponseCode() throws IOException { return httpURLConnection.getResponseCode(); } public String getResponseMessage() throws IOException { return httpURLConnection.getResponseMessage(); } public long getHeaderFieldDate(String name, long Default) { return httpURLConnection.getHeaderFieldDate(name, Default); } public void disconnect() { httpURLConnection.disconnect(); } public boolean usingProxy() { return httpURLConnection.usingProxy(); } public Permission getPermission() throws IOException { return httpURLConnection.getPermission(); } public InputStream getErrorStream() { return httpURLConnection.getErrorStream(); } } }