001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2024 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j;
015
016import static java.time.Instant.now;
017
018import java.time.Duration;
019import java.time.Instant;
020import java.time.temporal.ChronoUnit;
021import java.util.Objects;
022import java.util.Optional;
023import java.util.Set;
024
025import org.shredzone.acme4j.exception.AcmeException;
026
027/**
028 * Marks an ACME Resource with a pollable status.
029 * <p>
030 * The resource provides a status, and a method for updating the internal cache to read
031 * the current status from the server.
032 *
033 * @since 3.4.0
034 */
035public interface PollableResource {
036
037    /**
038     * Default delay between status polls if there is no Retry-After header.
039     */
040    Duration DEFAULT_RETRY_AFTER = Duration.ofSeconds(3L);
041
042    /**
043     * Returns the current status of the resource.
044     */
045    Status getStatus();
046
047    /**
048     * Fetches the current status from the server.
049     *
050     * @return Retry-After time, if given by the CA, otherwise empty.
051     */
052    Optional<Instant> fetch() throws AcmeException;
053
054    /**
055     * Waits until a terminal status has been reached, by polling until one of the given
056     * status or the given timeout has been reached. This call honors the Retry-After
057     * header if set by the CA.
058     * <p>
059     * This method is synchronous and blocks the current thread.
060     * <p>
061     * If the resource is already in a terminal status, the method returns immediately.
062     *
063     * @param statusSet
064     *         Set of {@link Status} that are accepted as terminal
065     * @param timeout
066     *         Timeout until a terminal status must have been reached
067     * @return Status that was reached
068     */
069    default Status waitForStatus(Set<Status> statusSet, Duration timeout)
070            throws AcmeException, InterruptedException {
071        Objects.requireNonNull(timeout, "timeout");
072        Objects.requireNonNull(statusSet, "statusSet");
073        if (statusSet.isEmpty()) {
074            throw new IllegalArgumentException("At least one Status is required");
075        }
076
077        var currentStatus = getStatus();
078        if (statusSet.contains(currentStatus)) {
079            return currentStatus;
080        }
081
082        var timebox = now().plus(timeout);
083        Instant now;
084
085        while ((now = now()).isBefore(timebox)) {
086            // Poll status and get the time of the next poll
087            var retryAfter = fetch()
088                    .orElse(now.plus(DEFAULT_RETRY_AFTER));
089
090            currentStatus = getStatus();
091            if (statusSet.contains(currentStatus)) {
092                return currentStatus;
093            }
094
095            // Preemptively end the loop if the next iteration would be after timebox
096            if (retryAfter.isAfter(timebox)) {
097                break;
098            }
099
100            // Wait until retryAfter is reached
101            Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
102        }
103
104        throw new AcmeException("Timeout has been reached");
105    }
106
107}