001/*
002 *  Copyright (c) 2023-2026, Agents-Flex (fuhai999@gmail.com).
003 *  <p>
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *  <p>
008 *  http://www.apache.org/licenses/LICENSE-2.0
009 *  <p>
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package com.agentsflex.core.util;
017
018import java.util.concurrent.Callable;
019import java.util.concurrent.TimeUnit;
020import java.util.function.Predicate;
021
022/**
023 * 重试执行器。
024 * <p>
025 * 设计原则:
026 * - Builder 仅用于构造配置
027 * - Retryer 实例是线程安全的(无状态),可全局复用
028 */
029public final class Retryer {
030
031    private final int maxRetries;
032    private final long initialDelayMs;
033    private final long maxDelayMs;
034    private final boolean exponentialBackoff;
035    private final long totalTimeoutMs;
036    private final Predicate<Exception> retryOnException;
037    private final Predicate<Object> retryOnResult;
038    private final String operationName;
039
040    private Retryer(Builder builder) {
041        this.maxRetries = builder.maxRetries;
042        this.initialDelayMs = builder.initialDelayMs;
043        this.maxDelayMs = builder.maxDelayMs;
044        this.exponentialBackoff = builder.exponentialBackoff;
045        this.totalTimeoutMs = builder.totalTimeoutMs;
046        this.retryOnException = builder.retryOnException;
047        this.retryOnResult = builder.retryOnResult;
048        this.operationName = builder.operationName;
049    }
050
051    public static Builder builder() {
052        return new Builder();
053    }
054
055
056    public static <T> T retry(Callable<T> task, int maxRetries, long initialDelayMs) {
057        return builder()
058            .maxRetries(maxRetries)
059            .initialDelayMs(initialDelayMs)
060            .build()
061            .execute(task);
062    }
063
064    public static void retry(Runnable task, int maxRetries, long initialDelayMs) {
065        builder()
066            .maxRetries(maxRetries)
067            .initialDelayMs(initialDelayMs)
068            .build()
069            .execute(task);
070    }
071
072
073    public <T> T execute(Callable<T> task) {
074        if (task == null) {
075            throw new IllegalArgumentException("Task must not be null");
076        }
077
078        long deadline = totalTimeoutMs > 0 ? System.currentTimeMillis() + totalTimeoutMs : Long.MAX_VALUE;
079        long currentDelay = initialDelayMs;
080        Exception lastException = null;
081
082        for (int attempt = 0; attempt <= maxRetries; attempt++) {
083            if (Thread.interrupted()) {
084                throw new RetryException(
085                    "Retry interrupted at attempt " + attempt + " for: " + operationName,
086                    new InterruptedException());
087            }
088
089            if (System.currentTimeMillis() > deadline) {
090                throw new RetryException(
091                    "Retry deadline exceeded after " + attempt + " attempts for: " + operationName,
092                    new java.util.concurrent.TimeoutException());
093            }
094
095            try {
096                T result = task.call();
097                if (attempt < maxRetries && retryOnResult.test(result)) {
098                    lastException = new RuntimeException("Retry triggered by result predicate");
099                    sleepSafely(Math.min(currentDelay, deadline - System.currentTimeMillis()));
100                    if (exponentialBackoff) {
101                        currentDelay = Math.min(currentDelay * 2, maxDelayMs);
102                    }
103                    continue;
104                }
105                return result;
106            } catch (Exception e) {
107                lastException = e;
108                if (attempt < maxRetries && retryOnException.test(e)) {
109                    sleepSafely(Math.min(currentDelay, deadline - System.currentTimeMillis()));
110                    if (exponentialBackoff) {
111                        currentDelay = Math.min(currentDelay * 2, maxDelayMs);
112                    }
113                } else {
114                    break;
115                }
116            }
117        }
118
119        if (lastException != null) {
120            throw new RetryException(
121                "Retry failed for: " + operationName + " after " + (maxRetries + 1) + " attempts",
122                lastException);
123        }
124
125        throw new IllegalStateException("Retry loop exited without result or exception");
126    }
127
128    public void execute(Runnable task) {
129        try {
130            execute(() -> {
131                task.run();
132                return null;
133            });
134        } catch (Exception e) {
135            if (e instanceof RuntimeException) {
136                throw (RuntimeException) e;
137            }
138            throw new RuntimeException("Error in retryable Runnable", e);
139        }
140    }
141
142    private void sleepSafely(long sleepMs) {
143        if (sleepMs <= 0) return;
144        try {
145            TimeUnit.MILLISECONDS.sleep(sleepMs);
146        } catch (InterruptedException e) {
147            Thread.currentThread().interrupt();
148            throw new RuntimeException("Interrupted during retry backoff", e);
149        }
150    }
151
152
153    public static class Builder {
154        private int maxRetries = 2;
155        private long initialDelayMs = 100;
156        private long maxDelayMs = 5000;
157        private boolean exponentialBackoff = false;
158        private long totalTimeoutMs = 0;
159        private Predicate<Exception> retryOnException = DEFAULT_RETRYABLE_EXCEPTION;
160        private Predicate<Object> retryOnResult = r -> false;
161        private String operationName = "retryable_operation";
162
163        public Builder maxRetries(int maxRetries) {
164            this.maxRetries = Math.max(0, maxRetries);
165            return this;
166        }
167
168        public Builder initialDelayMs(long delayMs) {
169            this.initialDelayMs = Math.max(0, delayMs);
170            return this;
171        }
172
173        public Builder maxDelayMs(long maxDelayMs) {
174            this.maxDelayMs = Math.max(this.initialDelayMs, maxDelayMs);
175            return this;
176        }
177
178        public Builder exponentialBackoff() {
179            this.exponentialBackoff = true;
180            return this;
181        }
182
183        public Builder totalTimeoutMs(long totalTimeoutMs) {
184            this.totalTimeoutMs = Math.max(0, totalTimeoutMs);
185            return this;
186        }
187
188        public Builder retryOnException(Predicate<Exception> predicate) {
189            this.retryOnException = predicate != null ? predicate : DEFAULT_RETRYABLE_EXCEPTION;
190            return this;
191        }
192
193        public Builder retryOnResult(Predicate<Object> predicate) {
194            this.retryOnResult = predicate != null ? predicate : (r -> false);
195            return this;
196        }
197
198        public Builder operationName(String name) {
199            this.operationName = name != null ? name : "retryable_operation";
200            return this;
201        }
202
203        public Retryer build() {
204            return new Retryer(this);
205        }
206    }
207
208
209    private static final Predicate<Exception> DEFAULT_RETRYABLE_EXCEPTION = e ->
210        e instanceof java.net.SocketTimeoutException ||
211            e instanceof java.net.ConnectException ||
212            e instanceof java.net.UnknownHostException ||
213            e instanceof java.io.IOException ||
214            (e.getCause() instanceof java.net.SocketTimeoutException) ||
215            (e.getCause() instanceof java.net.ConnectException);
216}