All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.palantir.conjure.java.okhttp.UrlSelectorImpl Maven / Gradle / Ivy

The newest version!
/*
 * (c) Copyright 2017 Palantir Technologies Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.palantir.conjure.java.okhttp;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.palantir.logsafe.Preconditions;
import com.palantir.logsafe.UnsafeArg;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import okhttp3.HttpUrl;

final class UrlSelectorImpl implements UrlSelector {

    private static final Duration RANDOMIZE = Duration.ofMinutes(10);

    private final Supplier> baseUrls;
    private final AtomicReference lastBaseUrl;
    private final Map failedUrls;
    private final boolean useFailedUrlCache;
    private final Clock clock;
    private final Duration failedUrlCooldown;

    private UrlSelectorImpl(
            ImmutableList baseUrls, boolean reshuffle, Duration failedUrlCooldown, Clock clock) {
        Preconditions.checkArgument(!baseUrls.isEmpty(), "Must specify at least one URL");
        Preconditions.checkArgument(!failedUrlCooldown.isNegative(), "Cache expiration must be non-negative");
        if (reshuffle) {
            // Add jitter to avoid mass node reassignment when multiple nodes of a client are restarted
            Duration jitter = Duration.ofSeconds(ThreadLocalRandom.current().nextLong(-30, 30));
            this.baseUrls = Suppliers.memoizeWithExpiration(
                    () -> shuffle(baseUrls), RANDOMIZE.plus(jitter).toMillis(), TimeUnit.MILLISECONDS);
        } else {
            // deterministic for testing only
            this.baseUrls = () -> baseUrls;
        }

        // Assuming that baseUrls is already randomized, start with the first one.
        this.lastBaseUrl = new AtomicReference<>(baseUrls.get(0));

        this.clock = clock;
        this.failedUrlCooldown = failedUrlCooldown;
        this.failedUrls = new ConcurrentHashMap<>(baseUrls.size());
        this.useFailedUrlCache = !failedUrlCooldown.isNegative() && !failedUrlCooldown.isZero();
    }

    /**
     * Creates a new {@link UrlSelector} with the supplied URLs. The order of the URLs are randomized every 10 minutes
     * when {@code randomizeOrder} is set to true, which should be preferred except when testing. If a
     * {@code failedUrlCooldown} is specified, URLs that are marked as failed using {@link #markAsFailed(HttpUrl)} will
     * be removed from the pool of prioritized, healthy URLs for that period of time.
     */
    static UrlSelectorImpl createWithFailedUrlCooldown(
            Collection baseUrls, boolean reshuffle, Duration failedUrlCooldown, Clock clock) {
        ImmutableSet.Builder canonicalUrls = ImmutableSet.builder(); // ImmutableSet maintains insert order
        baseUrls.forEach(url -> {
            HttpUrl httpUrl = HttpUrl.parse(switchWsToHttp(url));
            Preconditions.checkArgument(httpUrl != null, "Not a valid URL", UnsafeArg.of("url", url));
            HttpUrl canonicalUrl = canonicalize(httpUrl);
            Preconditions.checkArgument(
                    canonicalUrl.equals(httpUrl),
                    "Base URLs must be 'canonical' and consist of schema, host, port, and path only",
                    UnsafeArg.of("url", url));
            canonicalUrls.add(canonicalUrl);
        });
        return new UrlSelectorImpl(ImmutableList.copyOf(canonicalUrls.build()), reshuffle, failedUrlCooldown, clock);
    }

    @VisibleForTesting
    static UrlSelectorImpl create(Collection baseUrls, boolean reshuffle) {
        return createWithFailedUrlCooldown(baseUrls, reshuffle, Duration.ZERO, Clock.systemUTC());
    }

    static  List shuffle(List list) {
        List shuffledList = new ArrayList<>(list);
        Collections.shuffle(shuffledList);
        return Collections.unmodifiableList(shuffledList);
    }

    private static String switchWsToHttp(String url) {
        // Silently replace web socket URLs with HTTP URLs. See https://github.com/square/okhttp/issues/1652.
        if (url.regionMatches(true, 0, "ws:", 0, 3)) {
            return "http:" + url.substring(3);
        } else if (url.regionMatches(true, 0, "wss:", 0, 4)) {
            return "https:" + url.substring(4);
        } else {
            return url;
        }
    }

    /**
     * Attempt to redirect to the given redirectUrl, which could be a longer URL than just a base path, in which case it
     * will first be matched to a baseUrl from {@link #baseUrls}.
     */
    @Override
    public Optional redirectTo(HttpUrl requestUrl, String redirectUrl) {
        return baseUrlFor(HttpUrl.parse(redirectUrl), baseUrls.get())
                .flatMap(baseUrl -> redirectTo(requestUrl, baseUrl));
    }

    /**
     * Rewrites the request URL to use the new {@code redirectBaseUrl}, if the path prefix is compatible, otherwise it
     * returns {@link Optional#empty()}.
     *
     * 

Also updates the {@link #lastBaseUrl} with the given {@code redirectBaseUrl}. * * @param redirectBaseUrl expected to be an actual base url that exists in {@link #baseUrls}. */ private Optional redirectTo(HttpUrl requestUrl, HttpUrl redirectBaseUrl) { lastBaseUrl.set(redirectBaseUrl); if (!isPathPrefixFor(redirectBaseUrl, requestUrl)) { // The requested redirectBaseUrl has a path that is not compatible with // the path of the request URL return Optional.empty(); } return Optional.of(requestUrl .newBuilder() .scheme(redirectBaseUrl.scheme()) .host(redirectBaseUrl.host()) .port(redirectBaseUrl.port()) .encodedPath(redirectBaseUrl.encodedPath() // matching prefix + requestUrl .encodedPath() .substring(redirectBaseUrl.encodedPath().length())) .build()); } @Override public Optional redirectToNext(HttpUrl requestUrl) { List httpUrls = baseUrls.get(); // If possible, determine the index of the request URL (so we can be sure to redirect to a different URL) int lastIndex = indexFor(requestUrl, httpUrls).orElseGet(() -> indexForLastBaseUrl(httpUrls)); int nextIndex = increment(lastIndex, httpUrls); HttpUrl next = getNextHealthy(nextIndex, httpUrls).orElseGet(() -> httpUrls.get(nextIndex)); return redirectTo(requestUrl, next); } @Override public Optional redirectToCurrent(HttpUrl requestUrl) { List httpUrls = baseUrls.get(); int startIndex = indexForLastBaseUrl(httpUrls); HttpUrl next = getNextHealthy(startIndex, httpUrls).orElseGet(() -> { // Revert to round robin behaviour if _all_ nodes have been marked as unhealthy int nextIndex = increment(startIndex, httpUrls); return httpUrls.get(nextIndex); }); return redirectTo(requestUrl, next); } @Override public Optional redirectToNextRoundRobin(HttpUrl requestUrl) { List httpUrls = baseUrls.get(); // Ignore whatever base URL the request URL might match to, use the last base URL instead int lastIndex = indexForLastBaseUrl(httpUrls); int nextIndex = increment(lastIndex, httpUrls); HttpUrl next = getNextHealthy(nextIndex, httpUrls).orElseGet(() -> httpUrls.get(nextIndex)); return redirectTo(requestUrl, next); } @Override public void markAsSucceeded(HttpUrl succeededUrl) { if (useFailedUrlCache) { baseUrlFor(succeededUrl, baseUrls.get()).ifPresent(failedUrls::remove); } } @Override public void markAsFailed(HttpUrl failedUrl) { if (useFailedUrlCache) { baseUrlFor(failedUrl, baseUrls.get()).ifPresent(this::markBaseUrlAsFailed); } } private void markBaseUrlAsFailed(HttpUrl key) { failedUrls.put(key, clock.instant().plus(this.failedUrlCooldown)); } private int indexForLastBaseUrl(List httpUrls) { // Fallback to index 0 if last base URL is no longer present in base URLs return indexFor(lastBaseUrl.get(), httpUrls).orElse(0); } /** * Get the next URL in {@code baseUrls}, after the supplied index. * *

If the {@code failedUrlCooldown} is positive, then this method will skip over nodes that have failed if it's * been less than {@code failedUrlCooldown} since they failed. Furthermore, if a node had previously failed but the * cooldown has since elapsed, that node's URL will be returned but it will once again be marked as failed (so that * it's only tried once). */ private Optional getNextHealthy(int startIndex, List httpUrls) { for (int i = startIndex; i < startIndex + httpUrls.size(); i++) { HttpUrl httpUrl = httpUrls.get(i % httpUrls.size()); Instant cooldownFinished = failedUrls.get(httpUrl); if (cooldownFinished != null) { // continue to the next URL if the cooldown has not elapsed if (clock.instant().isBefore(cooldownFinished)) { continue; } // use the failed URL once and refresh to ensure that the cooldown elapses before it is used again markBaseUrlAsFailed(httpUrl); } return Optional.of(httpUrl); } return Optional.empty(); } private static Optional baseUrlFor(HttpUrl url, List httpUrls) { return indexFor(url, httpUrls).map(httpUrls::get); } private static Optional indexFor(HttpUrl url, List httpUrls) { HttpUrl canonicalUrl = canonicalize(url); for (int i = 0; i < httpUrls.size(); ++i) { if (isBaseUrlFor(httpUrls.get(i), canonicalUrl)) { return Optional.of(i); } } return Optional.empty(); } private static int increment(int index, List urls) { return (index + 1) % urls.size(); } /** * Returns true if the canonicalized base URLs are equal and if the path of the {@code prefixUrl} is a prefix (in * the string sense) of the path of the given {@code fullUrl}. */ @VisibleForTesting static boolean isBaseUrlFor(HttpUrl baseUrl, HttpUrl fullUrl) { return fullUrl.scheme().equals(baseUrl.scheme()) && fullUrl.host().equals(baseUrl.host()) && fullUrl.port() == baseUrl.port() && isPathPrefixFor(baseUrl, fullUrl); } /** Returns true if the path of the given {@code baseUrl} is a prefix of the path of the given {@code fullUrl}. */ private static boolean isPathPrefixFor(HttpUrl baseUrl, HttpUrl fullUrl) { return fullUrl.encodedPath().startsWith(baseUrl.encodedPath()); } /** Returns the "canonical" part of the given URL, consisting of schema, host, port, and path only. */ private static HttpUrl canonicalize(HttpUrl url) { return new HttpUrl.Builder() .scheme(url.scheme()) .host(url.host()) .port(url.port()) .encodedPath(url.encodedPath()) .build(); } @Override public List getBaseUrls() { return baseUrls.get(); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy