Repository: hub4j/github-api
Pull request #1290 (2021-11-08 03:48:05)

Swtich from HttpConnector to GitHubConnector
# Description

This change has the same aim as #1287 - remove this library's underlying dependency on `HttpURLConnection` and the limitations it entails.  

This PR introduces a new interface called `GitHubConnector` that replaces `HttpConnector`. It too has a single method, but this one takes `ConnectorRequest` and returns `ConnectorResponse`, interfaces controlled by this library instead of classes tied to any other library or java version. 

Significant effort was made to not change user-visible default behavior with this change.  Only users who change their code to use a non-adapter implementation of `GitHubConnector` (such as `OkHttpGitHubConnetor`) should see any noticeable change and even that should be limited to less used methods such as `GitHub.getConnector()`. 

<!-- Describe your change here -->

# Before submitting a PR:
We love getting PRs, but we hate asking people for the same basic changes every time.

- [ ] Push your changes to a branch other than `main`. Create your PR from that branch.
- [ ] Add JavaDocs and other comments
- [ ] Write tests that run and pass in CI. See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to capture snapshot data.
- [ ] Run `mvn -D enable-ci clean install site` locally. If this command doesn't succeed, your change will not pass CI.

# When creating a PR: 

- [ ] Fill in the "Description" above.
- [ ] Enable "Allow edits from maintainers".



Patch:
diff --git a/pom.xml b/pom.xml
index 43c52bcaa..4317b63d5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -270,7 +270,6 @@
           </annotationProcessorPaths>
         </configuration>
       </plugin>
-
       <plugin>
         <artifactId>maven-surefire-plugin</artifactId>
         <executions>
@@ -575,6 +574,28 @@
           <plugin>
             <artifactId>maven-surefire-plugin</artifactId>
             <executions>
+              <execution>
+                <id>okhttp-test</id>
+                <phase>test</phase>
+                <goals>
+                  <goal>test</goal>
+                </goals>
+                <configuration>
+                  <excludesFile>src/test/resources/slow-or-flaky-tests.txt</excludesFile>
+                  <argLine>@{jacoco.surefire.argLine} ${surefire.argLine} -Dtest.github.connector=okhttp</argLine>
+                </configuration>
+              </execution>
+              <execution>
+                <id>okhttpconnector-test</id>
+                <phase>test</phase>
+                <goals>
+                  <goal>test</goal>
+                </goals>
+                <configuration>
+                  <excludesFile>src/test/resources/slow-or-flaky-tests.txt</excludesFile>
+                  <argLine>@{jacoco.surefire.argLine} ${surefire.argLine} -Dtest.github.connector=okhttpconnector</argLine>
+                </configuration>
+              </execution>
               <execution>
                 <id>slow-or-flaky-test</id>
                 <phase>test</phase>
diff --git a/src/main/java/org/kohsuke/github/AbuseLimitHandler.java b/src/main/java/org/kohsuke/github/AbuseLimitHandler.java
index aabd07c1a..dfbee7f1d 100644
--- a/src/main/java/org/kohsuke/github/AbuseLimitHandler.java
+++ b/src/main/java/org/kohsuke/github/AbuseLimitHandler.java
@@ -1,9 +1,13 @@
 package org.kohsuke.github;
 
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+
 import java.io.IOException;
 import java.io.InterruptedIOException;
 import java.net.HttpURLConnection;
 
+import javax.annotation.Nonnull;
+
 /**
  * Pluggable strategy to determine what to do when the API abuse limit is hit.
  *
@@ -13,6 +17,33 @@ import java.net.HttpURLConnection;
  * @see RateLimitHandler
  */
 public abstract class AbuseLimitHandler {
+
+    /**
+     * Called when the library encounters HTTP error indicating that the API abuse limit is reached.
+     *
+     * <p>
+     * Any exception thrown from this method will cause the request to fail, and the caller of github-api will receive
+     * an exception. If this method returns normally, another request will be attempted. For that to make sense, the
+     * implementation needs to wait for some time.
+     *
+     * @param connectorResponse
+     *            Response information for this request.
+     * @throws IOException
+     *             on failure
+     * @see <a href="https://developer.github.com/v3/#abuse-rate-limits">API documentation from GitHub</a>
+     * @see <a href=
+     *      "https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits">Dealing
+     *      with abuse rate limits</a>
+     *
+     */
+    public void onError(@Nonnull GitHubConnectorResponse connectorResponse) throws IOException {
+        GHIOException e = new HttpException("Abuse limit violation",
+                connectorResponse.statusCode(),
+                connectorResponse.header("Status"),
+                connectorResponse.request().url().toString()).withResponseHeaderFields(connectorResponse.allHeaders());
+        onError(e, connectorResponse.toHttpURLConnection());
+    }
+
     /**
      * Called when the library encounters HTTP error indicating that the API abuse limit is reached.
      *
@@ -34,7 +65,9 @@ public abstract class AbuseLimitHandler {
      *      with abuse rate limits</a>
      *
      */
-    public abstract void onError(IOException e, HttpURLConnection uc) throws IOException;
+    @Deprecated
+    public void onError(IOException e, HttpURLConnection uc) throws IOException {
+    }
 
     /**
      * Wait until the API abuse "wait time" is passed.
diff --git a/src/main/java/org/kohsuke/github/GHCompare.java b/src/main/java/org/kohsuke/github/GHCompare.java
index e1e059b9b..3a4af3a77 100644
--- a/src/main/java/org/kohsuke/github/GHCompare.java
+++ b/src/main/java/org/kohsuke/github/GHCompare.java
@@ -3,7 +3,6 @@ package org.kohsuke.github;
 import com.fasterxml.jackson.annotation.JacksonInject;
 import com.infradna.tool.bridge_method_injector.WithBridgeMethods;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-import org.jetbrains.annotations.NotNull;
 
 import java.io.IOException;
 import java.net.URL;
@@ -174,7 +173,7 @@ public class GHCompare {
         } else {
             // if not using paginated commits, adapt the returned commits array
             return new PagedIterable<Commit>() {
-                @NotNull
+                @Nonnull
                 @Override
                 public PagedIterator<Commit> _iterator(int pageSize) {
                     return new PagedIterator<>(Collections.singleton(commits).iterator(), null);
diff --git a/src/main/java/org/kohsuke/github/GHNotificationStream.java b/src/main/java/org/kohsuke/github/GHNotificationStream.java
index ee2a2e60b..4c545354c 100644
--- a/src/main/java/org/kohsuke/github/GHNotificationStream.java
+++ b/src/main/java/org/kohsuke/github/GHNotificationStream.java
@@ -192,7 +192,7 @@ public class GHNotificationStream extends GitHubInteractiveObject implements Ite
                         idx = threads.length - 1;
 
                         nextCheckTime = calcNextCheckTime(response);
-                        lastModified = response.headerField("Last-Modified");
+                        lastModified = response.header("Last-Modified");
                     }
                 } catch (IOException | InterruptedException e) {
                     throw new RuntimeException(e);
@@ -200,7 +200,7 @@ public class GHNotificationStream extends GitHubInteractiveObject implements Ite
             }
 
             private long calcNextCheckTime(GitHubResponse<GHThread[]> response) {
-                String v = response.headerField("X-Poll-Interval");
+                String v = response.header("X-Poll-Interval");
                 if (v == null)
                     v = "60";
                 long seconds = Integer.parseInt(v);
diff --git a/src/main/java/org/kohsuke/github/GHObject.java b/src/main/java/org/kohsuke/github/GHObject.java
index a86d0ae50..7d89cd36d 100644
--- a/src/main/java/org/kohsuke/github/GHObject.java
+++ b/src/main/java/org/kohsuke/github/GHObject.java
@@ -5,6 +5,7 @@ import com.infradna.tool.bridge_method_injector.WithBridgeMethods;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
 
 import java.io.IOException;
 import java.lang.reflect.Field;
@@ -39,13 +40,13 @@ public abstract class GHObject extends GitHubInteractiveObject {
     /**
      * Called by Jackson
      *
-     * @param responseInfo
-     *            the {@link GitHubResponse.ResponseInfo} to get headers from.
+     * @param connectorResponse
+     *            the {@link GitHubConnectorResponse} to get headers from.
      */
     @JacksonInject
-    protected void setResponseHeaderFields(@CheckForNull GitHubResponse.ResponseInfo responseInfo) {
-        if (responseInfo != null) {
-            responseHeaderFields = responseInfo.headers();
+    protected void setResponseHeaderFields(@CheckForNull GitHubConnectorResponse connectorResponse) {
+        if (connectorResponse != null) {
+            responseHeaderFields = connectorResponse.allHeaders();
         }
     }
 
diff --git a/src/main/java/org/kohsuke/github/GHRateLimit.java b/src/main/java/org/kohsuke/github/GHRateLimit.java
index 8a1d90a28..0177e4a2b 100644
--- a/src/main/java/org/kohsuke/github/GHRateLimit.java
+++ b/src/main/java/org/kohsuke/github/GHRateLimit.java
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import org.apache.commons.lang3.StringUtils;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
 
 import java.time.Duration;
 import java.time.ZonedDateTime;
@@ -436,20 +437,20 @@ public class GHRateLimit {
          *            the remaining
          * @param resetEpochSeconds
          *            the reset epoch seconds
-         * @param responseInfo
+         * @param connectorResponse
          *            the response info
          */
         @JsonCreator
         Record(@JsonProperty(value = "limit", required = true) int limit,
                 @JsonProperty(value = "remaining", required = true) int remaining,
                 @JsonProperty(value = "reset", required = true) long resetEpochSeconds,
-                @JacksonInject @CheckForNull GitHubResponse.ResponseInfo responseInfo) {
+                @JacksonInject @CheckForNull GitHubConnectorResponse connectorResponse) {
             this.limit = limit;
             this.remaining = remaining;
             this.resetEpochSeconds = resetEpochSeconds;
             String updatedAt = null;
-            if (responseInfo != null) {
-                updatedAt = responseInfo.headerField("Date");
+            if (connectorResponse != null) {
+                updatedAt = connectorResponse.header("Date");
             }
             this.resetDate = calculateResetDate(updatedAt);
         }
diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java
index d41de5eb3..782aac9d8 100644
--- a/src/main/java/org/kohsuke/github/GitHub.java
+++ b/src/main/java/org/kohsuke/github/GitHub.java
@@ -30,6 +30,8 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import org.kohsuke.github.authorization.AuthorizationProvider;
 import org.kohsuke.github.authorization.ImmutableAuthorizationProvider;
 import org.kohsuke.github.authorization.UserAuthorizationProvider;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.internal.GitHubConnectorHttpConnectorAdapter;
 import org.kohsuke.github.internal.Previews;
 
 import java.io.*;
@@ -110,7 +112,7 @@ public class GitHub {
      *            a authorization provider
      */
     GitHub(String apiUrl,
-            HttpConnector connector,
+            GitHubConnector connector,
             RateLimitHandler rateLimitHandler,
             AbuseLimitHandler abuseLimitHandler,
             GitHubRateLimitChecker rateLimitChecker,
@@ -129,7 +131,7 @@ public class GitHub {
         users = new ConcurrentHashMap<>();
         orgs = new ConcurrentHashMap<>();
 
-        this.client = new GitHubHttpUrlConnectionClient(apiUrl,
+        this.client = new GitHubClient(apiUrl,
                 connector,
                 rateLimitHandler,
                 abuseLimitHandler,
@@ -441,7 +443,7 @@ public class GitHub {
     public static GitHub offline() {
         try {
             return new GitHubBuilder().withEndpoint("https://api.github.invalid")
-                    .withConnector(HttpConnector.OFFLINE)
+                    .withConnector(GitHubConnector.OFFLINE)
                     .build();
         } catch (IOException e) {
             throw new IllegalStateException("The offline implementation constructor should not connect", e);
@@ -470,7 +472,10 @@ public class GitHub {
      * Gets connector.
      *
      * @return the connector
+     * @deprecated HttpConnector has been replaced by GitHubConnector which is generally not useful outside of this
+     *             library. If you are using this method, file an issue describing your use case.
      */
+    @Deprecated
     public HttpConnector getConnector() {
         return client.getConnector();
     }
@@ -483,8 +488,8 @@ public class GitHub {
      * @deprecated HttpConnector should not be changed. If you find yourself needing to do this, file an issue.
      */
     @Deprecated
-    public void setConnector(HttpConnector connector) {
-        client.setConnector(connector);
+    public void setConnector(@Nonnull HttpConnector connector) {
+        client.setConnector(GitHubConnectorHttpConnectorAdapter.adapt(connector));
     }
 
     /**
diff --git a/src/main/java/org/kohsuke/github/GitHubBuilder.java b/src/main/java/org/kohsuke/github/GitHubBuilder.java
index 80960b979..e937f46f1 100644
--- a/src/main/java/org/kohsuke/github/GitHubBuilder.java
+++ b/src/main/java/org/kohsuke/github/GitHubBuilder.java
@@ -3,7 +3,9 @@ package org.kohsuke.github;
 import org.apache.commons.io.IOUtils;
 import org.kohsuke.github.authorization.AuthorizationProvider;
 import org.kohsuke.github.authorization.ImmutableAuthorizationProvider;
+import org.kohsuke.github.connector.GitHubConnector;
 import org.kohsuke.github.extras.ImpatientHttpConnector;
+import org.kohsuke.github.internal.GitHubConnectorHttpConnectorAdapter;
 
 import java.io.File;
 import java.io.FileInputStream;
@@ -30,7 +32,7 @@ public class GitHubBuilder implements Cloneable {
     // default scoped so unit tests can read them.
     /* private */ String endpoint = GitHubClient.GITHUB_URL;
 
-    private HttpConnector connector;
+    private GitHubConnector connector;
 
     private RateLimitHandler rateLimitHandler = RateLimitHandler.WAIT;
     private AbuseLimitHandler abuseLimitHandler = AbuseLimitHandler.WAIT;
@@ -332,7 +334,18 @@ public class GitHubBuilder implements Cloneable {
      *            the connector
      * @return the git hub builder
      */
-    public GitHubBuilder withConnector(HttpConnector connector) {
+    public GitHubBuilder withConnector(@Nonnull HttpConnector connector) {
+        return withConnector(GitHubConnectorHttpConnectorAdapter.adapt(connector));
+    }
+
+    /**
+     * With connector git hub builder.
+     *
+     * @param connector
+     *            the connector
+     * @return the git hub builder
+     */
+    public GitHubBuilder withConnector(GitHubConnector connector) {
         this.connector = connector;
         return this;
     }
diff --git a/src/main/java/org/kohsuke/github/GitHubClient.java b/src/main/java/org/kohsuke/github/GitHubClient.java
index c836153e8..fffa5f935 100644
--- a/src/main/java/org/kohsuke/github/GitHubClient.java
+++ b/src/main/java/org/kohsuke/github/GitHubClient.java
@@ -5,10 +5,12 @@ import com.fasterxml.jackson.databind.introspect.VisibilityChecker;
 import org.apache.commons.io.IOUtils;
 import org.kohsuke.github.authorization.AuthorizationProvider;
 import org.kohsuke.github.authorization.UserAuthorizationProvider;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.connector.GitHubConnectorRequest;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+import org.kohsuke.github.function.BodyHandler;
 
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InterruptedIOException;
+import java.io.*;
 import java.net.*;
 import java.time.Instant;
 import java.time.format.DateTimeFormatter;
@@ -25,6 +27,7 @@ import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY;
 import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE;
 import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
 import static java.util.logging.Level.*;
+import static org.apache.commons.lang3.StringUtils.defaultString;
 
 /**
  * A GitHub API Client
@@ -33,7 +36,7 @@ import static java.util.logging.Level.*;
  * to send multiple requests. GitHubClient also track some GitHub API information such as {@link GHRateLimit}.
  * </p>
  */
-abstract class GitHubClient {
+class GitHubClient {
 
     static final int CONNECTION_ERROR_RETRIES = 2;
     /**
@@ -44,12 +47,12 @@ abstract class GitHubClient {
     // Cache of myself object.
     private final String apiUrl;
 
-    protected final RateLimitHandler rateLimitHandler;
-    protected final AbuseLimitHandler abuseLimitHandler;
+    private final RateLimitHandler rateLimitHandler;
+    private final AbuseLimitHandler abuseLimitHandler;
     private final GitHubRateLimitChecker rateLimitChecker;
     private final AuthorizationProvider authorizationProvider;
 
-    private HttpConnector connector;
+    private GitHubConnector connector;
 
     @Nonnull
     private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);
@@ -70,7 +73,7 @@ abstract class GitHubClient {
     }
 
     GitHubClient(String apiUrl,
-            HttpConnector connector,
+            GitHubConnector connector,
             RateLimitHandler rateLimitHandler,
             AbuseLimitHandler abuseLimitHandler,
             GitHubRateLimitChecker rateLimitChecker,
@@ -81,7 +84,7 @@ abstract class GitHubClient {
         }
 
         if (null == connector) {
-            connector = HttpConnector.DEFAULT;
+            connector = GitHubConnector.DEFAULT;
         }
         this.apiUrl = apiUrl;
         this.connector = connector;
@@ -110,7 +113,7 @@ abstract class GitHubClient {
 
     private <T> T fetch(Class<T> type, String urlPath) throws IOException {
         GitHubRequest request = GitHubRequest.newBuilder().withApiUrl(getApiUrl()).withUrlPath(urlPath).build();
-        return sendRequest(request, (responseInfo) -> GitHubResponse.parseBody(responseInfo, type)).body();
+        return sendRequest(request, (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, type)).body();
     }
 
     /**
@@ -138,7 +141,7 @@ abstract class GitHubClient {
      * @return {@code true} if this is an always offline "connection".
      */
     public boolean isOffline() {
-        return getConnector() == HttpConnector.OFFLINE;
+        return connector == GitHubConnector.OFFLINE;
     }
 
     /**
@@ -146,8 +149,15 @@ abstract class GitHubClient {
      *
      * @return the connector
      */
+    @Deprecated
     public HttpConnector getConnector() {
-        return connector;
+        if (!(connector instanceof HttpConnector)) {
+            throw new UnsupportedOperationException("This GitHubConnector does not support HttpConnector.connect().");
+        }
+
+        LOGGER.warning(
+                "HttpConnector and getConnector() are deprecated. " + "Please file an issue describing your use case.");
+        return (HttpConnector) connector;
     }
 
     /**
@@ -158,7 +168,7 @@ abstract class GitHubClient {
      * @deprecated HttpConnector should not be changed.
      */
     @Deprecated
-    public void setConnector(HttpConnector connector) {
+    public void setConnector(GitHubConnector connector) {
         LOGGER.warning("Connector should not be changed. Please file an issue describing your use case.");
         this.connector = connector;
     }
@@ -198,7 +208,7 @@ abstract class GitHubClient {
     }
 
     @CheckForNull
-    protected String getEncodedAuthorization() throws IOException {
+    String getEncodedAuthorization() throws IOException {
         return authorizationProvider.getEncodedAuthorization();
     }
 
@@ -212,7 +222,8 @@ abstract class GitHubClient {
                     .withUrlPath("/rate_limit")
                     .build();
             result = this
-                    .sendRequest(request, (responseInfo) -> GitHubResponse.parseBody(responseInfo, JsonRateLimit.class))
+                    .sendRequest(request,
+                            (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, JsonRateLimit.class))
                     .body().resources;
         } catch (FileNotFoundException e) {
             // For some versions of GitHub Enterprise, the rate_limit endpoint returns a 404.
@@ -319,9 +330,8 @@ abstract class GitHubClient {
     }
 
     /**
-     * Builds a {@link GitHubRequest}, sends the {@link GitHubRequest} to the server, and uses the
-     * {@link GitHubResponse.BodyHandler} to parse the response info and response body data into an instance of
-     * {@link T}.
+     * Builds a {@link GitHubRequest}, sends the {@link GitHubRequest} to the server, and uses the {@link BodyHandler}
+     * to parse the response info and response body data into an instance of {@link T}.
      *
      * @param builder
      *            used to build the request that will be sent to the server.
@@ -336,13 +346,13 @@ abstract class GitHubClient {
      */
     @Nonnull
     public <T> GitHubResponse<T> sendRequest(@Nonnull GitHubRequest.Builder<?> builder,
-            @CheckForNull GitHubResponse.BodyHandler<T> handler) throws IOException {
+            @CheckForNull BodyHandler<T> handler) throws IOException {
         return sendRequest(builder.build(), handler);
     }
 
     /**
-     * Sends the {@link GitHubRequest} to the server, and uses the {@link GitHubResponse.BodyHandler} to parse the
-     * response info and response body data into an instance of {@link T}.
+     * Sends the {@link GitHubRequest} to the server, and uses the {@link BodyHandler} to parse the response info and
+     * response body data into an instance of {@link T}.
      *
      * @param request
      *            the request that will be sent to the server.
@@ -356,34 +366,33 @@ abstract class GitHubClient {
      *             if an I/O Exception occurs
      */
     @Nonnull
-    public <T> GitHubResponse<T> sendRequest(GitHubRequest request, @CheckForNull GitHubResponse.BodyHandler<T> handler)
+    public <T> GitHubResponse<T> sendRequest(GitHubRequest request, @CheckForNull BodyHandler<T> handler)
             throws IOException {
         int retries = CONNECTION_ERROR_RETRIES;
-        if (request.headers().get("Accept") == null) {
-            request = request.toBuilder().withHeader("Accept", "application/vnd.github.v3+json").build();
-        }
+        GitHubConnectorRequest connectorRequest = prepareConnectorRequest(request);
+
         do {
             // if we fail to create a connection we do not retry and we do not wrap
 
-            GitHubResponse.ResponseInfo responseInfo = null;
+            GitHubConnectorResponse connectorResponse = null;
             try {
                 try {
-                    logRequest(request);
-                    rateLimitChecker.checkRateLimit(this, request);
-
-                    responseInfo = getResponseInfo(request);
-                    noteRateLimit(responseInfo);
-                    detectOTPRequired(responseInfo);
+                    logRequest(connectorRequest);
+                    rateLimitChecker.checkRateLimit(this, request.rateLimitTarget());
+                    connectorResponse = connector.send(connectorRequest);
+                    noteRateLimit(request.rateLimitTarget(), connectorResponse);
+                    detectOTPRequired(connectorResponse);
 
-                    if (isInvalidCached404Response(responseInfo)) {
+                    if (isInvalidCached404Response(connectorResponse)) {
                         // Setting "Cache-Control" to "no-cache" stops the cache from supplying
                         // "If-Modified-Since" or "If-None-Match" values.
                         // This makes GitHub give us current data (not incorrectly cached data)
-                        request = request.toBuilder().setHeader("Cache-Control", "no-cache").build();
+                        connectorRequest = prepareConnectorRequest(
+                                request.toBuilder().setHeader("Cache-Control", "no-cache").build());
                         continue;
                     }
-                    if (!(isRateLimitResponse(responseInfo) || isAbuseLimitResponse(responseInfo))) {
-                        return createResponse(responseInfo, handler);
+                    if (!(isRateLimitResponse(connectorResponse) || isAbuseLimitResponse(connectorResponse))) {
+                        return createResponse(connectorResponse, handler);
                     }
                 } catch (IOException e) {
                     // For transient errors, retry
@@ -391,12 +400,12 @@ abstract class GitHubClient {
                         continue;
                     }
 
-                    throw interpretApiError(e, request, responseInfo);
+                    throw interpretApiError(e, connectorRequest, connectorResponse);
                 }
 
-                handleLimitingErrors(responseInfo);
+                handleLimitingErrors(connectorResponse);
             } finally {
-                IOUtils.closeQuietly(responseInfo);
+                IOUtils.closeQuietly(connectorResponse);
             }
 
         } while (--retries >= 0);
@@ -404,24 +413,59 @@ abstract class GitHubClient {
         throw new GHIOException("Ran out of retries for URL: " + request.url().toString());
     }
 
-    private void logRequest(@Nonnull final GitHubRequest request) {
+    private GitHubConnectorRequest prepareConnectorRequest(GitHubRequest request) throws IOException {
+        GitHubRequest.Builder<?> builder = request.toBuilder();
+        // if the authentication is needed but no credential is given, try it anyway (so that some calls
+        // that do work with anonymous access in the reduced form should still work.)
+        if (!request.allHeaders().containsKey("Authorization")) {
+            String authorization = getEncodedAuthorization();
+            if (authorization != null) {
+                builder.setHeader("Authorization", authorization);
+            }
+        }
+        if (request.header("Accept") == null) {
+            builder.setHeader("Accept", "application/vnd.github.v3+json");
+        }
+        builder.setHeader("Accept-Encoding", "gzip");
+
+        if (request.hasBody()) {
+            if (request.body() != null) {
+                builder.contentType(defaultString(request.contentType(), "application/x-www-form-urlencoded"));
+            } else {
+                builder.contentType("application/json");
+                Map<String, Object> json = new HashMap<>();
+                for (GitHubRequest.Entry e : request.args()) {
+                    json.put(e.key, e.value);
+                }
+                builder.with(new ByteArrayInputStream(getMappingObjectWriter().writeValueAsBytes(json)));
+            }
+
+        }
+
+        return builder.build();
+    }
+
+    private void logRequest(@Nonnull final GitHubConnectorRequest request) {
         LOGGER.log(FINE,
                 () -> "GitHub API request [" + (getLogin() == null ? "anonymous" : getLogin()) + "]: "
                         + request.method() + " " + request.url().toString());
     }
 
-    @Nonnull
-    protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;
-
-    protected abstract void handleLimitingErrors(@Nonnull GitHubResponse.ResponseInfo responseInfo) throws IOException;
+    private void handleLimitingErrors(@Nonnull GitHubConnectorResponse connectorResponse) throws IOException {
+        if (isRateLimitResponse(connectorResponse)) {
+            rateLimitHandler.onError(connectorResponse);
+        } else if (isAbuseLimitResponse(connectorResponse)) {
+            abuseLimitHandler.onError(connectorResponse);
+        }
+    }
 
     @Nonnull
-    private static <T> GitHubResponse<T> createResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo,
-            @CheckForNull GitHubResponse.BodyHandler<T> handler) throws IOException {
+    private static <T> GitHubResponse<T> createResponse(@Nonnull GitHubConnectorResponse connectorResponse,
+            @CheckForNull BodyHandler<T> handler) throws IOException {
         T body = null;
-        if (responseInfo.statusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
+        if (connectorResponse.statusCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
             // special case handling for 304 unmodified, as the content will be ""
-        } else if (responseInfo.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {
+        } else if (connectorResponse.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {
 
             // Response code 202 means data is being generated or an action that can require some time is triggered.
             // This happens in specific cases:
@@ -430,20 +474,20 @@ abstract class GitHubClient {
             // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run
 
             LOGGER.log(FINE,
-                    "Received HTTP_ACCEPTED(202) from " + responseInfo.url().toString()
+                    "Received HTTP_ACCEPTED(202) from " + connectorResponse.request().url().toString()
                             + " . Please try again in 5 seconds.");
         } else if (handler != null) {
-            body = handler.apply(responseInfo);
+            body = handler.apply(connectorResponse);
         }
-        return new GitHubResponse<>(responseInfo, body);
+        return new GitHubResponse<>(connectorResponse, body);
     }
 
     /**
      * Handle API error by either throwing it or by returning normally to retry.
      */
     private static IOException interpretApiError(IOException e,
-            @Nonnull GitHubRequest request,
-            @CheckForNull GitHubResponse.ResponseInfo responseInfo) throws IOException {
+            @Nonnull GitHubConnectorRequest connectorRequest,
+            @CheckForNull GitHubConnectorResponse connectorResponse) throws IOException {
         // If we're already throwing a GHIOException, pass through
         if (e instanceof GHIOException) {
             return e;
@@ -454,11 +498,11 @@ abstract class GitHubClient {
         Map<String, List<String>> headers = new HashMap<>();
         String errorMessage = null;
 
-        if (responseInfo != null) {
-            statusCode = responseInfo.statusCode();
-            message = responseInfo.headerField("Status");
-            headers = responseInfo.headers();
-            errorMessage = responseInfo.errorMessage();
+        if (connectorResponse != null) {
+            statusCode = connectorResponse.statusCode();
+            message = connectorResponse.header("Status");
+            headers = connectorResponse.allHeaders();
+            errorMessage = connectorResponse.errorMessage();
         }
 
         if (errorMessage != null) {
@@ -467,24 +511,24 @@ abstract class GitHubClient {
                 e = new GHFileNotFoundException(e.getMessage() + " " + errorMessage, e)
                         .withResponseHeaderFields(headers);
             } else if (statusCode >= 0) {
-                e = new HttpException(errorMessage, statusCode, message, request.url().toString(), e);
+                e = new HttpException(errorMessage, statusCode, message, connectorRequest.url().toString(), e);
             } else {
                 e = new GHIOException(errorMessage).withResponseHeaderFields(headers);
             }
         } else if (!(e instanceof FileNotFoundException)) {
-            e = new HttpException(statusCode, message, request.url().toString(), e);
+            e = new HttpException(statusCode, message, connectorRequest.url().toString(), e);
         }
         return e;
     }
 
-    protected static boolean isRateLimitResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
-        return responseInfo.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
-                && "0".equals(responseInfo.headerField("X-RateLimit-Remaining"));
+    protected static boolean isRateLimitResponse(@Nonnull GitHubConnectorResponse connectorResponse) {
+        return connectorResponse.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
+                && "0".equals(connectorResponse.header("X-RateLimit-Remaining"));
     }
 
-    protected static boolean isAbuseLimitResponse(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
-        return responseInfo.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
-                && responseInfo.headerField("Retry-After") != null;
+    protected static boolean isAbuseLimitResponse(@Nonnull GitHubConnectorResponse connectorResponse) {
+        return connectorResponse.statusCode() == HttpURLConnection.HTTP_FORBIDDEN
+                && connectorResponse.header("Retry-After") != null;
     }
 
     private static boolean retryConnectionError(IOException e, URL url, int retries) throws IOException {
@@ -505,9 +549,9 @@ abstract class GitHubClient {
         return false;
     }
 
-    private static boolean isInvalidCached404Response(GitHubResponse.ResponseInfo responseInfo) {
+    private static boolean isInvalidCached404Response(GitHubConnectorResponse connectorResponse) {
         // WORKAROUND FOR ISSUE #669:
-        // When the Requester detects a 404 response with an ETag (only happpens when the server's 304
+        // When the Requester detects a 404 response with an ETag (only happens when the server's 304
         // is bogus and would cause cache corruption), try the query again with new request header
         // that forces the server to not return 304 and return new data instead.
         //
@@ -516,44 +560,45 @@ abstract class GitHubClient {
         // scenarios. If GitHub ever fixes their issue and/or begins providing accurate ETags to
         // their 404 responses, this will result in at worst two requests being made for each 404
         // responses. However, only the second request will count against rate limit.
-        if (responseInfo.statusCode() == 404 && Objects.equals(responseInfo.request().method(), "GET")
-                && responseInfo.headerField("ETag") != null
-                && !Objects.equals(responseInfo.request().headers().get("Cache-Control"), "no-cache")) {
+        if (connectorResponse.statusCode() == 404 && Objects.equals(connectorResponse.request().method(), "GET")
+                && connectorResponse.header("ETag") != null
+                && !Objects.equals(connectorResponse.request().header("Cache-Control"), "no-cache")) {
             LOGGER.log(FINE,
-                    "Encountered GitHub invalid cached 404 from " + responseInfo.url()
+                    "Encountered GitHub invalid cached 404 from " + connectorResponse.request().url()
                             + ". Retrying with \"Cache-Control\"=\"no-cache\"...");
             return true;
         }
         return false;
     }
 
-    private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo responseInfo) {
+    private void noteRateLimit(@Nonnull RateLimitTarget rateLimitTarget,
+            @Nonnull GitHubConnectorResponse connectorResponse) {
         try {
-            String limitString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Limit"),
+            String limitString = Objects.requireNonNull(connectorResponse.header("X-RateLimit-Limit"),
                     "Missing X-RateLimit-Limit");
-            String remainingString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Remaining"),
+            String remainingString = Objects.requireNonNull(connectorResponse.header("X-RateLimit-Remaining"),
                     "Missing X-RateLimit-Remaining");
-            String resetString = Objects.requireNonNull(responseInfo.headerField("X-RateLimit-Reset"),
+            String resetString = Objects.requireNonNull(connectorResponse.header("X-RateLimit-Reset"),
                     "Missing X-RateLimit-Reset");
             int limit, remaining;
             long reset;
             limit = Integer.parseInt(limitString);
             remaining = Integer.parseInt(remainingString);
             reset = Long.parseLong(resetString);
-            GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, responseInfo);
-            updateRateLimit(GHRateLimit.fromRecord(observed, responseInfo.request().rateLimitTarget()));
+            GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, connectorResponse);
+            updateRateLimit(GHRateLimit.fromRecord(observed, rateLimitTarget));
         } catch (NumberFormatException | NullPointerException e) {
             LOGGER.log(FINEST, "Missing or malformed X-RateLimit header: ", e);
         }
     }
 
-    private static void detectOTPRequired(@Nonnull GitHubResponse.ResponseInfo responseInfo) throws GHIOException {
+    private static void detectOTPRequired(@Nonnull GitHubConnectorResponse connectorResponse) throws GHIOException {
         // 401 Unauthorized == bad creds or OTP request
-        if (responseInfo.statusCode() == HTTP_UNAUTHORIZED) {
+        if (connectorResponse.statusCode() == HTTP_UNAUTHORIZED) {
             // In the case of a user with 2fa enabled, a header with X-GitHub-OTP
             // will be returned indicating the user needs to respond with an otp
-            if (responseInfo.headerField("X-GitHub-OTP") != null) {
-                throw new GHOTPRequiredException().withResponseHeaderFields(responseInfo.headers());
+            if (connectorResponse.header("X-GitHub-OTP") != null) {
+                throw new GHOTPRequiredException().withResponseHeaderFields(connectorResponse.allHeaders());
             }
         }
     }
@@ -607,7 +652,7 @@ abstract class GitHubClient {
     private boolean isPrivateModeEnabled() {
         try {
             GitHubResponse<?> response = sendRequest(GitHubRequest.newBuilder().withApiUrl(getApiUrl()), null);
-            return response.statusCode() == HTTP_UNAUTHORIZED && response.headerField("X-GitHub-Media-Type") != null;
+            return response.statusCode() == HTTP_UNAUTHORIZED && response.header("X-GitHub-Media-Type") != null;
         } catch (IOException e) {
             return false;
         }
@@ -655,7 +700,7 @@ abstract class GitHubClient {
     }
 
     /**
-     * Helper for {@link #getMappingObjectReader(GitHubResponse.ResponseInfo)}
+     * Helper for {@link #getMappingObjectReader(GitHubConnectorResponse)}
      *
      * @param root
      *            the root GitHub object for this reader
@@ -664,7 +709,7 @@ abstract class GitHubClient {
      */
     @Nonnull
     static ObjectReader getMappingObjectReader(@Nonnull GitHub root) {
-        ObjectReader reader = getMappingObjectReader((GitHubResponse.ResponseInfo) null);
+        ObjectReader reader = getMappingObjectReader((GitHubConnectorResponse) null);
         ((InjectableValues.Std) reader.getInjectableValues()).addValue(GitHub.class, root);
         return reader;
     }
@@ -678,22 +723,26 @@ abstract class GitHubClient {
      * Having one spot to create readers and having it take all injectable values is not a great long term solution but
      * it is sufficient for this first cut.
      *
-     * @param responseInfo
-     *            the {@link GitHubResponse.ResponseInfo} to inject for this reader.
+     * @param connectorResponse
+     *            the {@link GitHubConnectorResponse} to inject for this reader.
      *
      * @return an {@link ObjectReader} instance that can be further configured.
      */
     @Nonnull
-    static ObjectReader getMappingObjectReader(@CheckForNull GitHubResponse.ResponseInfo responseInfo) {
+    static ObjectReader getMappingObjectReader(@CheckForNull GitHubConnectorResponse connectorResponse) {
         Map<String, Object> injected = new HashMap<>();
 
         // Required or many things break
-        injected.put(GitHubResponse.ResponseInfo.class.getName(), null);
+        injected.put(GitHubConnectorResponse.class.getName(), null);
         injected.put(GitHub.class.getName(), null);
 
-        if (responseInfo != null) {
-            injected.put(GitHubResponse.ResponseInfo.class.getName(), responseInfo);
-            injected.putAll(responseInfo.request().injectedMappingValues());
+        if (connectorResponse != null) {
+            injected.put(GitHubConnectorResponse.class.getName(), connectorResponse);
+            GitHubConnectorRequest request = connectorResponse.request();
+            // This is cheating, but it is an acceptable cheat for now.
+            if (request instanceof GitHubRequest) {
+                injected.putAll(((GitHubRequest) connectorResponse.request()).injectedMappingValues());
+            }
         }
         return MAPPER.reader(new InjectableValues.Std(injected));
     }
diff --git a/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java b/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java
deleted file mode 100644
index f7a4b9d85..000000000
--- a/src/main/java/org/kohsuke/github/GitHubHttpUrlConnectionClient.java
+++ /dev/null
@@ -1,235 +0,0 @@
-package org.kohsuke.github;
-
-import org.apache.commons.io.IOUtils;
-import org.kohsuke.github.authorization.AuthorizationProvider;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.reflect.Field;
-import java.net.HttpURLConnection;
-import java.net.ProtocolException;
-import java.nio.charset.StandardCharsets;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.logging.Logger;
-import java.util.zip.GZIPInputStream;
-
-import javax.annotation.Nonnull;
-
-import static java.util.logging.Level.*;
-import static org.apache.commons.lang3.StringUtils.defaultString;
-
-/**
- * A GitHub API Client for HttpUrlConnection
- * <p>
- * A GitHubClient can be used to send requests and retrieve their responses. GitHubClient is thread-safe and can be used
- * to send multiple requests. GitHubClient also track some GitHub API information such as {@link GHRateLimit}.
- * </p>
- * <p>
- * GitHubHttpUrlConnectionClient gets a new {@link HttpURLConnection} for each call to send.
- * </p>
- */
-class GitHubHttpUrlConnectionClient extends GitHubClient {
-
-    GitHubHttpUrlConnectionClient(String apiUrl,
-            HttpConnector connector,
-            RateLimitHandler rateLimitHandler,
-            AbuseLimitHandler abuseLimitHandler,
-            GitHubRateLimitChecker rateLimitChecker,
-            AuthorizationProvider authorizationProvider) throws IOException {
-        super(apiUrl, connector, rateLimitHandler, abuseLimitHandler, rateLimitChecker, authorizationProvider);
-    }
-
-    @Nonnull
-    protected GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException {
-        HttpURLConnection connection;
-        try {
-            connection = HttpURLConnectionResponseInfo.setupConnection(this, request);
-        } catch (IOException e) {
-            // An error in here should be wrapped to bypass http exception wrapping.
-            throw new GHIOException(e.getMessage(), e);
-        }
-
-        // HttpUrlConnection is nuts. This call opens the connection and gets a response.
-        // Putting this on it's own line for ease of debugging if needed.
-        int statusCode = connection.getResponseCode();
-        Map<String, List<String>> headers = connection.getHeaderFields();
-
-        return new HttpURLConnectionResponseInfo(request, statusCode, headers, connection);
-    }
-
-    protected void handleLimitingErrors(@Nonnull GitHubResponse.ResponseInfo responseInfo) throws IOException {
-        if (isRateLimitResponse(responseInfo)) {
-            GHIOException e = new HttpException("Rate limit violation",
-                    responseInfo.statusCode(),
-                    responseInfo.headerField("Status"),
-                    responseInfo.url().toString()).withResponseHeaderFields(responseInfo.headers());
-            rateLimitHandler.onError(e, ((HttpURLConnectionResponseInfo) responseInfo).connection);
-        } else if (isAbuseLimitResponse(responseInfo)) {
-            GHIOException e = new HttpException("Abuse limit violation",
-                    responseInfo.statusCode(),
-                    responseInfo.headerField("Status"),
-                    responseInfo.url().toString()).withResponseHeaderFields(responseInfo.headers());
-            abuseLimitHandler.onError(e, ((HttpURLConnectionResponseInfo) responseInfo).connection);
-        }
-    }
-
-    /**
-     * Initial response information supplied to a {@link GitHubResponse.BodyHandler} when a response is initially
-     * received and before the body is processed.
-     *
-     * Implementation specific to {@link HttpURLConnection}.
-     */
-    static class HttpURLConnectionResponseInfo extends GitHubResponse.ResponseInfo {
-
-        @Nonnull
-        private final HttpURLConnection connection;
-
-        HttpURLConnectionResponseInfo(@Nonnull GitHubRequest request,
-                int statusCode,
-                @Nonnull Map<String, List<String>> headers,
-                @Nonnull HttpURLConnection connection) {
-            super(request, statusCode, headers);
-            this.connection = connection;
-        }
-
-        @Nonnull
-        static HttpURLConnection setupConnection(@Nonnull GitHubClient client, @Nonnull GitHubRequest request)
-                throws IOException {
-            HttpURLConnection connection = client.getConnector().connect(request.url());
-
-            // if the authentication is needed but no credential is given, try it anyway (so that some calls
-            // that do work with anonymous access in the reduced form should still work.)
-            if (!request.headers().containsKey("Authorization")) {
-                String authorization = client.getEncodedAuthorization();
-                if (authorization != null) {
-                    connection.setRequestProperty("Authorization", client.getEncodedAuthorization());
-                }
-            }
-
-            setRequestMethod(request.method(), connection);
-            buildRequest(request, connection);
-
-            return connection;
-        }
-
-        /**
-         * Set up the request parameters or POST payload.
-         */
-        private static void buildRequest(GitHubRequest request, HttpURLConnection connection) throws IOException {
-            for (Map.Entry<String, String> e : request.headers().entrySet()) {
-                String v = e.getValue();
-                if (v != null)
-                    connection.setRequestProperty(e.getKey(), v);
-            }
-            connection.setRequestProperty("Accept-Encoding", "gzip");
-
-            if (request.inBody()) {
-                connection.setDoOutput(true);
-
-                try (InputStream body = request.body()) {
-                    if (body != null) {
-                        connection.setRequestProperty("Content-type",
-                                defaultString(request.contentType(), "application/x-www-form-urlencoded"));
-                        byte[] bytes = new byte[32768];
-                        int read;
-                        while ((read = body.read(bytes)) != -1) {
-                            connection.getOutputStream().write(bytes, 0, read);
-                        }
-                    } else {
-                        connection.setRequestProperty("Content-type",
-                                defaultString(request.contentType(), "application/json"));
-                        Map<String, Object> json = new HashMap<>();
-                        for (GitHubRequest.Entry e : request.args()) {
-                            json.put(e.key, e.value);
-                        }
-                        getMappingObjectWriter().writeValue(connection.getOutputStream(), json);
-                    }
-                }
-            }
-        }
-
-        private static void setRequestMethod(String method, HttpURLConnection connection) throws IOException {
-            try {
-                connection.setRequestMethod(method);
-            } catch (ProtocolException e) {
-                // JDK only allows one of the fixed set of verbs. Try to override that
-                try {
-                    Field $method = HttpURLConnection.class.getDeclaredField("method");
-                    $method.setAccessible(true);
-                    $method.set(connection, method);
-                } catch (Exception x) {
-                    throw (IOException) new IOException("Failed to set the custom verb").initCause(x);
-                }
-                // sun.net.www.protocol.https.DelegatingHttpsURLConnection delegates to another HttpURLConnection
-                try {
-                    Field $delegate = connection.getClass().getDeclaredField("delegate");
-                    $delegate.setAccessible(true);
-                    Object delegate = $delegate.get(connection);
-                    if (delegate instanceof HttpURLConnection) {
-                        HttpURLConnection nested = (HttpURLConnection) delegate;
-                        setRequestMethod(method, nested);
-                    }
-                } catch (NoSuchFieldException x) {
-                    // no problem
-                } catch (IllegalAccessException x) {
-                    throw (IOException) new IOException("Failed to set the custom verb").initCause(x);
-                }
-            }
-            if (!connection.getRequestMethod().equals(method))
-                throw new IllegalStateException("Failed to set the request method to " + method);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        InputStream bodyStream() throws IOException {
-            return wrapStream(connection.getInputStream());
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        String errorMessage() {
-            String result = null;
-            InputStream stream = null;
-            try {
-                stream = connection.getErrorStream();
-                if (stream != null) {
-                    result = IOUtils.toString(wrapStream(stream), StandardCharsets.UTF_8);
-                }
-            } catch (Exception e) {
-                LOGGER.log(FINER, "Ignored exception get error message", e);
-            } finally {
-                IOUtils.closeQuietly(stream);
-            }
-            return result;
-        }
-
-        /**
-         * Handles the "Content-Encoding" header.
-         *
-         * @param stream
-         *            the stream to possibly wrap
-         *
-         */
-        private InputStream wrapStream(InputStream stream) throws IOException {
-            String encoding = headerField("Content-Encoding");
-            if (encoding == null || stream == null)
-                return stream;
-            if (encoding.equals("gzip"))
-                return new GZIPInputStream(stream);
-
-            throw new UnsupportedOperationException("Unexpected Content-Encoding: " + encoding);
-        }
-
-        private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());
-
-        @Override
-        public void close() throws IOException {
-            IOUtils.closeQuietly(connection.getInputStream());
-        }
-    }
-
-}
diff --git a/src/main/java/org/kohsuke/github/GitHubPageIterator.java b/src/main/java/org/kohsuke/github/GitHubPageIterator.java
index 8c0a00f6c..476f5cb3c 100644
--- a/src/main/java/org/kohsuke/github/GitHubPageIterator.java
+++ b/src/main/java/org/kohsuke/github/GitHubPageIterator.java
@@ -138,10 +138,10 @@ class GitHubPageIterator<T> implements Iterator<T> {
         URL url = nextRequest.url();
         try {
             GitHubResponse<T> nextResponse = client.sendRequest(nextRequest,
-                    (responseInfo) -> GitHubResponse.parseBody(responseInfo, type));
+                    (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, type));
             assert nextResponse.body() != null;
             next = nextResponse.body();
-            nextRequest = findNextURL(nextResponse);
+            nextRequest = findNextURL(nextRequest, nextResponse);
             if (nextRequest == null) {
                 finalResponse = nextResponse;
             }
@@ -155,16 +155,17 @@ class GitHubPageIterator<T> implements Iterator<T> {
     /**
      * Locate the next page from the pagination "Link" tag.
      */
-    private GitHubRequest findNextURL(GitHubResponse<T> nextResponse) throws MalformedURLException {
+    private GitHubRequest findNextURL(GitHubRequest nextRequest, GitHubResponse<T> nextResponse)
+            throws MalformedURLException {
         GitHubRequest result = null;
-        String link = nextResponse.headerField("Link");
+        String link = nextResponse.header("Link");
         if (link != null) {
             for (String token : link.split(", ")) {
                 if (token.endsWith("rel=\"next\"")) {
                     // found the next page. This should look something like
                     // <https://api.github.com/repos?page=3&per_page=100>; rel="next"
                     int idx = token.indexOf('>');
-                    result = nextResponse.request().toBuilder().setRawUrlPath(token.substring(1, idx)).build();
+                    result = nextRequest.toBuilder().setRawUrlPath(token.substring(1, idx)).build();
                     break;
                 }
             }
diff --git a/src/main/java/org/kohsuke/github/GitHubRateLimitChecker.java b/src/main/java/org/kohsuke/github/GitHubRateLimitChecker.java
index 5e5d99887..859baf0ce 100644
--- a/src/main/java/org/kohsuke/github/GitHubRateLimitChecker.java
+++ b/src/main/java/org/kohsuke/github/GitHubRateLimitChecker.java
@@ -107,20 +107,20 @@ class GitHubRateLimitChecker {
      *
      * @param client
      *            the {@link GitHubClient} to check
-     * @param request
-     *            the {@link GitHubRequest} to check against
+     * @param rateLimitTarget
+     *            the {@link RateLimitTarget} to check against
      * @throws IOException
      *             if there is an I/O error
      */
-    void checkRateLimit(GitHubClient client, GitHubRequest request) throws IOException {
-        RateLimitChecker guard = selectChecker(request.rateLimitTarget());
+    void checkRateLimit(GitHubClient client, @Nonnull RateLimitTarget rateLimitTarget) throws IOException {
+        RateLimitChecker guard = selectChecker(rateLimitTarget);
         if (guard == RateLimitChecker.NONE) {
             return;
         }
 
         // For the first rate limit, accept the current limit if a valid one is already present.
-        GHRateLimit rateLimit = client.rateLimit(request.rateLimitTarget());
-        GHRateLimit.Record rateLimitRecord = rateLimit.getRecord(request.rateLimitTarget());
+        GHRateLimit rateLimit = client.rateLimit(rateLimitTarget);
+        GHRateLimit.Record rateLimitRecord = rateLimit.getRecord(rateLimitTarget);
         long waitCount = 0;
         try {
             while (guard.checkRateLimit(rateLimitRecord, waitCount)) {
@@ -133,8 +133,8 @@ class GitHubRateLimitChecker {
                 Thread.sleep(1000);
 
                 // After the first wait, always request a new rate limit from the server.
-                rateLimit = client.getRateLimit(request.rateLimitTarget());
-                rateLimitRecord = rateLimit.getRecord(request.rateLimitTarget());
+                rateLimit = client.getRateLimit(rateLimitTarget);
+                rateLimitRecord = rateLimit.getRecord(rateLimitTarget);
             }
         } catch (InterruptedException e) {
             throw (IOException) new InterruptedIOException(e.getMessage()).initCause(e);
diff --git a/src/main/java/org/kohsuke/github/GitHubRequest.java b/src/main/java/org/kohsuke/github/GitHubRequest.java
index fc94af582..91b64098d 100644
--- a/src/main/java/org/kohsuke/github/GitHubRequest.java
+++ b/src/main/java/org/kohsuke/github/GitHubRequest.java
@@ -1,9 +1,14 @@
 package org.kohsuke.github;
 
 import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.kohsuke.github.connector.GitHubConnectorRequest;
 import org.kohsuke.github.internal.Previews;
 
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
 import java.io.InputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.MalformedURLException;
@@ -12,15 +17,7 @@ import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
 
 import javax.annotation.CheckForNull;
 import javax.annotation.Nonnull;
@@ -37,32 +34,39 @@ import static java.util.Arrays.asList;
  * not specified until late in the building process, so this is still untyped.
  * </p>
  */
-class GitHubRequest {
+public class GitHubRequest implements GitHubConnectorRequest {
+
+    private static final Comparator<String> nullableCaseInsensitiveComparator = Comparator
+            .nullsFirst(String.CASE_INSENSITIVE_ORDER);
 
     private static final List<String> METHODS_WITHOUT_BODY = asList("GET", "DELETE");
     private final List<Entry> args;
-    private final Map<String, String> headers;
+    private final Map<String, List<String>> headers;
     private final Map<String, Object> injectedMappingValues;
     private final String apiUrl;
     private final String urlPath;
     private final String method;
     private final RateLimitTarget rateLimitTarget;
-    private final InputStream body;
+    private final byte[] body;
     private final boolean forceBody;
 
     private final URL url;
 
     private GitHubRequest(@Nonnull List<Entry> args,
-            @Nonnull Map<String, String> headers,
+            @Nonnull Map<String, List<String>> headers,
             @Nonnull Map<String, Object> injectedMappingValues,
             @Nonnull String apiUrl,
             @Nonnull String urlPath,
             @Nonnull String method,
             @Nonnull RateLimitTarget rateLimitTarget,
-            @CheckForNull InputStream body,
+            @CheckForNull byte[] body,
             boolean forceBody) {
         this.args = Collections.unmodifiableList(new ArrayList<>(args));
-        this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(headers));
+        TreeMap<String, List<String>> caseInsensitiveMap = new TreeMap<>(nullableCaseInsensitiveComparator);
+        for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
+            caseInsensitiveMap.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue())));
+        }
+        this.headers = Collections.unmodifiableMap(caseInsensitiveMap);
         this.injectedMappingValues = Collections.unmodifiableMap(new LinkedHashMap<>(injectedMappingValues));
         this.apiUrl = apiUrl;
         this.urlPath = urlPath;
@@ -79,7 +83,7 @@ class GitHubRequest {
      *
      * @return a new {@link Builder}.
      */
-    public static Builder<?> newBuilder() {
+    static Builder<?> newBuilder() {
         return new Builder<>();
     }
 
@@ -127,6 +131,7 @@ class GitHubRequest {
      *
      * @return the request method.
      */
+    @Override
     @Nonnull
     public String method() {
         return method;
@@ -146,8 +151,9 @@ class GitHubRequest {
      * The arguments for this request. Depending on the {@link #method()} and {@code #inBody()} these maybe added to the
      * url or to the request body.
      *
-     * @return the {@link List<Entry>} of arguments
+     * @return the list of arguments
      */
+    @SuppressFBWarnings(value = { "EI_EXPOSE_REP" }, justification = "Already unmodifiable")
     @Nonnull
     public List<Entry> args() {
         return args;
@@ -158,16 +164,35 @@ class GitHubRequest {
      *
      * @return the {@link Map} of headers
      */
+    @Override
+    @SuppressFBWarnings(value = { "EI_EXPOSE_REP" }, justification = "Unmodifiable Map of unmodifiable lists")
     @Nonnull
-    public Map<String, String> headers() {
+    public Map<String, List<String>> allHeaders() {
         return headers;
     }
 
+    /**
+     * Gets the first value of a header field for this request.
+     *
+     * @param name
+     *            the name of the header field.
+     * @return the value of the header field, or {@code null} if the header isn't set.
+     */
+    @CheckForNull
+    public String header(String name) {
+        List<String> values = headers.get(name);
+        if (values != null) {
+            return values.get(0);
+        }
+        return null;
+    }
+
     /**
      * The headers for this request.
      *
      * @return the {@link Map} of headers
      */
+    @SuppressFBWarnings(value = { "EI_EXPOSE_REP" }, justification = "Already unmodifiable")
     @Nonnull
     public Map<String, Object> injectedMappingValues() {
         return injectedMappingValues;
@@ -199,9 +224,9 @@ class GitHubRequest {
      *
      * @return the content type.
      */
-    @Nonnull
+    @Override
     public String contentType() {
-        return headers.get("Content-type");
+        return header("Content-type");
     }
 
     /**
@@ -209,9 +234,10 @@ class GitHubRequest {
      *
      * @return the {@link InputStream}.
      */
+    @Override
     @CheckForNull
     public InputStream body() {
-        return body;
+        return body != null ? new ByteArrayInputStream(body) : null;
     }
 
     /**
@@ -219,6 +245,7 @@ class GitHubRequest {
      *
      * @return the request {@link URL}
      */
+    @Override
     @Nonnull
     public URL url() {
         return url;
@@ -229,7 +256,8 @@ class GitHubRequest {
      *
      * @return true if the arguements should be sent in the body of the request.
      */
-    public boolean inBody() {
+    @Override
+    public boolean hasBody() {
         return forceBody || !METHODS_WITHOUT_BODY.contains(method);
     }
 
@@ -239,7 +267,7 @@ class GitHubRequest {
      *
      * @return a {@link Builder} based on this request.
      */
-    public Builder<?> toBuilder() {
+    Builder<?> toBuilder() {
         return new Builder<>(args,
                 headers,
                 injectedMappingValues,
@@ -253,7 +281,7 @@ class GitHubRequest {
 
     private String buildTailApiUrl() {
         String tailApiUrl = urlPath;
-        if (!inBody() && !args.isEmpty() && tailApiUrl.startsWith("/")) {
+        if (!hasBody() && !args.isEmpty() && tailApiUrl.startsWith("/")) {
             try {
                 StringBuilder argString = new StringBuilder();
                 boolean questionMarkFound = tailApiUrl.indexOf('?') != -1;
@@ -291,7 +319,7 @@ class GitHubRequest {
          * The header values for this request.
          */
         @Nonnull
-        private final Map<String, String> headers;
+        private final Map<String, List<String>> headers;
 
         /**
          * Injected local data map
@@ -316,7 +344,7 @@ class GitHubRequest {
         @Nonnull
         private RateLimitTarget rateLimitTarget;
 
-        private InputStream body;
+        private byte[] body;
         private boolean forceBody;
 
         /**
@@ -324,7 +352,7 @@ class GitHubRequest {
          */
         protected Builder() {
             this(new ArrayList<>(),
-                    new LinkedHashMap<>(),
+                    new TreeMap<>(nullableCaseInsensitiveComparator),
                     new LinkedHashMap<>(),
                     GitHubClient.GITHUB_URL,
                     "/",
@@ -335,16 +363,20 @@ class GitHubRequest {
         }
 
         private Builder(@Nonnull List<Entry> args,
-                @Nonnull Map<String, String> headers,
+                @Nonnull Map<String, List<String>> headers,
                 @Nonnull Map<String, Object> injectedMappingValues,
                 @Nonnull String apiUrl,
                 @Nonnull String urlPath,
                 @Nonnull String method,
                 @Nonnull RateLimitTarget rateLimitTarget,
-                @CheckForNull @WillClose InputStream body,
+                @CheckForNull byte[] body,
                 boolean forceBody) {
             this.args = new ArrayList<>(args);
-            this.headers = new LinkedHashMap<>(headers);
+            TreeMap<String, List<String>> caseInsensitiveMap = new TreeMap<>(nullableCaseInsensitiveComparator);
+            for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
+                caseInsensitiveMap.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+            }
+            this.headers = caseInsensitiveMap;
             this.injectedMappingValues = new LinkedHashMap<>(injectedMappingValues);
             this.apiUrl = apiUrl;
             this.urlPath = urlPath;
@@ -397,7 +429,9 @@ class GitHubRequest {
          * @return the request builder
          */
         public B setHeader(String name, String value) {
-            headers.put(name, value);
+            List<String> field = new ArrayList<>();
+            field.add(value);
+            headers.put(name, field);
             return (B) this;
         }
 
@@ -411,11 +445,13 @@ class GitHubRequest {
          * @return the request builder
          */
         public B withHeader(String name, String value) {
-            String oldValue = headers.get(name);
-            if (!StringUtils.isBlank(oldValue)) {
-                value = oldValue + ", " + value;
+            List<String> field = headers.get(name);
+            if (field == null) {
+                setHeader(name, value);
+            } else {
+                field.add(value);
             }
-            return setHeader(name, value);
+            return (B) this;
         }
 
         /**
@@ -566,8 +602,9 @@ class GitHubRequest {
          *            the body
          * @return the request builder
          */
-        public B with(@WillClose /* later */ InputStream body) {
-            this.body = body;
+        public B with(@WillClose InputStream body) throws IOException {
+            this.body = IOUtils.toByteArray(body);
+            IOUtils.closeQuietly(body);
             return (B) this;
         }
 
@@ -666,7 +703,7 @@ class GitHubRequest {
          * @return the request builder
          */
         public B contentType(String contentType) {
-            this.headers.put("Content-type", contentType);
+            this.setHeader("Content-type", contentType);
             return (B) this;
         }
 
diff --git a/src/main/java/org/kohsuke/github/GitHubResponse.java b/src/main/java/org/kohsuke/github/GitHubResponse.java
index d86dcba19..fdafa1dd0 100644
--- a/src/main/java/org/kohsuke/github/GitHubResponse.java
+++ b/src/main/java/org/kohsuke/github/GitHubResponse.java
@@ -4,21 +4,14 @@ import com.fasterxml.jackson.core.JsonParseException;
 import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.JsonMappingException;
 import org.apache.commons.io.IOUtils;
-import org.kohsuke.github.function.FunctionThrows;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
 
-import java.io.Closeable;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.lang.reflect.Array;
 import java.net.HttpURLConnection;
-import java.net.URL;
 import java.nio.charset.StandardCharsets;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
+import java.util.*;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
@@ -32,7 +25,7 @@ import javax.annotation.Nonnull;
  * </p>
  *
  * @param <T>
- *            the type of the data parsed from the body of a {@link ResponseInfo}.
+ *            the type of the data parsed from the body of a {@link GitHubConnectorResponse}.
  */
 class GitHubResponse<T> {
 
@@ -40,9 +33,6 @@ class GitHubResponse<T> {
 
     private final int statusCode;
 
-    @Nonnull
-    private final GitHubRequest request;
-
     @Nonnull
     private final Map<String, List<String>> headers;
 
@@ -51,22 +41,20 @@ class GitHubResponse<T> {
 
     GitHubResponse(GitHubResponse<T> response, @CheckForNull T body) {
         this.statusCode = response.statusCode();
-        this.request = response.request();
-        this.headers = response.headers();
+        this.headers = response.headers;
         this.body = body;
     }
 
-    GitHubResponse(ResponseInfo responseInfo, @CheckForNull T body) {
-        this.statusCode = responseInfo.statusCode();
-        this.request = responseInfo.request();
-        this.headers = responseInfo.headers();
+    GitHubResponse(GitHubConnectorResponse connectorResponse, @CheckForNull T body) {
+        this.statusCode = connectorResponse.statusCode();
+        this.headers = connectorResponse.allHeaders();
         this.body = body;
     }
 
     /**
-     * Parses a {@link ResponseInfo} body into a new instance of {@link T}.
+     * Parses a {@link GitHubConnectorResponse} body into a new instance of {@link T}.
      *
-     * @param responseInfo
+     * @param connectorResponse
      *            response info to parse.
      * @param type
      *            the type to be constructed.
@@ -77,9 +65,9 @@ class GitHubResponse<T> {
      *             if there is an I/O Exception.
      */
     @CheckForNull
-    static <T> T parseBody(ResponseInfo responseInfo, Class<T> type) throws IOException {
+    static <T> T parseBody(GitHubConnectorResponse connectorResponse, Class<T> type) throws IOException {
 
-        if (responseInfo.statusCode() == HttpURLConnection.HTTP_NO_CONTENT) {
+        if (connectorResponse.statusCode() == HttpURLConnection.HTTP_NO_CONTENT) {
             if (type != null && type.isArray()) {
                 // no content for array should be empty array
                 return type.cast(Array.newInstance(type.getComponentType(), 0));
@@ -89,12 +77,12 @@ class GitHubResponse<T> {
             }
         }
 
-        String data = responseInfo.getBodyAsString();
+        String data = getBodyAsString(connectorResponse);
         try {
             InjectableValues.Std inject = new InjectableValues.Std();
-            inject.addValue(ResponseInfo.class, responseInfo);
+            inject.addValue(GitHubConnectorResponse.class, connectorResponse);
 
-            return GitHubClient.getMappingObjectReader(responseInfo).forType(type).readValue(data);
+            return GitHubClient.getMappingObjectReader(connectorResponse).forType(type).readValue(data);
         } catch (JsonMappingException | JsonParseException e) {
             String message = "Failed to deserialize: " + data;
             LOGGER.log(Level.FINE, message);
@@ -103,9 +91,9 @@ class GitHubResponse<T> {
     }
 
     /**
-     * Parses a {@link ResponseInfo} body into a new instance of {@link T}.
+     * Parses a {@link GitHubConnectorResponse} body into a new instance of {@link T}.
      *
-     * @param responseInfo
+     * @param connectorResponse
      *            response info to parse.
      * @param instance
      *            the object to fill with data parsed from body
@@ -116,11 +104,11 @@ class GitHubResponse<T> {
      *             if there is an I/O Exception.
      */
     @CheckForNull
-    static <T> T parseBody(ResponseInfo responseInfo, T instance) throws IOException {
+    static <T> T parseBody(GitHubConnectorResponse connectorResponse, T instance) throws IOException {
 
-        String data = responseInfo.getBodyAsString();
+        String data = getBodyAsString(connectorResponse);
         try {
-            return GitHubClient.getMappingObjectReader(responseInfo).withValueToUpdate(instance).readValue(data);
+            return GitHubClient.getMappingObjectReader(connectorResponse).withValueToUpdate(instance).readValue(data);
         } catch (JsonMappingException | JsonParseException e) {
             String message = "Failed to deserialize: " + data;
             LOGGER.log(Level.FINE, message);
@@ -129,23 +117,18 @@ class GitHubResponse<T> {
     }
 
     /**
-     * The {@link URL} for this response.
-     *
-     * @return the {@link URL} for this response.
-     */
-    @Nonnull
-    public URL url() {
-        return request.url();
-    }
-
-    /**
-     * The {@link GitHubRequest} for this response.
+     * Gets the body of the response as a {@link String}.
      *
-     * @return the {@link GitHubRequest} for this response.
+     * @return the body of the response as a {@link String}.
+     * @throws IOException
+     *             if an I/O Exception occurs.
+     * @param connectorResponse
      */
     @Nonnull
-    public GitHubRequest request() {
-        return request;
+    static String getBodyAsString(GitHubConnectorResponse connectorResponse) throws IOException {
+        InputStreamReader r = null;
+        r = new InputStreamReader(connectorResponse.bodyStream(), StandardCharsets.UTF_8);
+        return IOUtils.toString(r);
     }
 
     /**
@@ -163,8 +146,8 @@ class GitHubResponse<T> {
      * @return the headers for this response.
      */
     @Nonnull
-    public Map<String, List<String>> headers() {
-        return headers;
+    public List<String> headers(String field) {
+        return headers.get(field);
     }
 
     /**
@@ -175,10 +158,11 @@ class GitHubResponse<T> {
      * @return the value of the header field, or {@code null} if the header isn't set.
      */
     @CheckForNull
-    public String headerField(String name) {
+    public String header(String name) {
         String result = null;
-        if (headers.containsKey(name)) {
-            result = headers.get(name).get(0);
+        List<String> rawResult = headers.get(name);
+        if (rawResult != null) {
+            result = rawResult.get(0);
         }
         return result;
     }
@@ -192,128 +176,4 @@ class GitHubResponse<T> {
         return body;
     }
 
-    /**
-     * Represents a supplier of results that can throw.
-     *
-     * @param <T>
-     *            the type of results supplied by this supplier
-     */
-    @FunctionalInterface
-    interface BodyHandler<T> extends FunctionThrows<ResponseInfo, T, IOException> {
-    }
-
-    /**
-     * Initial response information supplied to a {@link BodyHandler} when a response is initially received and before
-     * the body is processed.
-     */
-    static abstract class ResponseInfo implements Closeable {
-
-        private static final Comparator<String> nullableCaseInsensitiveComparator = Comparator
-                .nullsFirst(String.CASE_INSENSITIVE_ORDER);
-
-        private final int statusCode;
-        @Nonnull
-        private final GitHubRequest request;
-        @Nonnull
-        private final Map<String, List<String>> headers;
-
-        protected ResponseInfo(@Nonnull GitHubRequest request,
-                int statusCode,
-                @Nonnull Map<String, List<String>> headers) {
-            this.request = request;
-            this.statusCode = statusCode;
-
-            // Response header field names must be case-insensitive.
-            TreeMap<String, List<String>> caseInsensitiveMap = new TreeMap<>(nullableCaseInsensitiveComparator);
-            caseInsensitiveMap.putAll(headers);
-
-            this.headers = Collections.unmodifiableMap(caseInsensitiveMap);
-        }
-
-        /**
-         * Gets the value of a header field for this response.
-         *
-         * @param name
-         *            the name of the header field.
-         * @return the value of the header field, or {@code null} if the header isn't set.
-         */
-        @CheckForNull
-        public String headerField(String name) {
-            String result = null;
-            if (headers.containsKey(name)) {
-                result = headers.get(name).get(0);
-            }
-            return result;
-        }
-
-        /**
-         * The response body as an {@link InputStream}.
-         *
-         * @return the response body
-         * @throws IOException
-         *             if an I/O Exception occurs.
-         */
-        abstract InputStream bodyStream() throws IOException;
-
-        /**
-         * The error message for this response.
-         *
-         * @return if there is an error with some error string, that is returned. If not, {@code null}.
-         */
-        abstract String errorMessage();
-
-        /**
-         * The {@link URL} for this response.
-         *
-         * @return the {@link URL} for this response.
-         */
-        @Nonnull
-        public URL url() {
-            return request.url();
-        }
-
-        /**
-         * Gets the {@link GitHubRequest} for this response.
-         *
-         * @return the {@link GitHubRequest} for this response.
-         */
-        @Nonnull
-        public GitHubRequest request() {
-            return request;
-        }
-
-        /**
-         * The status code for this response.
-         *
-         * @return the status code for this response.
-         */
-        public int statusCode() {
-            return statusCode;
-        }
-
-        /**
-         * The headers for this response.
-         *
-         * @return the headers for this response.
-         */
-        @Nonnull
-        public Map<String, List<String>> headers() {
-            return headers;
-        }
-
-        /**
-         * Gets the body of the response as a {@link String}.
-         *
-         * @return the body of the response as a {@link String}.
-         * @throws IOException
-         *             if an I/O Exception occurs.
-         */
-        @Nonnull
-        String getBodyAsString() throws IOException {
-            InputStreamReader r = null;
-            r = new InputStreamReader(this.bodyStream(), StandardCharsets.UTF_8);
-            return IOUtils.toString(r);
-        }
-    }
-
 }
diff --git a/src/main/java/org/kohsuke/github/HttpConnector.java b/src/main/java/org/kohsuke/github/HttpConnector.java
index 0cb92ff40..91471ec7d 100644
--- a/src/main/java/org/kohsuke/github/HttpConnector.java
+++ b/src/main/java/org/kohsuke/github/HttpConnector.java
@@ -12,9 +12,11 @@ import java.net.URL;
  * <p>
  * For example, you can implement this to st custom timeouts.
  *
+ * @deprecated Use {@link org.kohsuke.github.connector.GitHubConnector} instead.
  * @author Kohsuke Kawaguchi
  */
 @FunctionalInterface
+@Deprecated
 public interface HttpConnector {
     /**
      * Opens a connection to the given URL.
diff --git a/src/main/java/org/kohsuke/github/RateLimitHandler.java b/src/main/java/org/kohsuke/github/RateLimitHandler.java
index 20c3390b0..ce7e7be6c 100644
--- a/src/main/java/org/kohsuke/github/RateLimitHandler.java
+++ b/src/main/java/org/kohsuke/github/RateLimitHandler.java
@@ -1,9 +1,13 @@
 package org.kohsuke.github;
 
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+
 import java.io.IOException;
 import java.io.InterruptedIOException;
 import java.net.HttpURLConnection;
 
+import javax.annotation.Nonnull;
+
 /**
  * Pluggable strategy to determine what to do when the API rate limit is reached.
  *
@@ -12,6 +16,30 @@ import java.net.HttpURLConnection;
  * @see AbuseLimitHandler
  */
 public abstract class RateLimitHandler {
+
+    /**
+     * Called when the library encounters HTTP error indicating that the API rate limit has been exceeded.
+     *
+     * <p>
+     * Any exception thrown from this method will cause the request to fail, and the caller of github-api will receive
+     * an exception. If this method returns normally, another request will be attempted. For that to make sense, the
+     * implementation needs to wait for some time.
+     *
+     * @param connectorResponse
+     *            Response information for this request.
+     *
+     * @throws IOException
+     *             the io exception
+     * @see <a href="https://developer.github.com/v3/#rate-limiting">API documentation from GitHub</a>
+     */
+    public void onError(@Nonnull GitHubConnectorResponse connectorResponse) throws IOException {
+        GHIOException e = new HttpException("Rate limit violation",
+                connectorResponse.statusCode(),
+                connectorResponse.header("Status"),
+                connectorResponse.request().url().toString()).withResponseHeaderFields(connectorResponse.allHeaders());
+        onError(e, connectorResponse.toHttpURLConnection());
+    }
+
     /**
      * Called when the library encounters HTTP error indicating that the API rate limit is reached.
      *
@@ -29,7 +57,9 @@ public abstract class RateLimitHandler {
      *             the io exception
      * @see <a href="https://developer.github.com/v3/#rate-limiting">API documentation from GitHub</a>
      */
-    public abstract void onError(IOException e, HttpURLConnection uc) throws IOException;
+    @Deprecated
+    public void onError(IOException e, HttpURLConnection uc) throws IOException {
+    }
 
     /**
      * Block until the API rate limit is reset. Useful for long-running batch processing.
diff --git a/src/main/java/org/kohsuke/github/Requester.java b/src/main/java/org/kohsuke/github/Requester.java
index 924f3b731..2f3154ec0 100644
--- a/src/main/java/org/kohsuke/github/Requester.java
+++ b/src/main/java/org/kohsuke/github/Requester.java
@@ -25,6 +25,8 @@ package org.kohsuke.github;
 
 import edu.umd.cs.findbugs.annotations.NonNull;
 import org.apache.commons.io.IOUtils;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+import org.kohsuke.github.function.BodyHandler;
 import org.kohsuke.github.function.InputStreamFunction;
 
 import java.io.ByteArrayInputStream;
@@ -57,7 +59,7 @@ class Requester extends GitHubRequest.Builder<Requester> {
     public void send() throws IOException {
         // Send expects there to be some body response, but doesn't care what it is.
         // If there isn't a body, this will throw.
-        client.sendRequest(this, (responseInfo) -> responseInfo.getBodyAsString());
+        client.sendRequest(this, (connectorResponse) -> GitHubResponse.getBodyAsString(connectorResponse));
     }
 
     /**
@@ -72,7 +74,8 @@ class Requester extends GitHubRequest.Builder<Requester> {
      *             if the server returns 4xx/5xx responses.
      */
     public <T> T fetch(@Nonnull Class<T> type) throws IOException {
-        return client.sendRequest(this, (responseInfo) -> GitHubResponse.parseBody(responseInfo, type)).body();
+        return client.sendRequest(this, (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, type))
+                .body();
     }
 
     /**
@@ -87,7 +90,8 @@ class Requester extends GitHubRequest.Builder<Requester> {
      *             the io exception
      */
     public <T> T fetchInto(@Nonnull T existingInstance) throws IOException {
-        return client.sendRequest(this, (responseInfo) -> GitHubResponse.parseBody(responseInfo, existingInstance))
+        return client
+                .sendRequest(this, (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, existingInstance))
                 .body();
     }
 
@@ -111,17 +115,17 @@ class Requester extends GitHubRequest.Builder<Requester> {
      *             the io exception
      */
     public <T> T fetchStream(@Nonnull InputStreamFunction<T> handler) throws IOException {
-        return client.sendRequest(this, (responseInfo) -> handler.apply(responseInfo.bodyStream())).body();
+        return client.sendRequest(this, (connectorResponse) -> handler.apply(connectorResponse.bodyStream())).body();
     }
 
     /**
      * Helper function to make it easy to pull streams.
      *
      * Copies an input stream to an in-memory input stream. The performance on this is not great but
-     * {@link GitHubResponse.ResponseInfo#bodyStream()} is closed at the end of every call to
-     * {@link GitHubClient#sendRequest(GitHubRequest, GitHubResponse.BodyHandler)}, so any reads to the original input
-     * stream must be completed before then. There are a number of deprecated methods that return {@link InputStream}.
-     * This method keeps all of them using the same code path.
+     * {@link GitHubConnectorResponse#bodyStream()} is closed at the end of every call to
+     * {@link GitHubClient#sendRequest(GitHubRequest, BodyHandler)}, so any reads to the original input stream must be
+     * completed before then. There are a number of deprecated methods that return {@link InputStream}. This method
+     * keeps all of them using the same code path.
      *
      * @param inputStream
      *            the input stream to be copied
diff --git a/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java b/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java
index 075475ff1..31af483eb 100644
--- a/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java
+++ b/src/main/java/org/kohsuke/github/authorization/OrgAppInstallationAuthorizationProvider.java
@@ -19,7 +19,7 @@ public class OrgAppInstallationAuthorizationProvider extends GitHub.DependentAut
 
     private final String organizationName;
 
-    private String latestToken;
+    private String authorization;
 
     @Nonnull
     private Instant validUntil = Instant.MIN;
@@ -44,19 +44,20 @@ public class OrgAppInstallationAuthorizationProvider extends GitHub.DependentAut
     @Override
     public String getEncodedAuthorization() throws IOException {
         synchronized (this) {
-            if (latestToken == null || Instant.now().isAfter(this.validUntil)) {
-                refreshToken();
+            if (authorization == null || Instant.now().isAfter(this.validUntil)) {
+                String token = refreshToken();
+                authorization = String.format("token %s", token);
             }
-            return String.format("token %s", latestToken);
+            return authorization;
         }
     }
 
-    private void refreshToken() throws IOException {
+    private String refreshToken() throws IOException {
         GitHub gitHub = this.gitHub();
         GHAppInstallation installationByOrganization = gitHub.getApp()
                 .getInstallationByOrganization(this.organizationName);
         GHAppInstallationToken ghAppInstallationToken = installationByOrganization.createToken().create();
         this.validUntil = ghAppInstallationToken.getExpiresAt().toInstant().minus(Duration.ofMinutes(5));
-        this.latestToken = Objects.requireNonNull(ghAppInstallationToken.getToken());
+        return Objects.requireNonNull(ghAppInstallationToken.getToken());
     }
 }
diff --git a/src/main/java/org/kohsuke/github/connector/GitHubConnector.java b/src/main/java/org/kohsuke/github/connector/GitHubConnector.java
new file mode 100644
index 000000000..cbff11dbc
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/connector/GitHubConnector.java
@@ -0,0 +1,52 @@
+package org.kohsuke.github.connector;
+
+import org.kohsuke.github.HttpConnector;
+import org.kohsuke.github.internal.DefaultGitHubConnector;
+import org.kohsuke.github.internal.GitHubConnectorHttpConnectorAdapter;
+
+import java.io.IOException;
+
+/**
+ * Pluggability for customizing HTTP request behaviors or using altogether different library.
+ *
+ * @author Liam Newman
+ */
+@FunctionalInterface
+public interface GitHubConnector {
+
+    /**
+     *
+     * Implementers of {@link GitHubConnector#send(GitHubConnectorRequest)} process the information from a
+     * {@link GitHubConnectorRequest} to open an HTTP connection and retrieve a response. They then return a class that
+     * extends {@link GitHubConnectorResponse} for their response data.
+     *
+     * Clients should not implement their own {@link GitHubConnectorRequest}. The {@link GitHubConnectorRequest}
+     * provided by the caller of {@link GitHubConnector#send(GitHubConnectorRequest)} should be passed to the
+     * constructor of {@link GitHubConnectorResponse}.
+     *
+     * @param connectorRequest
+     *            the request data to be sent.
+     * @return a GitHubConnectorResponse for the request
+     * @throws IOException
+     *             if there is an I/O error
+     */
+    GitHubConnectorResponse send(GitHubConnectorRequest connectorRequest) throws IOException;
+
+    /**
+     * Default implementation used when connector is not set by user.
+     *
+     * This calls {@link DefaultGitHubConnector#create} to get the default connector instance. The output of that method
+     * may differ depending on Java version and system properties.
+     *
+     * @see DefaultGitHubConnector#create
+     */
+    GitHubConnector DEFAULT = DefaultGitHubConnector.create();
+
+    /**
+     * Stub implementation that is always off-line.
+     *
+     * This connector currently uses {@link GitHubConnectorHttpConnectorAdapter} to maintain backward compatibility as
+     * much as possible.
+     */
+    GitHubConnector OFFLINE = new GitHubConnectorHttpConnectorAdapter(HttpConnector.OFFLINE);
+}
diff --git a/src/main/java/org/kohsuke/github/connector/GitHubConnectorRequest.java b/src/main/java/org/kohsuke/github/connector/GitHubConnectorRequest.java
new file mode 100644
index 000000000..1b6e22f53
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/connector/GitHubConnectorRequest.java
@@ -0,0 +1,82 @@
+package org.kohsuke.github.connector;
+
+import java.io.InputStream;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+
+/**
+ * A request passed to {@link GitHubConnector#send(GitHubConnectorRequest)} to get a {@link GitHubConnectorResponse}.\
+ *
+ * Implementers of {@link GitHubConnector#send(GitHubConnectorRequest)} process the information from a
+ * {@link GitHubConnectorRequest} to open an HTTP connection and retrieve a response. They then return a class that
+ * extends {@link GitHubConnectorResponse} for their response data.
+ *
+ * Clients should not implement their own {@link GitHubConnectorRequest}. The {@link GitHubConnectorRequest} provided by
+ * the caller of {@link GitHubConnector#send(GitHubConnectorRequest)} should be passed to the constructor of
+ * {@link GitHubConnectorResponse}.
+ */
+public interface GitHubConnectorRequest {
+
+    /**
+     * The request method for this request.
+     *
+     * For example, {@code GET} or {@code PATCH}.
+     *
+     * @return the request method.
+     */
+    @Nonnull
+    String method();
+
+    /**
+     * All request headers for this request.
+     *
+     * @return a map of all headers.
+     */
+    @Nonnull
+    Map<String, List<String>> allHeaders();
+
+    /**
+     * Gets the value contained in a header field.
+     *
+     * @param name
+     *            the name of the field.
+     * @return the value contained in that field, or {@code null} if not present.
+     */
+    @CheckForNull
+    String header(String name);
+
+    /**
+     * Get the content type for the body of this request.
+     *
+     * @return the content type string for the body of this request.
+     */
+    @CheckForNull
+    String contentType();
+
+    /**
+     * Gets the request body as an InputStream.
+     *
+     * @return the request body as an InputStream.
+     */
+    @CheckForNull
+    InputStream body();
+
+    /**
+     * Gets the url for this request.
+     *
+     * @return the url for this request.
+     */
+    @Nonnull
+    URL url();
+
+    /**
+     * Gets whether the request has information in {@link #body()} that needs to be sent.
+     *
+     * @return true, if the body is not null. Otherwise, false.
+     */
+    boolean hasBody();
+}
diff --git a/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponse.java b/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponse.java
new file mode 100644
index 000000000..dedc8495f
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponse.java
@@ -0,0 +1,122 @@
+package org.kohsuke.github.connector;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.util.*;
+
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+
+/**
+ * Response information supplied when a response is received and before the body is processed.
+ * <p>
+ * Instances of this class are closed once the response is done being processed. This means that contents of the body
+ * stream will not be readable after a call is completed. Status code, response headers, and request details will still
+ * be readable but it is recommended that consumers copy any information they need rather than retaining a reference.
+ */
+public abstract class GitHubConnectorResponse implements Closeable {
+
+    private static final Comparator<String> nullableCaseInsensitiveComparator = Comparator
+            .nullsFirst(String.CASE_INSENSITIVE_ORDER);
+
+    private final int statusCode;
+
+    @Nonnull
+    private final GitHubConnectorRequest request;
+    @Nonnull
+    private final Map<String, List<String>> headers;
+
+    protected GitHubConnectorResponse(@Nonnull GitHubConnectorRequest request,
+            int statusCode,
+            @Nonnull Map<String, List<String>> headers) {
+        this.request = request;
+        this.statusCode = statusCode;
+
+        // Response header field names must be case-insensitive.
+        TreeMap<String, List<String>> caseInsensitiveMap = new TreeMap<>(nullableCaseInsensitiveComparator);
+        for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
+            caseInsensitiveMap.put(entry.getKey(), Collections.unmodifiableList(new ArrayList<>(entry.getValue())));
+        }
+        this.headers = Collections.unmodifiableMap(caseInsensitiveMap);
+    }
+
+    /**
+     * Get this response as a {@link HttpURLConnection}.
+     *
+     * @return an object that implements at least the response related methods of {@link HttpURLConnection}.
+     */
+    @Deprecated
+    @Nonnull
+    public HttpURLConnection toHttpURLConnection() {
+        HttpURLConnection connection;
+        connection = new GitHubConnectorResponseHttpUrlConnectionAdapter(this);
+        return connection;
+    }
+
+    /**
+     * Gets the value of a header field for this response.
+     *
+     * @param name
+     *            the name of the header field.
+     * @return the value of the header field, or {@code null} if the header isn't set.
+     */
+    @CheckForNull
+    public String header(String name) {
+        String result = null;
+        if (headers.containsKey(name)) {
+            result = headers.get(name).get(0);
+        }
+        return result;
+    }
+
+    /**
+     * The response body as an {@link InputStream}.
+     *
+     * @return the response body
+     * @throws IOException
+     *             if an I/O Exception occurs.
+     */
+    public abstract InputStream bodyStream() throws IOException;
+
+    /**
+     * The error message for this response.
+     *
+     * @return if there is an error with some error string, that is returned. If not, {@code null}.
+     */
+    public abstract String errorMessage();
+
+    /**
+     * Gets the {@link GitHubConnectorRequest} for this response.
+     *
+     * @return the {@link GitHubConnectorRequest} for this response.
+     */
+    @Nonnull
+    public GitHubConnectorRequest request() {
+        return request;
+    }
+
+    /**
+     * The status code for this response.
+     *
+     * @return the status code for this response.
+     */
+    public int statusCode() {
+        return statusCode;
+    }
+
+    /**
+     * The headers for this response.
+     *
+     * @return the headers for this response.
+     */
+    @Nonnull
+    @SuppressFBWarnings(value = { "EI_EXPOSE_REP" }, justification = "Unmodifiable map of unmodifiable lists")
+    public Map<String, List<String>> allHeaders() {
+        return headers;
+    }
+
+}
diff --git a/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponseHttpUrlConnectionAdapter.java b/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponseHttpUrlConnectionAdapter.java
new file mode 100644
index 000000000..1b4b0d639
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/connector/GitHubConnectorResponseHttpUrlConnectionAdapter.java
@@ -0,0 +1,245 @@
+package org.kohsuke.github.connector;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import java.security.Permission;
+import java.util.*;
+
+class GitHubConnectorResponseHttpUrlConnectionAdapter extends HttpURLConnection {
+
+    private final GitHubConnectorResponse connectorResponse;
+
+    public GitHubConnectorResponseHttpUrlConnectionAdapter(GitHubConnectorResponse connectorResponse) {
+        super(connectorResponse.request().url());
+        this.connected = true;
+        this.connectorResponse = connectorResponse;
+    }
+
+    @Override
+    public String getHeaderFieldKey(int n) {
+        List<String> keys = new ArrayList<>(connectorResponse.allHeaders().keySet());
+        return keys.get(n);
+    }
+
+    @Override
+    public String getHeaderField(int n) {
+        return connectorResponse.header(getHeaderFieldKey(n));
+    }
+
+    @Override
+    public void setInstanceFollowRedirects(boolean followRedirects) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean getInstanceFollowRedirects() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getRequestMethod() {
+        return connectorResponse.request().method();
+    }
+
+    @Override
+    public int getResponseCode() throws IOException {
+        return connectorResponse.statusCode();
+    }
+
+    @Override
+    public String getResponseMessage() throws IOException {
+        return connectorResponse.header("Status");
+    }
+
+    @Override
+    public long getHeaderFieldDate(String name, long defaultValue) {
+        String dateString = getHeaderField(name);
+        try {
+            return Date.parse(dateString);
+        } catch (Exception e) {
+        }
+        return defaultValue;
+    }
+
+    @Override
+    public Permission getPermission() throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public InputStream getErrorStream() {
+        return new ByteArrayInputStream(connectorResponse.errorMessage().getBytes(StandardCharsets.UTF_8));
+    }
+
+    @Override
+    public void setConnectTimeout(int timeout) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getConnectTimeout() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void setReadTimeout(int timeout) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getReadTimeout() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getContentLength() {
+        long l = getContentLengthLong();
+        if (l > Integer.MAX_VALUE)
+            return -1;
+        return (int) l;
+    }
+
+    @Override
+    public long getContentLengthLong() {
+        return getHeaderFieldLong("content-length", -1);
+    }
+
+    @Override
+    public String getContentType() {
+        return connectorResponse.header("content-type");
+    }
+
+    @Override
+    public String getContentEncoding() {
+        return connectorResponse.header("content-encoding");
+    }
+
+    @Override
+    public long getExpiration() {
+        return getHeaderFieldDate("expires", 0);
+    }
+
+    @Override
+    public long getDate() {
+        return getHeaderFieldDate("date", 0);
+    }
+
+    @Override
+    public long getLastModified() {
+        return getHeaderFieldDate("last-modified", 0);
+    }
+
+    @Override
+    public String getHeaderField(String name) {
+        return connectorResponse.header(name);
+    }
+
+    @Override
+    public Map<String, List<String>> getHeaderFields() {
+        return connectorResponse.allHeaders();
+    }
+
+    @Override
+    public int getHeaderFieldInt(String name, int defaultValue) {
+        String value = getHeaderField(name);
+        try {
+            return Integer.parseInt(value);
+        } catch (Exception e) {
+        }
+        return defaultValue;
+    }
+
+    @Override
+    public long getHeaderFieldLong(String name, long defaultValue) {
+        String value = getHeaderField(name);
+        try {
+            return Long.parseLong(value);
+        } catch (Exception e) {
+        }
+        return defaultValue;
+    }
+
+    @Override
+    public Object getContent() throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Object getContent(Class[] classes) throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public InputStream getInputStream() throws IOException {
+        return connectorResponse.bodyStream();
+    }
+
+    @Override
+    public OutputStream getOutputStream() throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String toString() {
+        return this.getClass().getName() + ": " + connectorResponse.toString();
+    }
+
+    @Override
+    public boolean getDoInput() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean getDoOutput() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean getUseCaches() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getIfModifiedSince() {
+        return getHeaderFieldDate("If-Modified-Since", 0);
+    }
+
+    @Override
+    public void setDefaultUseCaches(boolean defaultusecaches) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getRequestProperty(String key) {
+        return connectorResponse.request().header(key);
+    }
+
+    @Override
+    public boolean getAllowUserInteraction() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean getDefaultUseCaches() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void disconnect() {
+        // ignored
+    }
+
+    @Override
+    public boolean usingProxy() {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void connect() throws IOException {
+        // no op
+    }
+}
diff --git a/src/main/java/org/kohsuke/github/extras/OkHttp3Connector.java b/src/main/java/org/kohsuke/github/extras/OkHttp3Connector.java
index 6117a892a..24819a7a4 100644
--- a/src/main/java/org/kohsuke/github/extras/OkHttp3Connector.java
+++ b/src/main/java/org/kohsuke/github/extras/OkHttp3Connector.java
@@ -4,6 +4,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import okhttp3.OkHttpClient;
 import okhttp3.OkUrlFactory;
 import org.kohsuke.github.HttpConnector;
+import org.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector;
 
 import java.io.IOException;
 import java.net.HttpURLConnection;
@@ -17,7 +18,7 @@ import java.net.URL;
  *
  * @author Roberto Tyley
  * @author Kohsuke Kawaguchi
- * @see org.kohsuke.github.extras.okhttp3.OkHttpConnector
+ * @see OkHttpGitHubConnector
  */
 @Deprecated
 @SuppressFBWarnings(value = { "EI_EXPOSE_REP2" }, justification = "Deprecated")
@@ -31,7 +32,7 @@ public class OkHttp3Connector implements HttpConnector {
      *            the url factory
      */
     /*
-     * @see org.kohsuke.github.extras.okhttp3.OkHttpConnector
+     * @see org.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector
      */
     @Deprecated
     public OkHttp3Connector(OkUrlFactory urlFactory) {
diff --git a/src/main/java/org/kohsuke/github/extras/OkHttpConnector.java b/src/main/java/org/kohsuke/github/extras/OkHttpConnector.java
index cf7fd0bc9..4b9b5bb36 100644
--- a/src/main/java/org/kohsuke/github/extras/OkHttpConnector.java
+++ b/src/main/java/org/kohsuke/github/extras/OkHttpConnector.java
@@ -5,6 +5,7 @@ import com.squareup.okhttp.ConnectionSpec;
 import com.squareup.okhttp.OkHttpClient;
 import com.squareup.okhttp.OkUrlFactory;
 import org.kohsuke.github.HttpConnector;
+import org.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector;
 
 import java.io.IOException;
 import java.net.HttpURLConnection;
@@ -26,9 +27,8 @@ import javax.net.ssl.SSLSocketFactory;
  *
  * @author Roberto Tyley
  * @author Kohsuke Kawaguchi
- * @deprecated This class depends on an unsupported version of OkHttp. Switch to
- *             {@link org.kohsuke.github.extras.okhttp3.OkHttpConnector}.
- * @see org.kohsuke.github.extras.okhttp3.OkHttpConnector
+ * @deprecated This class depends on an unsupported version of OkHttp. Switch to {@link OkHttpGitHubConnector}.
+ * @see OkHttpGitHubConnector
  */
 @Deprecated
 public class OkHttpConnector implements HttpConnector {
diff --git a/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java b/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java
index 1643b65d7..e16652402 100644
--- a/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java
+++ b/src/main/java/org/kohsuke/github/extras/authorization/JWTTokenProvider.java
@@ -34,7 +34,7 @@ public class JWTTokenProvider implements AuthorizationProvider {
     @Nonnull
     private Instant validUntil = Instant.MIN;
 
-    private String token;
+    private String authorization;
 
     /**
      * The identifier for the application
@@ -61,13 +61,30 @@ public class JWTTokenProvider implements AuthorizationProvider {
     @Override
     public String getEncodedAuthorization() throws IOException {
         synchronized (this) {
-            if (Instant.now().isAfter(validUntil)) {
-                token = refreshJWT();
+            if (isNotValid()) {
+                String token = refreshJWT();
+                authorization = String.format("Bearer %s", token);;
             }
-            return String.format("Bearer %s", token);
+            return authorization;
         }
     }
 
+    /**
+     * Indicates whether the token considered valid.
+     *
+     * <p>
+     * This is not the same as whether the token is expired. The token is considered not valid before it actually
+     * expires to prevent access denied errors.
+     *
+     * <p>
+     * Made internal for testing
+     *
+     * @return false if the the token has been refreshed within the required window, otherwise true
+     */
+    boolean isNotValid() {
+        return Instant.now().isAfter(validUntil);
+    }
+
     /**
      * Convert a PKCS#8 formatted private key in string format into a java PrivateKey
      *
diff --git a/src/main/java/org/kohsuke/github/extras/okhttp3/ObsoleteUrlFactory.java b/src/main/java/org/kohsuke/github/extras/okhttp3/ObsoleteUrlFactory.java
index 8ce00d87e..bc0bc5857 100644
--- a/src/main/java/org/kohsuke/github/extras/okhttp3/ObsoleteUrlFactory.java
+++ b/src/main/java/org/kohsuke/github/extras/okhttp3/ObsoleteUrlFactory.java
@@ -80,6 +80,7 @@ import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
  * code will be shorter, easier to read, and you'll be able to use interceptors.
  */
 @SuppressFBWarnings(value = { "EI_EXPOSE_REP", "EI_EXPOSE_REP2" }, justification = "Deprecated external code")
+@Deprecated
 public final class ObsoleteUrlFactory implements URLStreamHandlerFactory, Cloneable {
     static final String SELECTED_PROTOCOL = "ObsoleteUrlFactory-Selected-Protocol";
 
diff --git a/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpConnector.java b/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpConnector.java
index 823b2a09c..abcfd15bc 100644
--- a/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpConnector.java
+++ b/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpConnector.java
@@ -20,7 +20,9 @@ import java.util.concurrent.TimeUnit;
  *
  * @author Liam Newman
  * @author Kohsuke Kawaguchi
+ * @deprecated Use OkHttpGitHubConnector instead.
  */
+@Deprecated
 public class OkHttpConnector implements HttpConnector {
     private static final String HEADER_NAME = "Cache-Control";
     private final String maxAgeHeaderValue;
diff --git a/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnector.java b/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnector.java
new file mode 100644
index 000000000..f4d4e9688
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnector.java
@@ -0,0 +1,206 @@
+package org.kohsuke.github.extras.okhttp3;
+
+import okhttp3.*;
+import org.apache.commons.io.IOUtils;
+import org.kohsuke.github.*;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.connector.GitHubConnectorRequest;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+
+import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+import java.util.zip.GZIPInputStream;
+
+import javax.annotation.Nonnull;
+
+import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
+import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
+import static java.util.logging.Level.FINER;
+
+/**
+ * {@link HttpConnector} for {@link OkHttpClient}.
+ * <p>
+ * Unlike {@link #DEFAULT}, OkHttp does response caching. Making a conditional request against GitHubAPI and receiving a
+ * 304 response does not count against the rate limit. See http://developer.github.com/v3/#conditional-requests
+ *
+ * @author Liam Newman
+ * @author Kohsuke Kawaguchi
+ */
+public class OkHttpGitHubConnector implements GitHubConnector {
+    private static final String HEADER_NAME = "Cache-Control";
+    private final String maxAgeHeaderValue;
+
+    private final OkHttpClient client;
+
+    /**
+     * Instantiates a new Ok http connector.
+     *
+     * @param client
+     *            the client
+     */
+    public OkHttpGitHubConnector(OkHttpClient client) {
+        this(client, 0);
+    }
+
+    /**
+     * Instantiates a new Ok http connector.
+     *
+     * @param client
+     *            the client
+     * @param cacheMaxAge
+     *            the cache max age
+     */
+    public OkHttpGitHubConnector(OkHttpClient client, int cacheMaxAge) {
+
+        OkHttpClient.Builder builder = client.newBuilder();
+
+        builder.connectionSpecs(TlsConnectionSpecs());
+        this.client = builder.build();
+        if (cacheMaxAge >= 0 && this.client != null && this.client.cache() != null) {
+            maxAgeHeaderValue = new CacheControl.Builder().maxAge(cacheMaxAge, TimeUnit.SECONDS).build().toString();
+        } else {
+            maxAgeHeaderValue = null;
+        }
+    }
+
+    @Override
+    public GitHubConnectorResponse send(GitHubConnectorRequest request) throws IOException {
+        Request.Builder builder = new Request.Builder().url(request.url());
+        if (maxAgeHeaderValue != null && request.header(HEADER_NAME) == null) {
+            // By default OkHttp honors max-age, meaning it will use local cache
+            // without checking the network within that timeframe.
+            // However, that can result in stale data being returned during that time so
+            // we force network-based checking no matter how often the query is made.
+            // OkHttp still automatically does ETag checking and returns cached data when
+            // GitHub reports 304, but those do not count against rate limit.
+            builder.header(HEADER_NAME, maxAgeHeaderValue);
+        }
+
+        for (Map.Entry<String, List<String>> e : request.allHeaders().entrySet()) {
+            List<String> v = e.getValue();
+            if (v != null) {
+                builder.addHeader(e.getKey(), String.join(", ", v));
+            }
+        }
+
+        RequestBody body = null;
+        if (request.hasBody()) {
+            body = RequestBody.create(IOUtils.toByteArray(request.body()));
+        }
+        builder.method(request.method(), body);
+        Request okhttpRequest = builder.build();
+        Response okhttpResponse = client.newCall(okhttpRequest).execute();
+
+        return new OkHttpGitHubConnectorResponse(request, okhttpResponse);
+    }
+
+    /** Returns connection spec with TLS v1.2 in it */
+    private List<ConnectionSpec> TlsConnectionSpecs() {
+        return Arrays.asList(ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT);
+    }
+
+    /**
+     * Initial response information when a response is initially received and before the body is processed.
+     *
+     * Implementation specific to {@link okhttp3.Response}.
+     */
+    static class OkHttpGitHubConnectorResponse extends GitHubConnectorResponse {
+
+        private boolean bodyBytesRead = false;
+        private byte[] bodyBytes = null;
+
+        @Nonnull
+        private final Response response;
+
+        OkHttpGitHubConnectorResponse(@Nonnull GitHubConnectorRequest request, @Nonnull Response response) {
+            super(request, response.code(), response.headers().toMultimap());
+            this.response = response;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public InputStream bodyStream() throws IOException {
+            if (response.code() >= HTTP_BAD_REQUEST) {
+                if (response.code() == HTTP_NOT_FOUND) {
+                    throw new FileNotFoundException(request().url().toString());
+                } else {
+                    throw new HttpException(errorMessage(),
+                            response.code(),
+                            response.message(),
+                            request().url().toString());
+                }
+            }
+
+            readBodyBytes();
+            InputStream stream = bodyBytes == null ? null : new ByteArrayInputStream(bodyBytes);
+            return stream;
+        }
+
+        private void readBodyBytes() throws IOException {
+            synchronized (this) {
+                if (!bodyBytesRead) {
+                    try (ResponseBody body = response.body()) {
+                        if (body != null) {
+                            try (InputStream stream = wrapStream(body.byteStream())) {
+                                if (stream != null) {
+                                    bodyBytes = IOUtils.toByteArray(stream);
+                                }
+                            }
+                        }
+                    }
+                    bodyBytesRead = true;
+                }
+
+            }
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public String errorMessage() {
+            String result = null;
+            try {
+                if (!response.isSuccessful()) {
+                    readBodyBytes();
+                    result = bodyBytes == null ? null : new String(bodyBytes, StandardCharsets.UTF_8);
+                }
+            } catch (Exception e) {
+                LOGGER.log(FINER, "Ignored exception get error message", e);
+            }
+            return result;
+        }
+
+        /**
+         * Handles the "Content-Encoding" header.
+         *
+         * @param stream
+         *            the stream to possibly wrap
+         *
+         */
+        private InputStream wrapStream(InputStream stream) throws IOException {
+            String encoding = header("Content-Encoding");
+            if (encoding == null || stream == null)
+                return stream;
+            if (encoding.equals("gzip"))
+                return new GZIPInputStream(stream);
+
+            throw new UnsupportedOperationException("Unexpected Content-Encoding: " + encoding);
+        }
+
+        @Override
+        public void close() throws IOException {
+            response.close();
+        }
+
+        private static final Logger LOGGER = Logger.getLogger(OkHttpGitHubConnector.class.getName());
+    }
+}
diff --git a/src/main/java/org/kohsuke/github/function/BodyHandler.java b/src/main/java/org/kohsuke/github/function/BodyHandler.java
new file mode 100644
index 000000000..0083dcd52
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/function/BodyHandler.java
@@ -0,0 +1,15 @@
+package org.kohsuke.github.function;
+
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+
+import java.io.IOException;
+
+/**
+ * Represents a supplier of results that can throw.
+ *
+ * @param <T>
+ *            the type of results supplied by this supplier
+ */
+@FunctionalInterface
+public interface BodyHandler<T> extends FunctionThrows<GitHubConnectorResponse, T, IOException> {
+}
diff --git a/src/main/java/org/kohsuke/github/internal/DefaultGitHubConnector.java b/src/main/java/org/kohsuke/github/internal/DefaultGitHubConnector.java
new file mode 100644
index 000000000..c355c5449
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/internal/DefaultGitHubConnector.java
@@ -0,0 +1,53 @@
+package org.kohsuke.github.internal;
+
+import okhttp3.OkHttpClient;
+import org.kohsuke.github.HttpConnector;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.extras.okhttp3.OkHttpConnector;
+import org.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector;
+
+/**
+ * Internal class that selects what kind of {@link GitHubConnector} will be the default.
+ *
+ * Allow behavior to be changed for different version of Java, such as supporting Java 11 HttpClient.
+ */
+public final class DefaultGitHubConnector {
+
+    private DefaultGitHubConnector() {
+    }
+
+    /**
+     * Creates a {@link GitHubConnector} that will be used as the default connector.
+     *
+     * This method currently defaults to returning an instance of {@link GitHubConnectorHttpConnectorAdapter}. This
+     * preserves backward compatibility with {@link HttpConnector}.
+     *
+     * <p>
+     * For testing purposes, the system property {@code test.github.connector} can be set to change the default.
+     * Possible values: {@code default}, {@code okhttp}, {@code httpconnector}.
+     *
+     * <p>
+     * Should only be called to set {@link GitHubConnector#DEFAULT}.
+     *
+     * @return a GitHubConnector
+     */
+    public static GitHubConnector create() {
+        String defaultConnectorProperty = System.getProperty("test.github.connector", "default");
+        return create(defaultConnectorProperty);
+    }
+
+    static GitHubConnector create(String defaultConnectorProperty) {
+
+        if (defaultConnectorProperty.equalsIgnoreCase("okhttp")) {
+            return new OkHttpGitHubConnector(new OkHttpClient.Builder().build());
+        } else if (defaultConnectorProperty.equalsIgnoreCase("okhttpconnector")) {
+            return new GitHubConnectorHttpConnectorAdapter(new OkHttpConnector(new OkHttpClient.Builder().build()));
+        } else if (defaultConnectorProperty.equalsIgnoreCase("urlconnection")
+                || defaultConnectorProperty.equalsIgnoreCase("default")) {
+            return new GitHubConnectorHttpConnectorAdapter(HttpConnector.DEFAULT);
+        } else {
+            throw new IllegalStateException(
+                    "Property 'test.github.connector' must reference a valid built-in connector - okhttp, okhttpconnector, urlconnection, or default.");
+        }
+    }
+}
diff --git a/src/main/java/org/kohsuke/github/internal/GitHubConnectorHttpConnectorAdapter.java b/src/main/java/org/kohsuke/github/internal/GitHubConnectorHttpConnectorAdapter.java
new file mode 100644
index 000000000..54c736afd
--- /dev/null
+++ b/src/main/java/org/kohsuke/github/internal/GitHubConnectorHttpConnectorAdapter.java
@@ -0,0 +1,268 @@
+package org.kohsuke.github.internal;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.apache.commons.io.IOUtils;
+import org.kohsuke.github.*;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.connector.GitHubConnectorRequest;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+
+import java.io.ByteArrayInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+import java.util.zip.GZIPInputStream;
+
+import javax.annotation.Nonnull;
+
+import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
+import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
+import static java.util.logging.Level.*;
+
+/**
+ * Adapts an HttpConnector to be usable as GitHubConnector.
+ *
+ * For internal use only.
+ */
+public final class GitHubConnectorHttpConnectorAdapter implements GitHubConnector, HttpConnector {
+
+    /**
+     * Internal for testing.
+     */
+    final HttpConnector httpConnector;
+
+    /**
+     * Constructor.
+     *
+     * @param httpConnector
+     *            the HttpConnector to be adapted.
+     */
+    public GitHubConnectorHttpConnectorAdapter(HttpConnector httpConnector) {
+        this.httpConnector = httpConnector;
+    }
+
+    /**
+     * Creates a GitHubConnector for an HttpConnector.
+     *
+     * If a well-known static HttpConnector is passed, a corresponding static GitHubConnector is returned.
+     *
+     * @param connector
+     *            the HttpConnector to be adapted.
+     * @return a GitHubConnector that calls into the provided HttpConnector.
+     */
+    @Nonnull
+    public static GitHubConnector adapt(@Nonnull HttpConnector connector) {
+        GitHubConnector gitHubConnector;
+        if (connector == HttpConnector.DEFAULT) {
+            gitHubConnector = GitHubConnector.DEFAULT;
+        } else if (connector == HttpConnector.OFFLINE) {
+            gitHubConnector = GitHubConnector.OFFLINE;
+        } else if (connector instanceof GitHubConnector) {
+            gitHubConnector = (GitHubConnector) connector;
+        } else {
+            gitHubConnector = new GitHubConnectorHttpConnectorAdapter(connector);
+        }
+        return gitHubConnector;
+    }
+
+    @Nonnull
+    public HttpURLConnection connect(URL url) throws IOException {
+        return this.httpConnector.connect(url);
+    }
+
+    @Nonnull
+    public GitHubConnectorResponse send(GitHubConnectorRequest request) throws IOException {
+        HttpURLConnection connection;
+        try {
+            connection = setupConnection(this, request);
+        } catch (IOException e) {
+            // An error in here should be wrapped to bypass http exception wrapping.
+            throw new GHIOException(e.getMessage(), e);
+        }
+
+        // HttpUrlConnection is nuts. This call opens the connection and gets a response.
+        // Putting this on it's own line for ease of debugging if needed.
+        int statusCode = connection.getResponseCode();
+        Map<String, List<String>> headers = connection.getHeaderFields();
+
+        return new HttpURLConnectionGitHubConnectorResponse(request, statusCode, headers, connection);
+    }
+
+    @Nonnull
+    private static HttpURLConnection setupConnection(@Nonnull HttpConnector connector,
+            @Nonnull GitHubConnectorRequest request) throws IOException {
+        HttpURLConnection connection = connector.connect(request.url());
+        setRequestMethod(request.method(), connection);
+        buildRequest(request, connection);
+
+        return connection;
+    }
+
+    /**
+     * Set up the request parameters or POST payload.
+     */
+    private static void buildRequest(GitHubConnectorRequest request, HttpURLConnection connection) throws IOException {
+        for (Map.Entry<String, List<String>> e : request.allHeaders().entrySet()) {
+            List<String> v = e.getValue();
+            if (v != null)
+                connection.setRequestProperty(e.getKey(), String.join(", ", v));
+        }
+
+        if (request.hasBody()) {
+            connection.setDoOutput(true);
+            IOUtils.copyLarge(request.body(), connection.getOutputStream());
+        }
+    }
+
+    private static void setRequestMethod(String method, HttpURLConnection connection) throws IOException {
+        try {
+            connection.setRequestMethod(method);
+        } catch (ProtocolException e) {
+            // JDK only allows one of the fixed set of verbs. Try to override that
+            try {
+                Field $method = HttpURLConnection.class.getDeclaredField("method");
+                $method.setAccessible(true);
+                $method.set(connection, method);
+            } catch (Exception x) {
+                throw (IOException) new IOException("Failed to set the custom verb").initCause(x);
+            }
+            // sun.net.www.protocol.https.DelegatingHttpsURLConnection delegates to another HttpURLConnection
+            try {
+                Field $delegate = connection.getClass().getDeclaredField("delegate");
+                $delegate.setAccessible(true);
+                Object delegate = $delegate.get(connection);
+                if (delegate instanceof HttpURLConnection) {
+                    HttpURLConnection nested = (HttpURLConnection) delegate;
+                    setRequestMethod(method, nested);
+                }
+            } catch (NoSuchFieldException x) {
+                // no problem
+            } catch (IllegalAccessException x) {
+                throw (IOException) new IOException("Failed to set the custom verb").initCause(x);
+            }
+        }
+        if (!connection.getRequestMethod().equals(method))
+            throw new IllegalStateException("Failed to set the request method to " + method);
+    }
+
+    /**
+     * Initial response information supplied to a {@link org.kohsuke.github.function.BodyHandler} when a response is
+     * initially received and before the body is processed.
+     *
+     * Implementation specific to {@link HttpURLConnection}. For internal use only.
+     */
+    public final static class HttpURLConnectionGitHubConnectorResponse extends GitHubConnectorResponse {
+
+        private boolean inputStreamRead = false;
+        private byte[] inputBytes = null;
+        private boolean errorStreamRead = false;
+        private String errorString = null;
+
+        @Nonnull
+        private final HttpURLConnection connection;
+
+        HttpURLConnectionGitHubConnectorResponse(@Nonnull GitHubConnectorRequest request,
+                int statusCode,
+                @Nonnull Map<String, List<String>> headers,
+                @Nonnull HttpURLConnection connection) {
+            super(request, statusCode, headers);
+            this.connection = connection;
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public InputStream bodyStream() throws IOException {
+            if (connection.getResponseCode() >= HTTP_BAD_REQUEST) {
+                if (connection.getResponseCode() == HTTP_NOT_FOUND) {
+                    throw new FileNotFoundException(request().url().toString());
+                } else {
+                    throw new HttpException(errorMessage(),
+                            connection.getResponseCode(),
+                            connection.getResponseMessage(),
+                            connection.getURL().toString());
+                }
+            }
+
+            synchronized (this) {
+                if (!inputStreamRead) {
+                    try (InputStream stream = wrapStream(connection.getInputStream())) {
+                        if (stream != null) {
+                            inputBytes = IOUtils.toByteArray(stream);
+                        }
+                    }
+                    inputStreamRead = true;
+                }
+            }
+
+            return inputBytes == null ? null : new ByteArrayInputStream(inputBytes);
+        }
+
+        /**
+         * {@inheritDoc}
+         */
+        public String errorMessage() {
+            String result = null;
+            try {
+                synchronized (this) {
+                    if (!errorStreamRead) {
+                        try (InputStream stream = wrapStream(connection.getErrorStream())) {
+                            if (stream != null) {
+                                errorString = new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8);
+                            }
+                        }
+                        errorStreamRead = true;
+                    }
+                }
+                if (errorString != null) {
+                    result = errorString;
+                }
+            } catch (Exception e) {
+                LOGGER.log(FINER, "Ignored exception get error message", e);
+            }
+            return result;
+        }
+
+        @SuppressFBWarnings(value = { "EI_EXPOSE_REP" },
+                justification = "Internal implementation class. Should not be used externally.")
+        @Nonnull
+        @Override
+        @Deprecated
+        public HttpURLConnection toHttpURLConnection() {
+            return connection;
+        }
+
+        /**
+         * Handles the "Content-Encoding" header.
+         *
+         * @param stream
+         *            the stream to possibly wrap
+         *
+         */
+        private InputStream wrapStream(InputStream stream) throws IOException {
+            String encoding = header("Content-Encoding");
+            if (encoding == null || stream == null)
+                return stream;
+            if (encoding.equals("gzip"))
+                return new GZIPInputStream(stream);
+
+            throw new UnsupportedOperationException("Unexpected Content-Encoding: " + encoding);
+        }
+
+        private static final Logger LOGGER = Logger.getLogger(GitHub.class.getName());
+
+        @Override
+        public void close() throws IOException {
+            IOUtils.closeQuietly(connection.getInputStream());
+        }
+    }
+
+}


Test patch:
diff --git a/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java b/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java
index 8ed688027..5849be0e7 100644
--- a/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java
+++ b/src/test/java/org/kohsuke/github/AbstractGHAppInstallationTest.java
@@ -27,17 +27,17 @@ public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest {
     private static String PRIVATE_KEY_FILE_APP_2 = "/ghapi-test-app-2.private-key.pem";
     private static String PRIVATE_KEY_FILE_APP_3 = "/ghapi-test-app-3.private-key.pem";
 
-    private static AuthorizationProvider JWT_PROVIDER_1;
-    private static AuthorizationProvider JWT_PROVIDER_2;
-    private static AuthorizationProvider JWT_PROVIDER_3;
+    protected final AuthorizationProvider jwtProvider1;
+    protected final AuthorizationProvider jwtProvider2;
+    protected final AuthorizationProvider jwtProvider3;
 
-    AbstractGHAppInstallationTest() {
+    protected AbstractGHAppInstallationTest() {
         try {
-            JWT_PROVIDER_1 = new JWTTokenProvider(TEST_APP_ID_1,
+            jwtProvider1 = new JWTTokenProvider(TEST_APP_ID_1,
                     new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_1).getFile()));
-            JWT_PROVIDER_2 = new JWTTokenProvider(TEST_APP_ID_2,
+            jwtProvider2 = new JWTTokenProvider(TEST_APP_ID_2,
                     new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_2).getFile()).toPath());
-            JWT_PROVIDER_3 = new JWTTokenProvider(TEST_APP_ID_3,
+            jwtProvider3 = new JWTTokenProvider(TEST_APP_ID_3,
                     new String(
                             Files.readAllBytes(
                                     new File(this.getClass().getResource(PRIVATE_KEY_FILE_APP_3).getFile()).toPath()),
@@ -67,7 +67,7 @@ public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest {
         }
     }
 
-    private GHAppInstallation getAppInstallationWithToken(String jwtToken) throws IOException {
+    protected GHAppInstallation getAppInstallationWithToken(String jwtToken) throws IOException {
         GitHub gitHub = getGitHubBuilder().withJwtToken(jwtToken)
                 .withEndpoint(mockGitHub.apiServer().baseUrl())
                 .build();
@@ -89,16 +89,4 @@ public class AbstractGHAppInstallationTest extends AbstractGitHubWireMockTest {
         return appInstallation;
     }
 
-    protected GHAppInstallation getAppInstallationWithTokenApp1() throws IOException {
-        return getAppInstallationWithToken(JWT_PROVIDER_1.getEncodedAuthorization());
-    }
-
-    protected GHAppInstallation getAppInstallationWithTokenApp2() throws IOException {
-        return getAppInstallationWithToken(JWT_PROVIDER_2.getEncodedAuthorization());
-    }
-
-    protected GHAppInstallation getAppInstallationWithTokenApp3() throws IOException {
-        return getAppInstallationWithToken(JWT_PROVIDER_3.getEncodedAuthorization());
-    }
-
 }
diff --git a/src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java b/src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java
index c048d41d3..694a44faa 100644
--- a/src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java
+++ b/src/test/java/org/kohsuke/github/AbuseLimitHandlerTest.java
@@ -1,12 +1,21 @@
 package org.kohsuke.github;
 
 import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import org.apache.commons.io.IOUtils;
+import org.hamcrest.Matchers;
+import org.junit.Assert;
 import org.junit.Test;
 
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.Map;
 
-import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.CoreMatchers.notNullValue;
 import static org.hamcrest.core.IsInstanceOf.instanceOf;
 
 /**
@@ -46,7 +55,118 @@ public class AbuseLimitHandlerTest extends AbstractGitHubWireMockTest {
         snapshotNotAllowed();
 
         gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
-                .withAbuseLimitHandler(AbuseLimitHandler.FAIL)
+                .withAbuseLimitHandler(new AbuseLimitHandler() {
+                    @Override
+                    public void onError(IOException e, HttpURLConnection uc) throws IOException {
+
+                        // Verify
+                        assertThat(uc.getDate(), Matchers.greaterThanOrEqualTo(new Date().getTime() - 10000));
+                        assertThat(uc.getExpiration(), equalTo(0L));
+                        assertThat(uc.getIfModifiedSince(), equalTo(0L));
+                        assertThat(uc.getLastModified(), equalTo(1581014017000L));
+                        assertThat(uc.getRequestMethod(), equalTo("GET"));
+                        assertThat(uc.getResponseCode(), equalTo(403));
+                        assertThat(uc.getResponseMessage(), containsString("Forbidden"));
+                        assertThat(uc.getURL().toString(), endsWith("/repos/hub4j-test-org/temp-testHandler_Fail"));
+                        assertThat(uc.getHeaderFieldInt("X-RateLimit-Limit", 10), equalTo(5000));
+                        assertThat(uc.getHeaderFieldInt("X-RateLimit-Remaining", 10), equalTo(4000));
+                        assertThat(uc.getHeaderFieldInt("X-Foo", 20), equalTo(20));
+                        assertThat(uc.getHeaderFieldLong("X-RateLimit-Limit", 15L), equalTo(5000L));
+                        assertThat(uc.getHeaderFieldLong("X-RateLimit-Remaining", 15L), equalTo(4000L));
+                        assertThat(uc.getHeaderFieldLong("X-Foo", 20L), equalTo(20L));
+
+                        assertThat(uc.getContentEncoding(), nullValue());
+                        assertThat(uc.getContentType(), equalTo("application/json; charset=utf-8"));
+                        assertThat(uc.getContentLength(), equalTo(-1));
+
+                        // getting an input stream in an error case should throw
+                        IOException ioEx = Assert.assertThrows(IOException.class, () -> uc.getInputStream());
+
+                        InputStream errorStream = uc.getErrorStream();
+                        assertThat(errorStream, notNullValue());
+                        String error = IOUtils.toString(errorStream, StandardCharsets.UTF_8);
+                        assertThat(error, containsString("Must have push access to repository"));
+
+                        // calling again should still error
+                        ioEx = Assert.assertThrows(IOException.class, () -> uc.getInputStream());
+
+                        assertThat(uc.getHeaderFields(), instanceOf(Map.class));
+                        assertThat(uc.getHeaderFields().size(), Matchers.greaterThan(25));
+                        assertThat(uc.getHeaderField("Status"), equalTo("403 Forbidden"));
+
+                        String key = uc.getHeaderFieldKey(1);
+                        assertThat(key, notNullValue());
+                        assertThat(uc.getHeaderField(1), notNullValue());
+                        assertThat(uc.getHeaderField(1), equalTo(uc.getHeaderField(key)));
+
+                        assertThat(uc.getRequestProperty("Accept"), equalTo("application/vnd.github.v3+json"));
+
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.getRequestProperties());
+
+                        // Actions that are not allowed because connection already opened.
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.addRequestProperty("bogus", "item"));
+
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setAllowUserInteraction(true));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setChunkedStreamingMode(1));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setDoInput(true));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setDoOutput(true));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setFixedLengthStreamingMode(1));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setFixedLengthStreamingMode(1L));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setIfModifiedSince(1L));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setRequestProperty("bogus", "thing"));
+                        Assert.assertThrows(IllegalStateException.class, () -> uc.setUseCaches(true));
+
+                        boolean isAdapter = false;
+                        if (uc.toString().contains("GitHubConnectorResponseHttpUrlConnectionAdapter")) {
+                            isAdapter = true;
+                        }
+
+                        if (isAdapter) {
+                            Assert.assertThrows(UnsupportedOperationException.class,
+                                    () -> uc.getAllowUserInteraction());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getConnectTimeout());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getContent());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getContent(null));
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getDefaultUseCaches());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getDoInput());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getDoOutput());
+                            Assert.assertThrows(UnsupportedOperationException.class,
+                                    () -> uc.getInstanceFollowRedirects());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getOutputStream());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getPermission());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getReadTimeout());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.getUseCaches());
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.usingProxy());
+
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.setConnectTimeout(10));
+                            Assert.assertThrows(UnsupportedOperationException.class,
+                                    () -> uc.setDefaultUseCaches(true));
+
+                            Assert.assertThrows(UnsupportedOperationException.class,
+                                    () -> uc.setInstanceFollowRedirects(true));
+                            Assert.assertThrows(UnsupportedOperationException.class, () -> uc.setReadTimeout(10));
+                            Assert.assertThrows(ProtocolException.class, () -> uc.setRequestMethod("GET"));
+                        } else {
+                            uc.getDefaultUseCaches();
+                            assertThat(uc.getDoInput(), is(true));
+
+                            // Depending on the underlying implementation, this may throw or not
+                            // Assert.assertThrows(IllegalStateException.class, () -> uc.setRequestMethod("GET"));
+                        }
+
+                        // ignored
+                        uc.connect();
+
+                        // disconnect does nothing, never throws
+                        uc.disconnect();
+                        uc.disconnect();
+
+                        // ignored
+                        uc.connect();
+
+                        AbuseLimitHandler.FAIL.onError(e, uc);
+                    }
+                })
                 .build();
 
         gitHub.getMyself();
@@ -113,7 +233,7 @@ public class AbuseLimitHandlerTest extends AbstractGitHubWireMockTest {
         snapshotNotAllowed();
 
         gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
-                .withRateLimitHandler(new RateLimitHandler() {
+                .withAbuseLimitHandler(new AbuseLimitHandler() {
                     @Override
                     public void onError(IOException e, HttpURLConnection uc) throws IOException {
                     }
diff --git a/src/test/java/org/kohsuke/github/ArchTests.java b/src/test/java/org/kohsuke/github/ArchTests.java
index 810e9b196..8b4312f67 100644
--- a/src/test/java/org/kohsuke/github/ArchTests.java
+++ b/src/test/java/org/kohsuke/github/ArchTests.java
@@ -13,14 +13,18 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.builder.ReflectionToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringBuilder;
 import org.apache.commons.lang3.builder.ToStringStyle;
+import org.hamcrest.Matchers;
 import org.junit.BeforeClass;
 import org.junit.Test;
+import org.kohsuke.github.extras.okhttp3.OkHttpConnector;
 
 import java.io.Closeable;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.Reader;
 import java.lang.reflect.Field;
 import java.nio.charset.Charset;
+import java.util.Arrays;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.tngtech.archunit.core.domain.JavaCall.Predicates.target;
@@ -81,6 +85,13 @@ public class ArchTests {
         onlyAssertThatRule.check(testClassFiles);
     }
 
+    @Test
+    public void testApiStability() {
+        assertThat("OkHttpConnector must implement HttpConnector",
+                Arrays.asList(OkHttpConnector.class.getInterfaces()),
+                Matchers.containsInAnyOrder(HttpConnector.class));
+    }
+
     @Test
     public void testRequireUseOfOnlySpecificApacheCommons() {
 
@@ -114,9 +125,11 @@ public class ArchTests {
                         targetMethodIs(ReflectionToStringBuilder.class, "accept", Field.class),
                         targetMethodIs(IOUtils.class, "closeQuietly", InputStream.class),
                         targetMethodIs(IOUtils.class, "closeQuietly", Closeable.class),
+                        targetMethodIs(IOUtils.class, "copyLarge", InputStream.class, OutputStream.class),
                         targetMethodIs(IOUtils.class, "toString", InputStream.class, Charset.class),
                         targetMethodIs(IOUtils.class, "toString", Reader.class),
-                        targetMethodIs(IOUtils.class, "toByteArray", InputStream.class)))
+                        targetMethodIs(IOUtils.class, "toByteArray", InputStream.class),
+                        targetMethodIs(IOUtils.class, "write", byte[].class, OutputStream.class)))
                 .because(
                         "Commons methods must be manually verified to be compatible with commons-io:2.4 or earlier and commons-lang3:3.9 or earlier.");
 
diff --git a/src/test/java/org/kohsuke/github/GHAppInstallationTest.java b/src/test/java/org/kohsuke/github/GHAppInstallationTest.java
index 4fa13f42d..3317f868b 100644
--- a/src/test/java/org/kohsuke/github/GHAppInstallationTest.java
+++ b/src/test/java/org/kohsuke/github/GHAppInstallationTest.java
@@ -11,7 +11,7 @@ public class GHAppInstallationTest extends AbstractGHAppInstallationTest {
 
     @Test
     public void testListRepositoriesTwoRepos() throws IOException {
-        GHAppInstallation appInstallation = getAppInstallationWithTokenApp1();
+        GHAppInstallation appInstallation = getAppInstallationWithToken(jwtProvider1.getEncodedAuthorization());
 
         List<GHRepository> repositories = appInstallation.listRepositories().toList();
 
@@ -22,7 +22,7 @@ public class GHAppInstallationTest extends AbstractGHAppInstallationTest {
 
     @Test
     public void testListRepositoriesNoPermissions() throws IOException {
-        GHAppInstallation appInstallation = getAppInstallationWithTokenApp2();
+        GHAppInstallation appInstallation = getAppInstallationWithToken(jwtProvider2.getEncodedAuthorization());
 
         assertThat("App does not have permissions and should have 0 repositories",
                 appInstallation.listRepositories().toList().isEmpty());
diff --git a/src/test/java/org/kohsuke/github/GHCheckRunBuilderTest.java b/src/test/java/org/kohsuke/github/GHCheckRunBuilderTest.java
index efb98bdc6..c658a2446 100644
--- a/src/test/java/org/kohsuke/github/GHCheckRunBuilderTest.java
+++ b/src/test/java/org/kohsuke/github/GHCheckRunBuilderTest.java
@@ -36,7 +36,7 @@ import static org.hamcrest.Matchers.*;
 public class GHCheckRunBuilderTest extends AbstractGHAppInstallationTest {
 
     protected GitHub getInstallationGithub() throws IOException {
-        return getAppInstallationWithTokenApp3().root();
+        return getAppInstallationWithToken(jwtProvider3.getEncodedAuthorization()).root();
     }
 
     @Test
@@ -122,7 +122,7 @@ public class GHCheckRunBuilderTest extends AbstractGHAppInstallationTest {
             assertThat(x.getResponseCode(), equalTo(422));
             assertThat(x.getMessage(), containsString("\\\"conclusion\\\" wasn't supplied"));
             assertThat(x.getUrl(), containsString("/repos/hub4j-test-org/test-checks/check-runs"));
-            assertThat(x.getResponseMessage(), equalTo("422 Unprocessable Entity"));
+            assertThat(x.getResponseMessage(), containsString("Unprocessable Entity"));
         }
     }
 
diff --git a/src/test/java/org/kohsuke/github/GHRateLimitTest.java b/src/test/java/org/kohsuke/github/GHRateLimitTest.java
index 3b84c9f30..446481355 100644
--- a/src/test/java/org/kohsuke/github/GHRateLimitTest.java
+++ b/src/test/java/org/kohsuke/github/GHRateLimitTest.java
@@ -16,7 +16,7 @@ import static org.hamcrest.Matchers.*;
 import static org.hamcrest.core.IsInstanceOf.instanceOf;
 
 /**
- * Test showing the behavior of OkHttpConnector with and without cache.
+ * Test showing the behavior of OkHttpGitHubConnector with and without cache.
  * <p>
  * Key take aways:
  *
diff --git a/src/test/java/org/kohsuke/github/GitHubStaticTest.java b/src/test/java/org/kohsuke/github/GitHubStaticTest.java
index 8de44cc87..37f67aa2c 100644
--- a/src/test/java/org/kohsuke/github/GitHubStaticTest.java
+++ b/src/test/java/org/kohsuke/github/GitHubStaticTest.java
@@ -2,6 +2,8 @@ package org.kohsuke.github;
 
 import org.junit.Assert;
 import org.junit.Test;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
 
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -324,7 +326,7 @@ public class GitHubStaticTest extends AbstractGitHubWireMockTest {
         assertThat(repoString, not(nullValue()));
         assertThat(repoString, containsString("testMappingReaderWriter"));
 
-        GHRepository readRepo = GitHubClient.getMappingObjectReader((GitHubResponse.ResponseInfo) null)
+        GHRepository readRepo = GitHubClient.getMappingObjectReader((GitHubConnectorResponse) null)
                 .forType(GHRepository.class)
                 .readValue(repoString);
 
@@ -338,7 +340,7 @@ public class GitHubStaticTest extends AbstractGitHubWireMockTest {
         readRepo = GitHub.getMappingObjectReader().forType(GHRepository.class).readValue(repoString);
 
         // This should never happen if the internal method isn't used
-        assertThat(readRepo.getRoot().getConnector(), equalTo(HttpConnector.OFFLINE));
+        assertThat(readRepo.getRoot().getConnector(), equalTo(GitHubConnector.OFFLINE));
         assertThat(readRepo.getResponseHeaderFields(), nullValue());
 
         String readRepoString = GitHub.getMappingObjectWriter().writeValueAsString(readRepo);
diff --git a/src/test/java/org/kohsuke/github/GitHubTest.java b/src/test/java/org/kohsuke/github/GitHubTest.java
index f47249f63..e49d10669 100644
--- a/src/test/java/org/kohsuke/github/GitHubTest.java
+++ b/src/test/java/org/kohsuke/github/GitHubTest.java
@@ -268,17 +268,5 @@ public class GitHubTest extends AbstractGitHubWireMockTest {
         assertThat("KeySet from header fields should also be case-insensitive",
                 org.getResponseHeaderFields().keySet().contains("CacHe-ControL"));
         assertThat(org.getResponseHeaderFields().get("cachE-cOntrol").get(0), is("private, max-age=60, s-maxage=60"));
-
-        // GitHub has started changing their headers to all lowercase.
-        // For this test we want the field names to be with mixed-case (harder to do comparison).
-        // Ensure that it remains that way, if test resources are ever refreshed.
-        boolean found = false;
-        for (String key : org.getResponseHeaderFields().keySet()) {
-            if (Objects.equals("Cache-Control", key)) {
-                found = true;
-                break;
-            }
-        }
-        assertThat("Must have the literal expected string 'Cache-Control' for header field name", found);
     }
 }
diff --git a/src/test/java/org/kohsuke/github/RequesterRetryTest.java b/src/test/java/org/kohsuke/github/RequesterRetryTest.java
index 29060ae0c..9e1cc7616 100644
--- a/src/test/java/org/kohsuke/github/RequesterRetryTest.java
+++ b/src/test/java/org/kohsuke/github/RequesterRetryTest.java
@@ -8,7 +8,7 @@ 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.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector;
 import org.mockito.Mockito;
 
 import java.io.ByteArrayOutputStream;
@@ -86,7 +86,7 @@ public class RequesterRetryTest extends AbstractGitHubWireMockTest {
                 .connectionPool(new ConnectionPool(2, 100, TimeUnit.MILLISECONDS))
                 .build();
 
-        OkHttpConnector connector = new OkHttpConnector(client);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client);
 
         for (int x = 0; x < 100; x++) {
 
diff --git a/src/test/java/org/kohsuke/github/extras/GitHubCachingTest.java b/src/test/java/org/kohsuke/github/extras/GitHubCachingTest.java
index e2ea624da..2e03775f6 100644
--- a/src/test/java/org/kohsuke/github/extras/GitHubCachingTest.java
+++ b/src/test/java/org/kohsuke/github/extras/GitHubCachingTest.java
@@ -23,7 +23,7 @@ import java.io.IOException;
 import static org.junit.Assert.fail;
 
 /**
- * Test showing the behavior of OkHttpConnector cache with GitHub 404 responses.
+ * Test showing the behavior of OkHttpGitHubConnector cache with GitHub 404 responses.
  *
  * @author Liam Newman
  */
diff --git a/src/test/java/org/kohsuke/github/extras/OkHttpConnectorTest.java b/src/test/java/org/kohsuke/github/extras/OkHttpConnectorTest.java
index 2f275372d..761cf32b5 100644
--- a/src/test/java/org/kohsuke/github/extras/OkHttpConnectorTest.java
+++ b/src/test/java/org/kohsuke/github/extras/OkHttpConnectorTest.java
@@ -20,7 +20,7 @@ import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
 /**
- * Test showing the behavior of OkHttpConnector with and without cache.
+ * Test showing the behavior of OkHttpGitHubConnector with and without cache.
  * <p>
  * Key take aways:
  *
diff --git a/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java b/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java
index 351892c4d..24581000e 100644
--- a/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java
+++ b/src/test/java/org/kohsuke/github/extras/authorization/JWTTokenProviderTest.java
@@ -1,9 +1,7 @@
 package org.kohsuke.github.extras.authorization;
 
 import org.junit.Test;
-import org.kohsuke.github.AbstractGitHubWireMockTest;
-import org.kohsuke.github.GitHub;
-import org.kohsuke.github.HttpException;
+import org.kohsuke.github.*;
 
 import java.io.File;
 import java.io.IOException;
@@ -32,11 +30,23 @@ import static org.hamcrest.Matchers.*;
  * with a private key and a random nonce, so it will never be the same (under normal conditions). For more
  * information on the format of a JWT token, see: https://jwt.io/introduction/
  */
-public class JWTTokenProviderTest extends AbstractGitHubWireMockTest {
+public class JWTTokenProviderTest extends AbstractGHAppInstallationTest {
 
     private static String TEST_APP_ID_2 = "83009";
     private static String PRIVATE_KEY_FILE_APP_2 = "/ghapi-test-app-2.private-key.pem";
 
+    @Test
+    public void testCachingValidAuthorization() throws IOException {
+        assertThat(jwtProvider1, instanceOf(JWTTokenProvider.class));
+        JWTTokenProvider provider = (JWTTokenProvider) jwtProvider1;
+
+        assertThat(provider.isNotValid(), is(true));
+        String authorization = provider.getEncodedAuthorization();
+        assertThat(provider.isNotValid(), is(false));
+        String authorizationRefresh = provider.getEncodedAuthorization();
+        assertThat(authorizationRefresh, sameInstance(authorization));
+    }
+
     @Test
     public void testAuthorizationHeaderPattern() throws GeneralSecurityException, IOException {
         // authorization header check is custom
diff --git a/src/test/java/org/kohsuke/github/extras/okhttp3/GitHubCachingTest.java b/src/test/java/org/kohsuke/github/extras/okhttp3/GitHubCachingTest.java
index b8011ecbb..00abeaad2 100644
--- a/src/test/java/org/kohsuke/github/extras/okhttp3/GitHubCachingTest.java
+++ b/src/test/java/org/kohsuke/github/extras/okhttp3/GitHubCachingTest.java
@@ -22,7 +22,7 @@ import java.io.IOException;
 import static org.junit.Assert.fail;
 
 /**
- * Test showing the behavior of OkHttpConnector cache with GitHub 404 responses.
+ * Test showing the behavior of OkHttpGitHubConnector cache with GitHub 404 responses.
  *
  * @author Liam Newman
  */
@@ -64,7 +64,7 @@ public class GitHubCachingTest extends AbstractGitHubWireMockTest {
         snapshotNotAllowed();
 
         OkHttpClient client = createClient(true);
-        OkHttpConnector connector = new OkHttpConnector(client);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client);
 
         this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
                 .withConnector(connector)
@@ -73,7 +73,7 @@ public class GitHubCachingTest extends AbstractGitHubWireMockTest {
         // Alternate client also doing caching but staying in a good state
         // We use this to do sanity checks and other information gathering
         GitHub gitHub2 = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
-                .withConnector(new OkHttpConnector(createClient(true)))
+                .withConnector(new OkHttpGitHubConnector(createClient(true)))
                 .build();
 
         // Create a branch from a known conflicting branch
diff --git a/src/test/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnectorTest.java b/src/test/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnectorTest.java
new file mode 100644
index 000000000..773b3f861
--- /dev/null
+++ b/src/test/java/org/kohsuke/github/extras/okhttp3/OkHttpGitHubConnectorTest.java
@@ -0,0 +1,296 @@
+package org.kohsuke.github.extras.okhttp3;
+
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder;
+import okhttp3.Cache;
+import okhttp3.OkHttpClient;
+import org.apache.commons.io.FileUtils;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.kohsuke.github.AbstractGitHubWireMockTest;
+import org.kohsuke.github.GHRateLimit;
+import org.kohsuke.github.GHRepository;
+import org.kohsuke.github.GitHub;
+
+import java.io.File;
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.core.Is.is;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
+
+/**
+ * Test showing the behavior of OkHttpGitHubConnector with and without cache.
+ * <p>
+ * Key take aways:
+ *
+ * <ul>
+ * <li>These tests are artificial and intended to highlight the differences in behavior between scenarios. However, the
+ * differences they indicate are stark.</li>
+ * <li>Caching reduces rate limit consumption by at least a factor of two in even the simplest case.</li>
+ * <li>The OkHttp cache is pretty smart and will often connect read and write requests made on the same client and
+ * invalidate caches.</li>
+ * <li>Changes made outside the current client cause the OkHttp cache to return stale data. This is expected and correct
+ * behavior.</li>
+ * <li>"max-age=0" addresses the problem of external changes by revalidating caches for each request. This produces the
+ * same number of requests as OkHttp without caching, but those requests only count towards the GitHub rate limit if
+ * data has changes.</li>
+ * </ul>
+ *
+ * @author Liam Newman
+ */
+public class OkHttpGitHubConnectorTest extends AbstractGitHubWireMockTest {
+
+    public OkHttpGitHubConnectorTest() {
+        useDefaultGitHub = false;
+    }
+
+    private static int defaultRateLimitUsed = 17;
+    private static int okhttpRateLimitUsed = 17;
+    private static int maxAgeZeroRateLimitUsed = 7;
+    private static int maxAgeThreeRateLimitUsed = 7;
+    private static int maxAgeNoneRateLimitUsed = 4;
+
+    private static int userRequestCount = 0;
+
+    private static int defaultNetworkRequestCount = 16;
+    private static int okhttpNetworkRequestCount = 16;
+    private static int maxAgeZeroNetworkRequestCount = 16;
+    private static int maxAgeThreeNetworkRequestCount = 9;
+    private static int maxAgeNoneNetworkRequestCount = 5;
+
+    private static int maxAgeZeroHitCount = 10;
+    private static int maxAgeThreeHitCount = 10;
+    private static int maxAgeNoneHitCount = 11;
+
+    private GHRateLimit rateLimitBefore;
+    private Cache cache = null;
+
+    @Override
+    protected WireMockConfiguration getWireMockOptions() {
+        return super.getWireMockOptions()
+                // Use the same data files as the 2.x test
+                .usingFilesUnderDirectory(baseRecordPath.replace("/okhttp3/OkHttpGitHubConnector", "/OkHttpConnector"))
+                .extensions(templating.newResponseTransformer());
+    }
+
+    @Before
+    public void setupRepo() throws Exception {
+        if (mockGitHub.isUseProxy()) {
+            GHRepository repo = getRepository(getNonRecordingGitHub());
+            repo.setDescription("Resetting");
+
+            // Let things settle a bit between tests when working against the live site
+            Thread.sleep(5000);
+            userRequestCount = 1;
+        }
+    }
+
+    @After
+    public void deleteCache() throws IOException {
+        if (cache != null) {
+            cache.delete();
+        }
+    }
+
+    @Test
+    public void DefaultConnector() throws Exception {
+
+        this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl()).build();
+
+        doTestActions();
+
+        // Testing behavior after change
+        // Uncached connection gets updated correctly but at cost of rate limit
+        assertThat(getRepository(gitHub).getDescription(), is("Tricky"));
+
+        checkRequestAndLimit(defaultNetworkRequestCount, defaultRateLimitUsed);
+    }
+
+    @Test
+    public void OkHttpConnector_NoCache() throws Exception {
+
+        OkHttpClient client = createClient(false);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client);
+
+        this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
+                .withConnector(connector)
+                .build();
+
+        doTestActions();
+
+        // Testing behavior after change
+        // Uncached okhttp connection gets updated correctly but at cost of rate limit
+        assertThat(getRepository(gitHub).getDescription(), is("Tricky"));
+
+        checkRequestAndLimit(okhttpNetworkRequestCount, okhttpRateLimitUsed);
+
+        assertThat("Cache", cache, is(nullValue()));
+    }
+
+    @Test
+    public void OkHttpConnector_Cache_MaxAgeNone() throws Exception {
+        // The responses were recorded from github, but the Date headers
+        // have been templated to make caching behavior work as expected.
+        // This is reasonable as long as the number of network requests matches up.
+        snapshotNotAllowed();
+
+        OkHttpClient client = createClient(true);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client, -1);
+
+        this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
+                .withConnector(connector)
+                .build();
+
+        doTestActions();
+
+        // Testing behavior after change
+        // NOTE: this is wrong! The live data changed!
+        // Due to max-age (default 60 from response) the cache returns the old data.
+        assertThat(getRepository(gitHub).getDescription(), is(mockGitHub.getMethodName()));
+
+        checkRequestAndLimit(maxAgeNoneNetworkRequestCount, maxAgeNoneRateLimitUsed);
+
+        // NOTE: this is actually bad.
+        // This elevated hit count is the stale requests returning bad data took longer to detect a change.
+        assertThat("getHitCount", cache.hitCount(), is(maxAgeNoneHitCount));
+    }
+
+    @Test
+    public void OkHttpConnector_Cache_MaxAge_Three() throws Exception {
+
+        // NOTE: This test is very timing sensitive.
+        // It can be run locally to verify behavior but snapshot data is to touchy
+        assumeFalse("Test only valid when not taking a snapshot", mockGitHub.isTakeSnapshot());
+        assumeTrue("Test only valid when proxying (-Dtest.github.useProxy to enable)", mockGitHub.isUseProxy());
+
+        OkHttpClient client = createClient(true);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client, 3);
+
+        this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
+                .withConnector(connector)
+                .build();
+
+        doTestActions();
+
+        // Due to max-age=3 this eventually checks the site and gets updated information. Yay?
+        assertThat(getRepository(gitHub).getDescription(), is("Tricky"));
+
+        checkRequestAndLimit(maxAgeThreeNetworkRequestCount, maxAgeThreeRateLimitUsed);
+
+        assertThat("getHitCount", cache.hitCount(), is(maxAgeThreeHitCount));
+    }
+
+    @Test
+    public void OkHttpConnector_Cache_MaxAgeDefault_Zero() throws Exception {
+        // The responses were recorded from github, but the Date headers
+        // have been templated to make caching behavior work as expected.
+        // This is reasonable as long as the number of network requests matches up.
+        snapshotNotAllowed();
+
+        OkHttpClient client = createClient(true);
+        OkHttpGitHubConnector connector = new OkHttpGitHubConnector(client);
+
+        this.gitHub = getGitHubBuilder().withEndpoint(mockGitHub.apiServer().baseUrl())
+                .withConnector(connector)
+                .build();
+
+        doTestActions();
+
+        // Testing behavior after change
+        // NOTE: max-age=0 produces the same result at uncached without added rate-limit use.
+        assertThat(getRepository(gitHub).getDescription(), is("Tricky"));
+
+        checkRequestAndLimit(maxAgeZeroNetworkRequestCount, maxAgeZeroRateLimitUsed);
+
+        assertThat("getHitCount", cache.hitCount(), is(maxAgeZeroHitCount));
+    }
+
+    private void checkRequestAndLimit(int networkRequestCount, int rateLimitUsed) throws IOException {
+        GHRateLimit rateLimitAfter = gitHub.rateLimit();
+        assertThat("Request Count", getRequestCount(), is(networkRequestCount + userRequestCount));
+
+        // Rate limit must be under this value, but if it wiggles we don't care
+        assertThat("Rate Limit Change",
+                rateLimitBefore.remaining - rateLimitAfter.remaining,
+                is(lessThanOrEqualTo(rateLimitUsed + userRequestCount)));
+
+    }
+
+    private int getRequestCount() {
+        return mockGitHub.apiServer().countRequestsMatching(RequestPatternBuilder.allRequests().build()).getCount();
+    }
+
+    private OkHttpClient createClient(boolean useCache) throws IOException {
+        OkHttpClient.Builder builder = new OkHttpClient().newBuilder();
+
+        if (useCache) {
+            File cacheDir = new File("target/cache/" + baseFilesClassPath + "/" + mockGitHub.getMethodName());
+            cacheDir.mkdirs();
+            FileUtils.cleanDirectory(cacheDir);
+            cache = new Cache(cacheDir, 100 * 1024L * 1024L);
+
+            builder.cache(cache);
+        }
+
+        return builder.build();
+    }
+
+    /**
+     * This is a standard set of actions to be performed with each connector
+     *
+     * @throws Exception
+     */
+    private void doTestActions() throws Exception {
+        rateLimitBefore = gitHub.getRateLimit();
+
+        String name = mockGitHub.getMethodName();
+
+        GHRepository repo = getRepository(gitHub);
+
+        // Testing behavior when nothing has changed.
+        pollForChange("Resetting");
+        assertThat(getRepository(gitHub).getDescription(), is("Resetting"));
+
+        repo.setDescription(name);
+
+        pollForChange(name);
+
+        // Test behavior after change
+        assertThat(getRepository(gitHub).getDescription(), is(name));
+
+        // Get Tricky - make a change via a different client
+        if (mockGitHub.isUseProxy()) {
+            GHRepository altRepo = getRepository(getNonRecordingGitHub());
+            altRepo.setDescription("Tricky");
+        }
+
+        // Testing behavior after change
+        pollForChange("Tricky");
+    }
+
+    private void pollForChange(String name) throws IOException, InterruptedException {
+        getRepository(gitHub).getDescription();
+        Thread.sleep(500);
+        getRepository(gitHub).getDescription();
+        // This is only interesting when running the max-age=3 test which currently only runs with proxy
+        // Disabled to speed up the tests
+        if (mockGitHub.isUseProxy()) {
+            Thread.sleep(1000);
+        }
+        getRepository(gitHub).getDescription();
+        // This is only interesting when running the max-age=3 test which currently only runs with proxy
+        // Disabled to speed up the tests
+        if (mockGitHub.isUseProxy()) {
+            Thread.sleep(4000);
+        }
+    }
+
+    private static GHRepository getRepository(GitHub gitHub) throws IOException {
+        return gitHub.getOrganization("hub4j-test-org").getRepository("github-api");
+    }
+
+}
diff --git a/src/test/java/org/kohsuke/github/internal/DefaultGitHubConnectorTest.java b/src/test/java/org/kohsuke/github/internal/DefaultGitHubConnectorTest.java
new file mode 100644
index 000000000..48eab51ce
--- /dev/null
+++ b/src/test/java/org/kohsuke/github/internal/DefaultGitHubConnectorTest.java
@@ -0,0 +1,65 @@
+package org.kohsuke.github.internal;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.kohsuke.github.AbstractGitHubWireMockTest;
+import org.kohsuke.github.GitHubBuilder;
+import org.kohsuke.github.HttpConnector;
+import org.kohsuke.github.connector.GitHubConnector;
+import org.kohsuke.github.connector.GitHubConnectorRequest;
+import org.kohsuke.github.connector.GitHubConnectorResponse;
+import org.kohsuke.github.extras.okhttp3.OkHttpConnector;
+import org.kohsuke.github.extras.okhttp3.OkHttpGitHubConnector;
+
+import java.io.IOException;
+
+import static org.hamcrest.CoreMatchers.*;
+
+public class DefaultGitHubConnectorTest extends AbstractGitHubWireMockTest {
+
+    public DefaultGitHubConnectorTest() {
+        useDefaultGitHub = false;
+    }
+
+    @Test
+    public void testCreate() throws Exception {
+        GitHubConnector connector;
+        GitHubConnectorHttpConnectorAdapter adapter;
+
+        connector = DefaultGitHubConnector.create("default");
+        assertThat(connector, instanceOf(GitHubConnectorHttpConnectorAdapter.class));
+        adapter = (GitHubConnectorHttpConnectorAdapter) connector;
+        assertThat(adapter.httpConnector, equalTo(HttpConnector.DEFAULT));
+
+        connector = DefaultGitHubConnector.create("urlconnection");
+        assertThat(connector, instanceOf(GitHubConnectorHttpConnectorAdapter.class));
+        adapter = (GitHubConnectorHttpConnectorAdapter) connector;
+        assertThat(adapter.httpConnector, equalTo(HttpConnector.DEFAULT));
+
+        connector = DefaultGitHubConnector.create("okhttpconnector");
+        assertThat(connector, instanceOf(GitHubConnectorHttpConnectorAdapter.class));
+        adapter = (GitHubConnectorHttpConnectorAdapter) connector;
+        assertThat(adapter.httpConnector, instanceOf(OkHttpConnector.class));
+
+        connector = DefaultGitHubConnector.create("okhttp");
+        assertThat(connector, instanceOf(OkHttpGitHubConnector.class));
+
+        Assert.assertThrows(IllegalStateException.class, () -> DefaultGitHubConnector.create(""));
+
+        assertThat(GitHubConnectorHttpConnectorAdapter.adapt(HttpConnector.DEFAULT),
+                sameInstance(GitHubConnector.DEFAULT));
+        assertThat(GitHubConnectorHttpConnectorAdapter.adapt(HttpConnector.OFFLINE),
+                sameInstance(GitHubConnector.OFFLINE));
+
+        gitHub = new GitHubBuilder().withConnector(new GitHubConnector() {
+            @Override
+            public GitHubConnectorResponse send(GitHubConnectorRequest connectorRequest) throws IOException {
+                throw new IOException();
+            }
+        }).build();
+        Assert.assertThrows(UnsupportedOperationException.class, () -> gitHub.getConnector());
+        gitHub.setConnector((HttpConnector) GitHubConnector.OFFLINE);
+        // Doesn't throw when HttpConnect is implemented
+        gitHub.getConnector();
+    }
+}
diff --git a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenOver250/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenOver250/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
index d0313d237..0f14fb7af 100644
--- a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenOver250/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
+++ b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenOver250/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
@@ -21136,7 +21136,7 @@
       "blob_url": "https://github.com/hub4j-test-org/github-api/blob/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "raw_url": "https://github.com/hub4j-test-org/github-api/raw/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "contents_url": "https://api.github.com/repos/hub4j-test-org/github-api/contents/src/main/java/org/kohsuke/github/GitHubClient.java?ref=94ff089e60064bfa43e374baeb10846f7ce82f40",
-      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo responseInfo = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     responseInfo = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (responseInfo.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (responseInfo.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (responseInfo.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + responseInfo.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + responseInfo.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(responseInfo);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo responseInfo) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, responseInfo);\n             updateRateLimit(GHRateLimit.fromRecord(observed, responseInfo.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
+      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo connectorResponse = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     connectorResponse = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (connectorResponse.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (connectorResponse.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (connectorResponse.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + connectorResponse.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + connectorResponse.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(connectorResponse);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo connectorResponse) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, connectorResponse);\n             updateRateLimit(GHRateLimit.fromRecord(observed, connectorResponse.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
     },
     {
       "sha": "aea8b99b1f3301f65c8bc86c657bfd187db5c178",
diff --git a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
index a66e4891a..7ff57c7ca 100644
--- a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
+++ b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-4.json
@@ -1098,7 +1098,7 @@
       "blob_url": "https://github.com/hub4j-test-org/github-api/blob/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "raw_url": "https://github.com/hub4j-test-org/github-api/raw/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "contents_url": "https://api.github.com/repos/hub4j-test-org/github-api/contents/src/main/java/org/kohsuke/github/GitHubClient.java?ref=94ff089e60064bfa43e374baeb10846f7ce82f40",
-      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo responseInfo = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     responseInfo = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (responseInfo.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (responseInfo.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (responseInfo.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + responseInfo.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + responseInfo.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(responseInfo);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo responseInfo) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, responseInfo);\n             updateRateLimit(GHRateLimit.fromRecord(observed, responseInfo.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
+      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo connectorResponse = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     connectorResponse = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (connectorResponse.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (connectorResponse.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (connectorResponse.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + connectorResponse.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + connectorResponse.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(connectorResponse);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo connectorResponse) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, connectorResponse);\n             updateRateLimit(GHRateLimit.fromRecord(observed, connectorResponse.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
     },
     {
       "sha": "aea8b99b1f3301f65c8bc86c657bfd187db5c178",
diff --git a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-5.json b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-5.json
index 22c43a022..3980686fa 100644
--- a/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-5.json
+++ b/src/test/resources/org/kohsuke/github/GHRepositoryTest/wiremock/getCommitsBetweenPaged/__files/repos_hub4j-test-org_github-api_compare_4261c42949915816a9f246eb14c3dfd21a637bc294ff089e60064bfa43e374baeb10846f7ce82f40-5.json
@@ -9064,7 +9064,7 @@
       "blob_url": "https://github.com/hub4j-test-org/github-api/blob/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "raw_url": "https://github.com/hub4j-test-org/github-api/raw/94ff089e60064bfa43e374baeb10846f7ce82f40/src/main/java/org/kohsuke/github/GitHubClient.java",
       "contents_url": "https://api.github.com/repos/hub4j-test-org/github-api/contents/src/main/java/org/kohsuke/github/GitHubClient.java?ref=94ff089e60064bfa43e374baeb10846f7ce82f40",
-      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo responseInfo = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     responseInfo = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (responseInfo.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (responseInfo.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (responseInfo.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + responseInfo.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + responseInfo.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(responseInfo);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo responseInfo) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, responseInfo);\n             updateRateLimit(GHRateLimit.fromRecord(observed, responseInfo.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
+      "patch": "@@ -14,6 +14,7 @@\n import java.time.format.DateTimeFormatter;\n import java.time.temporal.ChronoUnit;\n import java.util.*;\n+import java.util.concurrent.atomic.AtomicReference;\n import java.util.function.Consumer;\n import java.util.logging.Logger;\n \n@@ -52,10 +53,8 @@\n \n     private HttpConnector connector;\n \n-    private final Object rateLimitLock = new Object();\n-\n     @Nonnull\n-    private GHRateLimit rateLimit = GHRateLimit.DEFAULT;\n+    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);\n \n     private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());\n \n@@ -141,10 +140,9 @@ public boolean isCredentialValid() {\n             getRateLimit();\n             return true;\n         } catch (IOException e) {\n-            if (LOGGER.isLoggable(FINE))\n-                LOGGER.log(FINE,\n-                        \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n-                        e);\n+            LOGGER.log(FINE,\n+                    \"Exception validating credentials on \" + getApiUrl() + \" with login '\" + login + \"' \" + e,\n+                    e);\n             return false;\n         }\n     }\n@@ -256,9 +254,7 @@ GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExce\n     @Nonnull\n     @Deprecated\n     GHRateLimit lastRateLimit() {\n-        synchronized (rateLimitLock) {\n-            return rateLimit;\n-        }\n+        return rateLimit.get();\n     }\n \n     /**\n@@ -278,12 +274,19 @@ GHRateLimit lastRateLimit() {\n      */\n     @Nonnull\n     GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {\n-        synchronized (rateLimitLock) {\n-            if (rateLimit.getRecord(rateLimitTarget).isExpired()) {\n-                getRateLimit(rateLimitTarget);\n+        GHRateLimit result = rateLimit.get();\n+        // Most of the time rate limit is not expired, so try to avoid locking.\n+        if (result.getRecord(rateLimitTarget).isExpired()) {\n+            // if the rate limit is expired, synchronize to ensure\n+            // only one call to getRateLimit() is made to refresh it.\n+            synchronized (this) {\n+                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {\n+                    getRateLimit(rateLimitTarget);\n+                }\n             }\n-            return rateLimit;\n+            result = rateLimit.get();\n         }\n+        return result;\n     }\n \n     /**\n@@ -296,15 +299,9 @@ GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOExcepti\n      *            {@link GHRateLimit.Record} constructed from the response header information\n      */\n     private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {\n-        synchronized (rateLimitLock) {\n-            observed = rateLimit.getMergedRateLimit(observed);\n-\n-            if (rateLimit != observed) {\n-                rateLimit = observed;\n-                LOGGER.log(FINE, \"Rate limit now: {0}\", rateLimit);\n-            }\n-            return rateLimit;\n-        }\n+        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));\n+        LOGGER.log(FINEST, \"Rate limit now: {0}\", rateLimit.get());\n+        return result;\n     }\n \n     /**\n@@ -384,11 +381,7 @@ public String getApiUrl() {\n             GitHubResponse.ResponseInfo connectorResponse = null;\n             try {\n                 try {\n-                    if (LOGGER.isLoggable(FINE)) {\n-                        LOGGER.log(FINE,\n-                                \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \"\n-                                        + request.method() + \" \" + request.url().toString());\n-                    }\n+                    logRequest(request);\n                     rateLimitChecker.checkRateLimit(this, request);\n \n                     connectorResponse = getResponseInfo(request);\n@@ -424,6 +417,12 @@ public String getApiUrl() {\n         throw new GHIOException(\"Ran out of retries for URL: \" + request.url().toString());\n     }\n \n+    private void logRequest(@Nonnull final GitHubRequest request) {\n+        LOGGER.log(FINE,\n+                () -> \"GitHub API request [\" + (login == null ? \"anonymous\" : login) + \"]: \" + request.method() + \" \"\n+                        + request.url().toString());\n+    }\n+\n     @Nonnull\n     protected abstract GitHubResponse.ResponseInfo getResponseInfo(GitHubRequest request) throws IOException;\n \n@@ -437,20 +436,15 @@ public String getApiUrl() {\n             // special case handling for 304 unmodified, as the content will be \"\"\n         } else if (connectorResponse.statusCode() == HttpURLConnection.HTTP_ACCEPTED) {\n \n-            // Response code 202 means data is being generated.\n+            // Response code 202 means data is being generated or an action that can require some time is triggered.\n             // This happens in specific cases:\n             // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching\n             // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork\n+            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run\n \n-            if (connectorResponse.url().toString().endsWith(\"/forks\")) {\n-                LOGGER.log(INFO, \"The fork is being created. Please try again in 5 seconds.\");\n-            } else if (connectorResponse.url().toString().endsWith(\"/statistics\")) {\n-                LOGGER.log(INFO, \"The statistics are being generated. Please try again in 5 seconds.\");\n-            } else {\n-                LOGGER.log(INFO,\n-                        \"Received 202 from \" + connectorResponse.url().toString() + \" . Please try again in 5 seconds.\");\n-            }\n-            // Maybe throw an exception instead?\n+            LOGGER.log(FINE,\n+                    \"Received HTTP_ACCEPTED(202) from \" + connectorResponse.url().toString()\n+                            + \" . Please try again in 5 seconds.\");\n         } else if (handler != null) {\n             body = handler.apply(connectorResponse);\n         }\n@@ -562,9 +556,7 @@ private void noteRateLimit(@Nonnull GitHubResponse.ResponseInfo connectorResponse) {\n             GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, connectorResponse);\n             updateRateLimit(GHRateLimit.fromRecord(observed, connectorResponse.request().rateLimitTarget()));\n         } catch (NumberFormatException | NullPointerException e) {\n-            if (LOGGER.isLoggable(FINEST)) {\n-                LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n-            }\n+            LOGGER.log(FINEST, \"Missing or malformed X-RateLimit header: \", e);\n         }\n     }\n "
     },
     {
       "sha": "aea8b99b1f3301f65c8bc86c657bfd187db5c178",
diff --git a/src/test/resources/org/kohsuke/github/extras/GitHubCachingTest/wiremock/testCached404/mappings/repos_hub4j-test-org_github-api_git_refs_heads_test_content_ref_cache-15.json b/src/test/resources/org/kohsuke/github/extras/GitHubCachingTest/wiremock/testCached404/mappings/repos_hub4j-test-org_github-api_git_refs_heads_test_content_ref_cache-15.json
index 6bb9de6da..65e35c3bf 100644
--- a/src/test/resources/org/kohsuke/github/extras/GitHubCachingTest/wiremock/testCached404/mappings/repos_hub4j-test-org_github-api_git_refs_heads_test_content_ref_cache-15.json
+++ b/src/test/resources/org/kohsuke/github/extras/GitHubCachingTest/wiremock/testCached404/mappings/repos_hub4j-test-org_github-api_git_refs_heads_test_content_ref_cache-15.json
@@ -6,7 +6,7 @@
     "method": "GET",
     "headers": {
       "If-None-Match": {
-        "equalTo": "W/\"d3be5b35b8d84ef7ac03c0cc9c94ed81\""
+        "matches": "^(W/)?\"d3be5b35b8d84ef7ac03c0cc9c94ed81\"$"
       },
       "Cache-Control": {
         "equalTo": "max-age=0"
diff --git a/src/test/resources/slow-or-flaky-tests.txt b/src/test/resources/slow-or-flaky-tests.txt
index 35be9489e..e0aa93a72 100644
--- a/src/test/resources/slow-or-flaky-tests.txt
+++ b/src/test/resources/slow-or-flaky-tests.txt
@@ -1,6 +1,5 @@
 **/extras/**
-**/AbuseLimitHandlerTest
 **/GHRateLimitTest
 **/RequesterRetryTest
 **/RateLimitCheckerTest
-**/RateLimitHandlerTest
\ No newline at end of file
+**/RateLimitHandlerTest