diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClient.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClient.java index afc7da3..62c4b06 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClient.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClient.java @@ -156,7 +156,7 @@ static RuntimeException toException(URI uri, Response response) { return new JiraRestException( "Encountered an error calling Jira REST API: %s resulting in: %s".formatted(uri, response.hasEntity() ? response.readEntity(String.class) : "No response body"), - response.getStatus()); + response.getStatus(), response.getHeaders()); } return null; } diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClientBuilder.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClientBuilder.java index 33b5f9b..c1c6b8d 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClientBuilder.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestClientBuilder.java @@ -1,11 +1,29 @@ package org.hibernate.infra.replicate.jira.service.jira.client; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.hibernate.infra.replicate.jira.JiraConfig; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraComment; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraComments; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssue; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueBulk; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueBulkResponse; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueLink; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueLinkTypes; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssueResponse; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraIssues; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraRemoteLink; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraSimpleObject; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraTransition; +import org.hibernate.infra.replicate.jira.service.jira.model.rest.JiraUser; +import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.client.api.ClientLogger; import org.jboss.resteasy.reactive.client.api.LoggingScope; @@ -15,6 +33,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpClientRequest; import io.vertx.core.http.HttpClientResponse; +import jakarta.ws.rs.core.Response; public class JiraRestClientBuilder { @@ -34,7 +53,7 @@ public static JiraRestClient of(JiraConfig.Instance jira) { if (jira.logRequests()) { builder.clientLogger(CustomClientLogger.INSTANCE).loggingScope(LoggingScope.REQUEST_RESPONSE); } - return builder.build(JiraRestClient.class); + return new JiraRestClientWithRetry(builder.build(JiraRestClient.class)); } private static class CustomClientLogger implements ClientLogger { @@ -102,4 +121,184 @@ private String sanitize(String header, String value) { } } } + + // TODO: remove it once we figure out how to correctly integrate smallrye fault-tolerance + // (simply adding annotations on the REST interface does not work) + private static class JiraRestClientWithRetry implements JiraRestClient { + + private final JiraRestClient delegate; + + private JiraRestClientWithRetry(JiraRestClient delegate) { + this.delegate = delegate; + } + + @Override + public JiraIssue getIssue(String key) { + return withRetry(() -> delegate.getIssue(key)); + } + + @Override + public JiraIssue getIssue(Long id) { + return delegate.getIssue(id); + } + + @Override + public JiraIssueResponse create(JiraIssue issue) { + return withRetry(() -> delegate.create(issue)); + } + + @Override + public JiraIssueBulkResponse create(JiraIssueBulk bulk) { + return withRetry(() -> delegate.create(bulk)); + } + + @Override + public JiraIssueResponse update(String key, JiraIssue issue) { + return withRetry(() -> delegate.update(key, issue)); + } + + @Override + public void upsertRemoteLink(String key, JiraRemoteLink remoteLink) { + withRetry(() -> delegate.upsertRemoteLink(key, remoteLink)); + } + + @Override + public JiraComment getComment(Long issueId, Long commentId) { + return withRetry(() -> delegate.getComment(issueId, commentId)); + } + + @Override + public JiraComment getComment(String issueKey, Long commentId) { + return withRetry(() -> delegate.getComment(issueKey, commentId)); + } + + @Override + public JiraComments getComments(Long issueId, int startAt, int maxResults) { + return withRetry(() -> delegate.getComments(issueId, startAt, maxResults)); + } + + @Override + public JiraComments getComments(String issueKey, int startAt, int maxResults) { + return withRetry(() -> delegate.getComments(issueKey, startAt, maxResults)); + } + + @Override + public JiraIssueResponse create(String issueKey, JiraComment comment) { + return withRetry(() -> delegate.create(issueKey, comment)); + } + + @Override + public JiraIssueResponse update(String issueKey, String commentId, JiraComment comment) { + return withRetry(() -> delegate.update(issueKey, commentId, comment)); + } + + @Override + public List getPriorities() { + return withRetry(delegate::getPriorities); + } + + @Override + public List getIssueTypes() { + return withRetry(delegate::getIssueTypes); + } + + @Override + public List getStatues() { + return withRetry(delegate::getStatues); + } + + @Override + public JiraIssueLinkTypes getIssueLinkTypes() { + return withRetry(delegate::getIssueLinkTypes); + } + + @Override + public List findUser(String email) { + return withRetry(() -> delegate.findUser(email)); + } + + @Override + public JiraIssueLink getIssueLink(Long id) { + return withRetry(() -> delegate.getIssueLink(id)); + } + + @Override + public void upsertIssueLink(JiraIssueLink link) { + withRetry(() -> delegate.upsertIssueLink(link)); + } + + @Override + public void deleteComment(String issueKey, String commentId) { + withRetry(() -> delegate.deleteComment(issueKey, commentId)); + } + + @Override + public void deleteIssueLink(String linkId) { + withRetry(() -> delegate.deleteIssueLink(linkId)); + } + + @Override + public JiraIssues find(String query, int startAt, int maxResults) { + return withRetry(() -> delegate.find(query, startAt, maxResults)); + } + + @Override + public void transition(String issueKey, JiraTransition transition) { + withRetry(() -> delegate.transition(issueKey, transition)); + } + + private static final int RETRIES = 5; + private static final Duration WAIT_BETWEEN_RETRIES = Duration.of(2, ChronoUnit.SECONDS); + + private void withRetry(Runnable runnable) { + withRetry(() -> { + runnable.run(); + return null; + }); + } + + private T withRetry(Supplier supplier) { + for (int i = 0; i < RETRIES; i++) { + try { + return supplier.get(); + } catch (RuntimeException exception) { + if (!shouldRetryOnException(exception)) { + throw exception; + } + } + try { + Thread.sleep(WAIT_BETWEEN_RETRIES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + throw new IllegalStateException("Shouldn't really reach this far."); + } + + private boolean shouldRetryOnException(Throwable throwable) { + if (throwable instanceof JiraRestException exception) { + if (exception.statusCode() == RestResponse.StatusCode.UNAUTHORIZED + || exception.statusCode() == RestResponse.StatusCode.FORBIDDEN) { + Log.warnf(exception, + "Will make an attempt to retry the REST API call because of the authentication problem. Response headers %s", + exception.headers()); + return true; + } + if (exception.statusCode() == RestResponse.StatusCode.NOT_FOUND) { + // not found is fine :) + Log.warn("Will make no retry attempt of a REST API call for a NOT_FOUND response."); + return false; + } + if (Response.Status.Family.SERVER_ERROR + .equals(Response.Status.Family.familyOf(exception.statusCode()))) { + Log.warnf(exception, + "Will make an attempt to retry the REST API call because of the internal server problem. Response headers %s", + exception.headers()); + return true; + } + } + return false; + } + } + } diff --git a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestException.java b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestException.java index 410a4ad..4dae4f5 100644 --- a/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestException.java +++ b/src/main/java/org/hibernate/infra/replicate/jira/service/jira/client/JiraRestException.java @@ -1,14 +1,23 @@ package org.hibernate.infra.replicate.jira.service.jira.client; +import java.util.List; +import java.util.Map; + public class JiraRestException extends RuntimeException { private final int statusCode; + private final Map> headers; - public JiraRestException(String message, int statusCode) { + public JiraRestException(String message, int statusCode, Map> headers) { super(message); this.statusCode = statusCode; + this.headers = headers; } public int statusCode() { return statusCode; } + + public Map> headers() { + return headers; + } } diff --git a/src/test/java/org/hibernate/infra/replicate/jira/mock/SampleJiraRestClient.java b/src/test/java/org/hibernate/infra/replicate/jira/mock/SampleJiraRestClient.java index e8ff645..13c1e4f 100644 --- a/src/test/java/org/hibernate/infra/replicate/jira/mock/SampleJiraRestClient.java +++ b/src/test/java/org/hibernate/infra/replicate/jira/mock/SampleJiraRestClient.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -41,7 +42,7 @@ public class SampleJiraRestClient implements JiraRestClient { @Override public JiraIssue getIssue(String key) { if (Pattern.compile(itemCannotBeFound.get()).matcher(key).matches()) { - throw new JiraRestException("No issue %s".formatted(key), 404); + throw new JiraRestException("No issue %s".formatted(key), 404, Map.of()); } return sample(1L, key); } @@ -49,7 +50,7 @@ public JiraIssue getIssue(String key) { @Override public JiraIssue getIssue(Long id) { if (Pattern.compile(itemCannotBeFound.get()).matcher(Long.toString(id)).matches()) { - throw new JiraRestException("No issue %s".formatted(id), 404); + throw new JiraRestException("No issue %s".formatted(id), 404, Map.of()); } return sample(id, jiraKey(id)); } @@ -87,7 +88,7 @@ public void upsertRemoteLink(String key, JiraRemoteLink remoteLink) { @Override public JiraComment getComment(Long issueId, Long commentId) { if (Pattern.compile(itemCannotBeFound.get()).matcher("%d - %d".formatted(issueId, commentId)).matches()) { - throw new JiraRestException("No comment %s".formatted(commentId), 404); + throw new JiraRestException("No comment %s".formatted(commentId), 404, Map.of()); } return sampleComment(issueId, commentId); } @@ -177,7 +178,9 @@ public void deleteIssueLink(String linkId) { @Override public JiraIssues find(String query, int startAt, int maxResults) { - return new JiraIssues(); + JiraIssues issues = new JiraIssues(); + issues.issues = List.of(); + return issues; } @Override