GitHubClient.java

package org.kohsuke.github;

import com.fasterxml.jackson.databind.*;
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.FunctionThrows;

import java.io.*;
import java.net.*;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;

import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;

import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY;
import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE;
import static java.net.HttpURLConnection.HTTP_ACCEPTED;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_MOVED_PERM;
import static java.net.HttpURLConnection.HTTP_MOVED_TEMP;
import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
import static java.util.logging.Level.*;
import static org.apache.commons.lang3.StringUtils.defaultString;

// TODO: Auto-generated Javadoc
/**
 * A GitHub API Client
 * <p>
 * A GitHubClient can be used to send requests and retrieve their responses. Uses {@link GitHubConnector} as a pluggable
 * component to communicate using differing HTTP client libraries.
 * <p>
 * GitHubClient is thread-safe and can be used to send multiple requests simultaneously. GitHubClient also tracks some
 * GitHub API information such as {@link GHRateLimit}.
 * </p>
 *
 * @author Liam Newman
 */
class GitHubClient {

    /** The Constant CONNECTION_ERROR_RETRIES. */
    private static final int DEFAULT_CONNECTION_ERROR_RETRIES = 2;

    /** The Constant DEFAULT_MINIMUM_RETRY_TIMEOUT_MILLIS. */
    private static final int DEFAULT_MINIMUM_RETRY_MILLIS = 100;

    /** The Constant DEFAULT_MAXIMUM_RETRY_TIMEOUT_MILLIS. */
    private static final int DEFAULT_MAXIMUM_RETRY_MILLIS = DEFAULT_MINIMUM_RETRY_MILLIS;

    private static final ThreadLocal<String> sendRequestTraceId = new ThreadLocal<>();

    // Cache of myself object.
    private final String apiUrl;

    private final GitHubRateLimitHandler rateLimitHandler;
    private final GitHubAbuseLimitHandler abuseLimitHandler;
    private final GitHubRateLimitChecker rateLimitChecker;
    private final AuthorizationProvider authorizationProvider;

    private GitHubConnector connector;

    @Nonnull
    private final AtomicReference<GHRateLimit> rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT);

    @Nonnull
    private final GitHubSanityCachedValue<GHRateLimit> sanityCachedRateLimit = new GitHubSanityCachedValue<>();

    @Nonnull
    private GitHubSanityCachedValue<Boolean> sanityCachedIsCredentialValid = new GitHubSanityCachedValue<>();

    private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName());

    private static final ObjectMapper MAPPER = new ObjectMapper();

    /** The Constant GITHUB_URL. */
    static final String GITHUB_URL = "https://api.github.com";

    private static final DateTimeFormatter DATE_TIME_PARSER_SLASHES = DateTimeFormatter
            .ofPattern("yyyy/MM/dd HH:mm:ss Z");

    static {
        MAPPER.setVisibility(new VisibilityChecker.Std(NONE, NONE, NONE, NONE, ANY));
        MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        MAPPER.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true);
        MAPPER.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
    }

    /**
     * Instantiates a new git hub client.
     *
     * @param apiUrl
     *            the api url
     * @param connector
     *            the connector
     * @param rateLimitHandler
     *            the rate limit handler
     * @param abuseLimitHandler
     *            the abuse limit handler
     * @param rateLimitChecker
     *            the rate limit checker
     * @param authorizationProvider
     *            the authorization provider
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    GitHubClient(String apiUrl,
            GitHubConnector connector,
            GitHubRateLimitHandler rateLimitHandler,
            GitHubAbuseLimitHandler abuseLimitHandler,
            GitHubRateLimitChecker rateLimitChecker,
            AuthorizationProvider authorizationProvider) throws IOException {

        if (apiUrl.endsWith("/")) {
            apiUrl = apiUrl.substring(0, apiUrl.length() - 1); // normalize
        }

        if (null == connector) {
            connector = GitHubConnector.DEFAULT;
        }
        this.apiUrl = apiUrl;
        this.connector = connector;

        // Prefer credential configuration via provider
        this.authorizationProvider = authorizationProvider;

        this.rateLimitHandler = rateLimitHandler;
        this.abuseLimitHandler = abuseLimitHandler;
        this.rateLimitChecker = rateLimitChecker;
    }

    /**
     * Gets the login.
     *
     * @return the login
     */
    String getLogin() {
        try {
            if (this.authorizationProvider instanceof UserAuthorizationProvider
                    && this.authorizationProvider.getEncodedAuthorization() != null) {

                UserAuthorizationProvider userAuthorizationProvider = (UserAuthorizationProvider) this.authorizationProvider;

                return userAuthorizationProvider.getLogin();
            }
        } catch (IOException e) {
        }
        return null;
    }

    private <T> T fetch(Class<T> type, String urlPath) throws IOException {
        GitHubRequest request = GitHubRequest.newBuilder().withApiUrl(getApiUrl()).withUrlPath(urlPath).build();
        return sendRequest(request, (connectorResponse) -> GitHubResponse.parseBody(connectorResponse, type)).body();
    }

    /**
     * Ensures that the credential for this client is valid.
     *
     * @return the boolean
     */
    public boolean isCredentialValid() {
        return sanityCachedIsCredentialValid.get(() -> {
            try {
                // If 404, ratelimit returns a default value.
                // This works as credential test because invalid credentials returns 401, not 404
                getRateLimit();
                return Boolean.TRUE;
            } catch (IOException e) {
                LOGGER.log(FINE,
                        e,
                        () -> String.format("(%s) Exception validating credentials on %s with login '%s'",
                                sendRequestTraceId.get(),
                                getApiUrl(),
                                getLogin()));
                return Boolean.FALSE;
            }
        });
    }

    /**
     * Is this an always offline "connection".
     *
     * @return {@code true} if this is an always offline "connection".
     */
    public boolean isOffline() {
        return connector == GitHubConnector.OFFLINE;
    }

    /**
     * Is this an anonymous connection.
     *
     * @return {@code true} if operations that require authentication will fail.
     */
    public boolean isAnonymous() {
        try {
            return getLogin() == null && this.authorizationProvider.getEncodedAuthorization() == null;
        } catch (IOException e) {
            // An exception here means that the provider failed to provide authorization parameters,
            // basically meaning the same as "no auth"
            return false;
        }
    }

    /**
     * Gets the current full rate limit information from the server.
     *
     * For some versions of GitHub Enterprise, the {@code /rate_limit} endpoint returns a {@code 404 Not Found}. In that
     * case, the most recent {@link GHRateLimit} information will be returned, including rate limit information returned
     * in the response header for this request in if was present.
     *
     * For most use cases it would be better to implement a {@link RateLimitChecker} and add it via
     * {@link GitHubBuilder#withRateLimitChecker(RateLimitChecker)}.
     *
     * @return the rate limit
     * @throws IOException
     *             the io exception
     */
    @Nonnull
    public GHRateLimit getRateLimit() throws IOException {
        return getRateLimit(RateLimitTarget.NONE);
    }

    /**
     * Gets the encoded authorization.
     *
     * @return the encoded authorization
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    @CheckForNull
    String getEncodedAuthorization() throws IOException {
        return authorizationProvider.getEncodedAuthorization();
    }

    /**
     * Gets the rate limit.
     *
     * @param rateLimitTarget
     *            the rate limit target
     * @return the rate limit
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    @Nonnull
    GHRateLimit getRateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {
        // Even when explicitly asking for rate limit, restrict to sane query frequency
        // return cached value if available
        GHRateLimit output = sanityCachedRateLimit.get(
                (currentValue) -> currentValue == null || currentValue.getRecord(rateLimitTarget).isExpired(),
                () -> {
                    GHRateLimit result;
                    try {
                        final GitHubRequest request = GitHubRequest.newBuilder()
                                .rateLimit(RateLimitTarget.NONE)
                                .withApiUrl(getApiUrl())
                                .withUrlPath("/rate_limit")
                                .build();
                        result = this
                                .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.
                        LOGGER.log(FINE, "(%s) /rate_limit returned 404 Not Found.", sendRequestTraceId.get());

                        // However some newer versions of GHE include rate limit header information
                        // If the header info is missing and the endpoint returns 404, fill the rate limit
                        // with unknown
                        result = GHRateLimit.fromRecord(GHRateLimit.UnknownLimitRecord.current(), rateLimitTarget);
                    }
                    return result;
                });
        return updateRateLimit(output);
    }

    /**
     * Returns the most recently observed rate limit data.
     *
     * Generally, instead of calling this you should implement a {@link RateLimitChecker} or call
     *
     * @return the most recently observed rate limit data. This may include expired or
     *         {@link GHRateLimit.UnknownLimitRecord} entries.
     * @deprecated implement a {@link RateLimitChecker} and add it via
     *             {@link GitHubBuilder#withRateLimitChecker(RateLimitChecker)}.
     */
    @Nonnull
    @Deprecated
    GHRateLimit lastRateLimit() {
        return rateLimit.get();
    }

    /**
     * Gets the current rate limit for an endpoint while trying not to actually make any remote requests unless
     * absolutely necessary.
     *
     * If the {@link GHRateLimit.Record} for {@code urlPath} is not expired, it is returned. If the
     * {@link GHRateLimit.Record} for {@code urlPath} is expired, {@link #getRateLimit()} will be called to get the
     * current rate limit.
     *
     * @param rateLimitTarget
     *            the endpoint to get the rate limit for.
     *
     * @return the current rate limit data. {@link GHRateLimit.Record}s in this instance may be expired when returned.
     * @throws IOException
     *             if there was an error getting current rate limit data.
     */
    @Nonnull
    GHRateLimit rateLimit(@Nonnull RateLimitTarget rateLimitTarget) throws IOException {
        GHRateLimit result = rateLimit.get();
        // Most of the time rate limit is not expired, so try to avoid locking.
        if (result.getRecord(rateLimitTarget).isExpired()) {
            // if the rate limit is expired, synchronize to ensure
            // only one call to getRateLimit() is made to refresh it.
            synchronized (this) {
                if (rateLimit.get().getRecord(rateLimitTarget).isExpired()) {
                    getRateLimit(rateLimitTarget);
                }
            }
            result = rateLimit.get();
        }
        return result;
    }

    /**
     * Update the Rate Limit with the latest info from response header.
     *
     * Due to multi-threading, requests might complete out of order. This method calls
     * {@link GHRateLimit#getMergedRateLimit(GHRateLimit)} to ensure the most current records are used.
     *
     * @param observed
     *            {@link GHRateLimit.Record} constructed from the response header information
     */
    private GHRateLimit updateRateLimit(@Nonnull GHRateLimit observed) {
        GHRateLimit result = rateLimit.accumulateAndGet(observed, (current, x) -> current.getMergedRateLimit(x));
        LOGGER.log(FINEST, "Rate limit now: {0}", rateLimit.get());
        return result;
    }

    /**
     * Tests the connection.
     *
     * <p>
     * Verify that the API URL and credentials are valid to access this GitHub.
     *
     * <p>
     * This method returns normally if the endpoint is reachable and verified to be GitHub API URL. Otherwise this
     * method throws {@link IOException} to indicate the problem.
     *
     * @throws IOException
     *             the io exception
     */
    public void checkApiUrlValidity() throws IOException {
        try {
            this.fetch(GHApiInfo.class, "/").check(getApiUrl());
        } catch (IOException e) {
            if (isPrivateModeEnabled()) {
                throw (IOException) new IOException(
                        "GitHub Enterprise server (" + getApiUrl() + ") with private mode enabled").initCause(e);
            }
            throw e;
        }
    }

    /**
     * Gets the api url.
     *
     * @return the api url
     */
    public String getApiUrl() {
        return apiUrl;
    }

    /**
     * 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 <T>
     *            the type of the parse body data.
     * @param builder
     *            used to build the request that will be sent to the server.
     * @param handler
     *            parse the response info and body data into a instance of {@link T}. If null, no parsing occurs and
     *            {@link GitHubResponse#body()} will return null.
     * @return a {@link GitHubResponse} containing the parsed body data as a {@link T}. Parsed instance may be null.
     * @throws IOException
     *             if an I/O Exception occurs
     */
    @Nonnull
    public <T> GitHubResponse<T> sendRequest(@Nonnull GitHubRequest.Builder<?> builder,
            @CheckForNull BodyHandler<T> handler) throws IOException {
        return sendRequest(builder.build(), handler);
    }

    /**
     * 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 <T>
     *            the type of the parse body data.
     * @param request
     *            the request that will be sent to the server.
     * @param handler
     *            parse the response info and body data into a instance of {@link T}. If null, no parsing occurs and
     *            {@link GitHubResponse#body()} will return null.
     * @return a {@link GitHubResponse} containing the parsed body data as a {@link T}. Parsed instance may be null.
     * @throws IOException
     *             if an I/O Exception occurs
     */
    @Nonnull
    public <T> GitHubResponse<T> sendRequest(GitHubRequest request, @CheckForNull BodyHandler<T> handler)
            throws IOException {
        // WARNING: This is an unsupported environment variable.
        // The GitHubClient class is internal and may change at any time.
        int retryCount = Math.max(DEFAULT_CONNECTION_ERROR_RETRIES,
                Integer.getInteger(GitHubClient.class.getName() + ".retryCount", DEFAULT_CONNECTION_ERROR_RETRIES));

        int retries = retryCount;
        sendRequestTraceId.set(Integer.toHexString(request.hashCode()));
        GitHubConnectorRequest connectorRequest = prepareConnectorRequest(request, authorizationProvider);
        do {
            GitHubConnectorResponse connectorResponse = null;
            try {
                logRequest(connectorRequest);
                rateLimitChecker.checkRateLimit(this, request.rateLimitTarget());
                connectorResponse = connector.send(connectorRequest);
                logResponse(connectorResponse);
                noteRateLimit(request.rateLimitTarget(), connectorResponse);
                detectKnownErrors(connectorResponse, request, handler != null);
                logResponseBody(connectorResponse);
                return createResponse(connectorResponse, handler);
            } catch (RetryRequestException e) {
                // retry requested by requested by error handler (rate limit handler for example)
                if (retries > 0 && e.connectorRequest != null) {
                    connectorRequest = e.connectorRequest;
                }
            } catch (IOException e) {
                throw interpretApiError(e, connectorRequest, connectorResponse);
            } finally {
                IOUtils.closeQuietly(connectorResponse);
            }
        } while (--retries >= 0);

        throw new GHIOException("Ran out of retries for URL: " + request.url().toString());
    }

    private void detectKnownErrors(GitHubConnectorResponse connectorResponse,
            GitHubRequest request,
            boolean detectStatusCodeError) throws IOException {
        detectOTPRequired(connectorResponse);
        detectInvalidCached404Response(connectorResponse, request);
        detectExpiredToken(connectorResponse, request);
        detectRedirect(connectorResponse, request);
        if (rateLimitHandler.isError(connectorResponse)) {
            rateLimitHandler.onError(connectorResponse);
            throw new RetryRequestException();
        } else if (abuseLimitHandler.isError(connectorResponse)) {
            abuseLimitHandler.onError(connectorResponse);
            throw new RetryRequestException();
        } else if (detectStatusCodeError
                && GitHubConnectorResponseErrorHandler.STATUS_HTTP_BAD_REQUEST_OR_GREATER.isError(connectorResponse)) {
            GitHubConnectorResponseErrorHandler.STATUS_HTTP_BAD_REQUEST_OR_GREATER.onError(connectorResponse);
        }
    }

    private void detectExpiredToken(GitHubConnectorResponse connectorResponse, GitHubRequest request)
            throws IOException {
        if (connectorResponse.statusCode() != HTTP_UNAUTHORIZED) {
            return;
        }
        String originalAuthorization = connectorResponse.request().header("Authorization");
        if (Objects.isNull(originalAuthorization) || originalAuthorization.isEmpty()) {
            return;
        }
        GitHubConnectorRequest updatedRequest = prepareConnectorRequest(request, authorizationProvider);
        String updatedAuthorization = updatedRequest.header("Authorization");
        if (!originalAuthorization.equals(updatedAuthorization)) {
            throw new RetryRequestException(updatedRequest);
        }
    }

    private void detectRedirect(GitHubConnectorResponse connectorResponse, GitHubRequest request) throws IOException {
        if (isRedirecting(connectorResponse.statusCode())) {
            // For redirects, GitHub expects the Authorization header to be removed.
            // GitHubConnector implementations can follow any redirects automatically as long as they remove the header
            // as well.
            // Okhttp does this.
            // https://github.com/square/okhttp/blob/f9dfd4e8cc070ca2875a67d8f7ad939d95e7e296/okhttp/src/main/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt#L313-L318
            // GitHubClient always strips Authorization from detected redirects for security.
            // This problem was discovered when upload-artifact@v4 was released as the new
            // service we are redirected to for downloading the artifacts doesn't support
            // having the Authorization header set.
            // See also https://github.com/arduino/report-size-deltas/pull/83 for more context

            GitHubConnectorRequest updatedRequest = prepareRedirectRequest(connectorResponse, request);
            throw new RetryRequestException(updatedRequest);
        }
    }

    private GitHubConnectorRequest prepareRedirectRequest(GitHubConnectorResponse connectorResponse,
            GitHubRequest request) throws IOException {
        URI requestUri = URI.create(request.url().toString());
        URI redirectedUri = getRedirectedUri(requestUri, connectorResponse);
        // If we switch ports on the same host, we consider that as a different host
        // This is slightly different from Redirect#NORMAL, but needed for local testing
        boolean sameHost = redirectedUri.getHost().equalsIgnoreCase(request.url().getHost())
                && redirectedUri.getPort() == request.url().getPort();

        // mimicking the behavior of Redirect#NORMAL which was the behavior we used before
        // Always redirect, except from HTTPS URLs to HTTP URLs.
        if (!requestUri.getScheme().equalsIgnoreCase(redirectedUri.getScheme())
                && !"https".equalsIgnoreCase(redirectedUri.getScheme())) {
            throw new HttpException("Attemped to redirect to a different scheme and the target scheme as not https.",
                    connectorResponse.statusCode(),
                    "Redirect",
                    connectorResponse.request().url().toString());
        }

        String redirectedMethod = getRedirectedMethod(connectorResponse.statusCode(), request.method());

        // let's build the new redirected request
        GitHubRequest.Builder<?> requestBuilder = request.toBuilder()
                .setRawUrlPath(redirectedUri.toString())
                .method(redirectedMethod);
        // if we redirect to a different host (even https), we remove the Authorization header
        AuthorizationProvider provider = authorizationProvider;
        if (!sameHost) {
            requestBuilder.removeHeader("Authorization");
            provider = AuthorizationProvider.ANONYMOUS;
        }
        return prepareConnectorRequest(requestBuilder.build(), provider);
    }

    private static URI getRedirectedUri(URI requestUri, GitHubConnectorResponse connectorResponse) throws IOException {
        URI redirectedURI;
        redirectedURI = Optional.of(connectorResponse.header("Location"))
                .map(URI::create)
                .orElseThrow(() -> new IOException("Invalid redirection"));

        // redirect could be relative to original URL, but if not
        // then redirect is used.
        redirectedURI = requestUri.resolve(redirectedURI);
        return redirectedURI;
    }

    // This implements the exact same rules as the ones applied in jdk.internal.net.http.RedirectFilter
    private static boolean isRedirecting(int statusCode) {
        return statusCode == HTTP_MOVED_PERM || statusCode == HTTP_MOVED_TEMP || statusCode == 303 || statusCode == 307
                || statusCode == 308;
    }

    // This implements the exact same rules as the ones applied in jdk.internal.net.http.RedirectFilter
    private static String getRedirectedMethod(int statusCode, String originalMethod) {
        switch (statusCode) {
            case HTTP_MOVED_PERM :
            case HTTP_MOVED_TEMP :
                return originalMethod.equals("POST") ? "GET" : originalMethod;
            case 303 :
                return "GET";
            case 307 :
            case 308 :
                return originalMethod;
            default :
                return originalMethod;
        }
    }

    private static GitHubConnectorRequest prepareConnectorRequest(GitHubRequest request,
            AuthorizationProvider authorizationProvider) 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 = authorizationProvider.getEncodedAuthorization();
            if (authorization != null) {
                builder.setHeader("Authorization", authorization);
            }
        }
        if (request.header("Accept") == null) {
            builder.setHeader("Accept", "application/vnd.github+json");
        }
        builder.setHeader("Accept-Encoding", "gzip");

        builder.setHeader("X-GitHub-Api-Version", "2022-11-28");

        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,
                () -> String.format("(%s) GitHub API request: %s %s",
                        sendRequestTraceId.get(),
                        request.method(),
                        request.url().toString()));
    }

    private void logResponse(@Nonnull final GitHubConnectorResponse response) {
        LOGGER.log(FINER, () -> {
            return String.format("(%s) GitHub API response: %s",
                    sendRequestTraceId.get(),
                    response.request().url().toString(),
                    response.statusCode());
        });
    }

    private void logResponseBody(@Nonnull final GitHubConnectorResponse response) {
        LOGGER.log(FINEST, () -> {
            String body;
            try {
                body = GitHubResponse.getBodyAsString(response);
            } catch (Throwable e) {
                body = "Error reading response body";
            }
            return String.format("(%s) GitHub API response body: %s", sendRequestTraceId.get(), body);

        });
    }

    @Nonnull
    private static <T> GitHubResponse<T> createResponse(@Nonnull GitHubConnectorResponse connectorResponse,
            @CheckForNull BodyHandler<T> handler) throws IOException {
        T body = null;
        if (handler != null) {
            if (!shouldIgnoreBody(connectorResponse)) {
                body = handler.apply(connectorResponse);
            }
        }
        return new GitHubResponse<>(connectorResponse, body);
    }

    private static boolean shouldIgnoreBody(@Nonnull GitHubConnectorResponse connectorResponse) {
        if (connectorResponse.statusCode() == HTTP_NOT_MODIFIED) {
            // special case handling for 304 unmodified, as the content will be ""
            return true;
        } else if (connectorResponse.statusCode() == 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:
            // statistics - See https://developer.github.com/v3/repos/statistics/#a-word-about-caching
            // fork creation - See https://developer.github.com/v3/repos/forks/#create-a-fork
            // workflow run cancellation - See https://docs.github.com/en/rest/reference/actions#cancel-a-workflow-run

            LOGGER.log(FINE,
                    () -> String.format("(%s) Received HTTP_ACCEPTED(202) from %s. Please try again in 5 seconds.",
                            sendRequestTraceId.get(),
                            connectorResponse.request().url().toString()));
            return true;
        } else {
            return false;
        }
    }

    /**
     * Handle API error by either throwing it or by returning normally to retry.
     */
    private static IOException interpretApiError(IOException e,
            @Nonnull GitHubConnectorRequest connectorRequest,
            @CheckForNull GitHubConnectorResponse connectorResponse) throws IOException {
        // If we're already throwing a GHIOException, pass through
        if (e instanceof GHIOException) {
            return e;
        }

        int statusCode = -1;
        String message = null;
        Map<String, List<String>> headers = new HashMap<>();
        String errorMessage = null;

        if (connectorResponse != null) {
            statusCode = connectorResponse.statusCode();
            message = connectorResponse.header("Status");
            headers = connectorResponse.allHeaders();
            if (connectorResponse.statusCode() >= HTTP_BAD_REQUEST) {
                errorMessage = GitHubResponse.getBodyAsStringOrNull(connectorResponse);
            }
        }

        if (errorMessage != null) {
            if (e instanceof FileNotFoundException) {
                // pass through 404 Not Found to allow the caller to handle it intelligently
                e = new GHFileNotFoundException(e.getMessage() + " " + errorMessage, e)
                        .withResponseHeaderFields(headers);
            } else if (statusCode >= 0) {
                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, connectorRequest.url().toString(), e);
        }
        return e;
    }

    private static void logRetryConnectionError(IOException e, URL url, int retries) throws IOException {
        // There are a range of connection errors where we want to wait a moment and just automatically retry

        // WARNING: These are unsupported environment variables.
        // The GitHubClient class is internal and may change at any time.
        int minRetryInterval = Math.max(DEFAULT_MINIMUM_RETRY_MILLIS,
                Integer.getInteger(GitHubClient.class.getName() + ".minRetryInterval", DEFAULT_MINIMUM_RETRY_MILLIS));
        int maxRetryInterval = Math.max(DEFAULT_MAXIMUM_RETRY_MILLIS,
                Integer.getInteger(GitHubClient.class.getName() + ".maxRetryInterval", DEFAULT_MAXIMUM_RETRY_MILLIS));

        long sleepTime = maxRetryInterval <= minRetryInterval
                ? minRetryInterval
                : ThreadLocalRandom.current().nextLong(minRetryInterval, maxRetryInterval);

        LOGGER.log(INFO,
                () -> String.format(
                        "(%s) %s while connecting to %s: '%s'. Sleeping %d milliseconds before retrying (%d retries remaining)",
                        sendRequestTraceId.get(),
                        e.getClass().toString(),
                        url.toString(),
                        e.getMessage(),
                        sleepTime,
                        retries));
        try {
            Thread.sleep(sleepTime);
        } catch (InterruptedException ie) {
            throw (IOException) new InterruptedIOException().initCause(e);
        }
    }

    private void detectInvalidCached404Response(GitHubConnectorResponse connectorResponse, GitHubRequest request)
            throws IOException {
        // WORKAROUND FOR ISSUE #669:
        // 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.
        //
        // This solution is transparent to users of this library and automatically handles a
        // situation that was cause insidious and hard to debug bad responses in caching
        // 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 (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,
                    () -> String.format(
                            "(%s) Encountered GitHub invalid cached 404 from %s. Retrying with \"Cache-Control\"=\"no-cache\"...",
                            sendRequestTraceId.get(),
                            connectorResponse.request().url()));
            // 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)
            throw new RetryRequestException(
                    prepareConnectorRequest(request.toBuilder().setHeader("Cache-Control", "no-cache").build(),
                            authorizationProvider));
        }
    }

    private void noteRateLimit(@Nonnull RateLimitTarget rateLimitTarget,
            @Nonnull GitHubConnectorResponse connectorResponse) {
        try {
            int limit = connectorResponse.parseInt("X-RateLimit-Limit");
            int remaining = connectorResponse.parseInt("X-RateLimit-Remaining");
            int reset = connectorResponse.parseInt("X-RateLimit-Reset");
            GHRateLimit.Record observed = new GHRateLimit.Record(limit, remaining, reset, connectorResponse);
            updateRateLimit(GHRateLimit.fromRecord(observed, rateLimitTarget));
        } catch (NumberFormatException e) {
            LOGGER.log(FINER,
                    () -> String.format("(%s) Missing or malformed X-RateLimit header: %s",
                            sendRequestTraceId.get(),
                            e.getMessage()));
        }
    }

    private static void detectOTPRequired(@Nonnull GitHubConnectorResponse connectorResponse) throws GHIOException {
        // 401 Unauthorized == bad creds or OTP request
        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 (connectorResponse.header("X-GitHub-OTP") != null) {
                throw new GHOTPRequiredException().withResponseHeaderFields(connectorResponse.allHeaders());
            }
        }
    }

    /**
     * Require credential.
     */
    void requireCredential() {
        if (isAnonymous())
            throw new IllegalStateException(
                    "This operation requires a credential but none is given to the GitHub constructor");
    }

    private static class GHApiInfo {
        private String rate_limit_url;

        void check(String apiUrl) throws IOException {
            if (rate_limit_url == null)
                throw new IOException(apiUrl + " doesn't look like GitHub API URL");

            // make sure that the URL is legitimate
            new URL(rate_limit_url);
        }
    }

    /**
     * Checks if a GitHub Enterprise server is configured in private mode.
     *
     * In private mode response looks like:
     *
     * <pre>
     *  $ curl -i https://github.mycompany.com/api/v3/
     *     HTTP/1.1 401 Unauthorized
     *     Server: GitHub.com
     *     Date: Sat, 05 Mar 2016 19:45:01 GMT
     *     Content-Type: application/json; charset=utf-8
     *     Content-Length: 130
     *     Status: 401 Unauthorized
     *     X-GitHub-Media-Type: github.v3
     *     X-XSS-Protection: 1; mode=block
     *     X-Frame-Options: deny
     *     Content-Security-Policy: default-src 'none'
     *     Access-Control-Allow-Credentials: true
     *     Access-Control-Expose-Headers: ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval
     *     Access-Control-Allow-Origin: *
     *     X-GitHub-Request-Id: dbc70361-b11d-4131-9a7f-674b8edd0411
     *     Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
     *     X-Content-Type-Options: nosniff
     * </pre>
     *
     * @return {@code true} if private mode is enabled. If it tries to use this method with GitHub, returns {@code
     * false}.
     */
    private boolean isPrivateModeEnabled() {
        try {
            GitHubResponse<?> response = sendRequest(GitHubRequest.newBuilder().withApiUrl(getApiUrl()), null);
            return response.statusCode() == HTTP_UNAUTHORIZED && response.header("X-GitHub-Media-Type") != null;
        } catch (IOException e) {
            return false;
        }
    }

    /**
     * Parses the URL.
     *
     * @param s
     *            the s
     * @return the url
     */
    static URL parseURL(String s) {
        try {
            return s == null ? null : new URL(s);
        } catch (MalformedURLException e) {
            throw new IllegalStateException("Invalid URL: " + s);
        }
    }

    /**
     * Parses the date.
     *
     * @param timestamp
     *            the timestamp
     * @return the date
     */
    static Date parseDate(String timestamp) {
        if (timestamp == null)
            return null;

        return Date.from(parseInstant(timestamp));
    }

    /**
     * Parses the instant.
     *
     * @param timestamp
     *            the timestamp
     * @return the instant
     */
    static Instant parseInstant(String timestamp) {
        if (timestamp == null)
            return null;

        if (timestamp.charAt(4) == '/') {
            // Unsure where this is used, but retained for compatibility.
            return Instant.from(DATE_TIME_PARSER_SLASHES.parse(timestamp));
        } else {
            return Instant.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(timestamp));
        }
    }

    /**
     * Prints the date.
     *
     * @param dt
     *            the dt
     * @return the string
     */
    static String printDate(Date dt) {
        return DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(dt.getTime()).truncatedTo(ChronoUnit.SECONDS));
    }

    /**
     * Gets an {@link ObjectWriter}.
     *
     * @return an {@link ObjectWriter} instance that can be further configured.
     */
    @Nonnull
    static ObjectWriter getMappingObjectWriter() {
        return MAPPER.writer();
    }

    /**
     * Helper for {@link #getMappingObjectReader(GitHubConnectorResponse)}.
     *
     * @param root
     *            the root GitHub object for this reader
     * @return an {@link ObjectReader} instance that can be further configured.
     */
    @Nonnull
    static ObjectReader getMappingObjectReader(@Nonnull GitHub root) {
        ObjectReader reader = getMappingObjectReader((GitHubConnectorResponse) null);
        ((InjectableValues.Std) reader.getInjectableValues()).addValue(GitHub.class, root);
        return reader;
    }

    /**
     * Gets an {@link ObjectReader}.
     *
     * Members of {@link InjectableValues} must be present even if {@code null}, otherwise classes expecting those
     * values will fail to read. This differs from regular JSONProperties which provide defaults instead of failing.
     *
     * 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 connectorResponse
     *            the {@link GitHubConnectorResponse} to inject for this reader.
     *
     * @return an {@link ObjectReader} instance that can be further configured.
     */
    @Nonnull
    static ObjectReader getMappingObjectReader(@CheckForNull GitHubConnectorResponse connectorResponse) {
        Map<String, Object> injected = new HashMap<>();

        // Required or many things break
        injected.put(GitHubConnectorResponse.class.getName(), null);
        injected.put(GitHub.class.getName(), null);

        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));
    }

    /**
     * Unmodifiable map or null.
     *
     * @param <K>
     *            the key type
     * @param <V>
     *            the value type
     * @param map
     *            the map
     * @return the map
     */
    static <K, V> Map<K, V> unmodifiableMapOrNull(Map<? extends K, ? extends V> map) {
        return map == null ? null : Collections.unmodifiableMap(map);
    }

    /**
     * Unmodifiable list or null.
     *
     * @param <T>
     *            the generic type
     * @param list
     *            the list
     * @return the list
     */
    static <T> List<T> unmodifiableListOrNull(List<? extends T> list) {
        return list == null ? null : Collections.unmodifiableList(list);
    }

    /**
     * The Class RetryRequestException.
     */
    static class RetryRequestException extends IOException {

        /** The connector request. */
        final GitHubConnectorRequest connectorRequest;

        /**
         * Instantiates a new retry request exception.
         */
        RetryRequestException() {
            this(null);
        }

        /**
         * Instantiates a new retry request exception.
         *
         * @param connectorRequest
         *            the connector request
         */
        RetryRequestException(GitHubConnectorRequest connectorRequest) {
            this.connectorRequest = connectorRequest;
        }
    }

    /**
     * Represents a supplier of results that can throw.
     *
     * @param <T>
     *            the type of results supplied by this supplier
     */
    @FunctionalInterface
    interface BodyHandler<T> extends FunctionThrows<GitHubConnectorResponse, T, IOException> {
    }
}