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

com.github.robozonky.app.tenant.AvailabilityImpl Maven / Gradle / Ivy

/*
 * Copyright 2021 The RoboZonky Project
 *
 * 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.github.robozonky.app.tenant;

import java.net.SocketTimeoutException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;

import javax.ws.rs.ClientErrorException;
import javax.ws.rs.InternalServerErrorException;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.github.robozonky.internal.Defaults;
import com.github.robozonky.internal.remote.RequestCounter;
import com.github.robozonky.internal.tenant.Availability;
import com.github.robozonky.internal.test.DateUtil;

final class AvailabilityImpl implements Availability {

    static final long MANDATORY_DELAY_IN_SECONDS = 5;
    private static final Logger LOGGER = LogManager.getLogger(AvailabilityImpl.class);
    private final ZonkyApiTokenSupplier zonkyApiTokenSupplier;
    private final AtomicReference pause = new AtomicReference<>();
    private final Predicate hasNewerRequest;

    public AvailabilityImpl(final ZonkyApiTokenSupplier zonkyTokenSupplier, final RequestCounter requestCounter) {
        this.zonkyApiTokenSupplier = zonkyTokenSupplier;
        if (requestCounter == null) { // for easier testing
            this.hasNewerRequest = instant -> true;
        } else {
            this.hasNewerRequest = requestCounter::hasMoreRecent;
        }
    }

    AvailabilityImpl(final ZonkyApiTokenSupplier zonkyTokenSupplier) {
        this(zonkyTokenSupplier, null);
    }

    static boolean isQuotaLimitHit(Throwable throwable) {
        if (throwable == null) {
            return false;
        } else if (throwable instanceof ClientErrorException) {
            final int code = ((ClientErrorException) throwable).getResponse()
                .getStatus();
            if (code == 429) {
                return true;
            }
        }
        return isQuotaLimitHit(throwable.getCause());
    }

    static boolean canBeIgnored(Throwable throwable) {
        if (throwable == null) {
            return false;
        } else if (throwable instanceof SocketTimeoutException || throwable instanceof InternalServerErrorException) {
            return true;
        }
        return canBeIgnored(throwable.getCause());
    }

    @Override
    public ZonedDateTime nextAvailabilityCheck() {
        if (zonkyApiTokenSupplier.isClosed()) {
            LOGGER.debug("Zonky OAuth2 token already closed, can not perform any more operations.");
            return Instant.ofEpochMilli(Long.MAX_VALUE)
                .atZone(Defaults.ZONKYCZ_ZONE_ID);
        } else if (isAvailable()) { // no waiting for anything
            return DateUtil.zonedNow();
        }
        final Status paused = pause.get();
        // add 5 seconds of initial delay to give time to recover from HTTP 429 or whatever other problem there was
        final boolean unavailableDueToQuota = paused.isQuotaLimited();
        final long initialMandatoryDelayInSeconds = unavailableDueToQuota ? 60 : MANDATORY_DELAY_IN_SECONDS;
        final long secondsFromPauseToNextCheck = initialMandatoryDelayInSeconds
                + (long) Math.pow(2, paused.getFailedRetries());
        return paused.getExceptionRegisteredOn()
            .plus(Duration.ofSeconds(secondsFromPauseToNextCheck));
    }

    @Override
    public boolean isAvailable() {
        return !zonkyApiTokenSupplier.isClosed() && pause.get() == null;
    }

    @Override
    public Optional registerSuccess() {
        if (isAvailable()) {
            return Optional.empty();
        }
        var paused = pause.get();
        var pausedOn = paused.getExceptionRegisteredOn();
        if (hasNewerRequest.test(pausedOn)) {
            pause.set(null);
            LOGGER.info(() -> "Resumed after a forced pause on " + DateUtil.toString(pausedOn) + ".");
            return Optional.of(paused.getExceptionRegisteredOn());
        } else { // make sure we have actually performed a metered operation, safeguarding against HTTP 429
            LOGGER.info(() -> "Not resumed after a forced pause on " + DateUtil.toString(pausedOn) + ".");
            return Optional.empty();
        }
    }

    @Override
    public boolean registerException(final Exception ex) {
        if (canBeIgnored(ex)) {
            // Zonky throws some errors relatively frequently.
            // The user can do absolutely nothing about them.
            // So we just ignore them.
            LOGGER.debug("Ignoring Zonky API exception.", ex);
            return false;
        }
        if (isAvailable()) {
            pause.set(new Status(isQuotaLimitHit(ex)));
            LOGGER.debug("Fault identified, forcing pause.", ex);
            // will go to console, no stack trace
            LOGGER.warn("Forcing a pause due to a remote failure.");
            return true;
        } else {
            var paused = pause.updateAndGet(Status::anotherFailure);
            LOGGER.debug(() -> "Forced pause in effect since " + DateUtil.toString(paused.getExceptionRegisteredOn())
                    + ", " + paused.getFailedRetries() + " failed retries.", ex);
            return false;
        }
    }

    private static final class Status {

        private final ZonedDateTime exceptionRegisteredOn;
        private final int failedRetries;
        private final boolean isQuotaLimited;

        public Status(final ZonedDateTime exceptionRegisteredOn, final int failedRetries,
                final boolean isQuotaLimited) {
            this.exceptionRegisteredOn = exceptionRegisteredOn;
            this.failedRetries = failedRetries;
            this.isQuotaLimited = isQuotaLimited;
        }

        public Status(final boolean isQuotaLimited) {
            this(DateUtil.zonedNow(), 0, isQuotaLimited);
        }

        public Status anotherFailure() {
            return new Status(exceptionRegisteredOn, failedRetries + 1, isQuotaLimited);
        }

        public ZonedDateTime getExceptionRegisteredOn() {
            return exceptionRegisteredOn;
        }

        public int getFailedRetries() {
            return failedRetries;
        }

        public boolean isQuotaLimited() {
            return isQuotaLimited;
        }
    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy