diff --git a/build.gradle b/build.gradle index 56dc59a..d02f611 100644 --- a/build.gradle +++ b/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-hateoas' implementation 'org.springframework.boot:spring-boot-starter-graphql' + implementation 'org.springframework.retry:spring-retry:2.0.10' implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2' diff --git a/src/main/java/io/spring/projectapi/Application.java b/src/main/java/io/spring/projectapi/Application.java index e5369df..9d135bc 100644 --- a/src/main/java/io/spring/projectapi/Application.java +++ b/src/main/java/io/spring/projectapi/Application.java @@ -26,6 +26,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.HttpClientErrorException; @SpringBootApplication @EnableConfigurationProperties(ApplicationProperties.class) @@ -33,11 +35,11 @@ public class Application { @Bean public GithubOperations githubOperations(RestTemplateBuilder builder, ObjectMapper objectMapper, - ApplicationProperties properties) { + ApplicationProperties properties, RetryTemplate retryTemplate) { Github github = properties.getGithub(); String accessToken = github.getAccesstoken(); String branch = github.getBranch(); - return new GithubOperations(builder, objectMapper, accessToken, branch); + return new GithubOperations(builder, objectMapper, accessToken, branch, retryTemplate); } @Bean @@ -49,6 +51,16 @@ public GithubQueries githubQueries(RestTemplateBuilder builder, ObjectMapper obj return new GithubQueries(builder, objectMapper, accessToken, branch); } + @Bean + public RetryTemplate retryTemplate() { + return RetryTemplate.builder().maxAttempts(10).exponentialBackoff(100, 2, 10000).retryOn((throwable) -> { + if (throwable instanceof HttpClientErrorException ex) { + return (ex.getStatusCode().value() == 409); + } + return false; + }).build(); + } + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/io/spring/projectapi/github/ConflictingGithubContentException.java b/src/main/java/io/spring/projectapi/github/ConflictingGithubContentException.java new file mode 100644 index 0000000..a49cff5 --- /dev/null +++ b/src/main/java/io/spring/projectapi/github/ConflictingGithubContentException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.projectapi.github; + +import org.springframework.web.client.HttpClientErrorException; + +/** + * {@link GithubException} thrown when an update results in a conflict. + * + * @author Madhura Bhave + */ +public class ConflictingGithubContentException extends GithubException { + + ConflictingGithubContentException(String projectSlug, String fileName) { + super("Conflicting update for slug '%s' and file %s".formatted(projectSlug, fileName)); + } + + static void throwIfConflict(HttpClientErrorException ex, String projectSlug, String fileName) { + if (ex.getStatusCode().value() == 409) { + throw new ConflictingGithubContentException(projectSlug, fileName); + } + } + +} diff --git a/src/main/java/io/spring/projectapi/github/GithubOperations.java b/src/main/java/io/spring/projectapi/github/GithubOperations.java index 8444de6..13be89a 100644 --- a/src/main/java/io/spring/projectapi/github/GithubOperations.java +++ b/src/main/java/io/spring/projectapi/github/GithubOperations.java @@ -44,6 +44,7 @@ import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; @@ -84,8 +85,11 @@ public class GithubOperations { private final String branch; + private final RetryTemplate retryTemplate; + public GithubOperations(RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper, String token, - String branch) { + String branch, RetryTemplate retryTemplate) { + this.retryTemplate = retryTemplate; this.restTemplate = restTemplateBuilder.rootUri(GITHUB_URI) .defaultHeader("Authorization", "Bearer " + token) .build(); @@ -102,17 +106,26 @@ private static int compare(ProjectDocumentation o1, ProjectDocumentation o2) { } public void addProjectDocumentation(String projectSlug, ProjectDocumentation documentation) { - ResponseEntity> response = getFile(projectSlug, "documentation.json"); - List documentations = new ArrayList<>(); - String sha = null; - if (response != null) { - String content = getFileContents(response); - sha = getFileSha(response); - documentations.addAll(convertToProjectDocumentation(content)); + try { + this.retryTemplate.execute((context) -> { + ResponseEntity> response = getFile(projectSlug, "documentation.json"); + List documentations = new ArrayList<>(); + String sha = null; + if (response != null) { + String content = getFileContents(response); + sha = getFileSha(response); + documentations.addAll(convertToProjectDocumentation(content)); + } + documentations.add(documentation); + List updatedDocumentation = computeCurrentRelease(documentations); + updateProjectDocumentation(projectSlug, updatedDocumentation, sha); + return null; + }); } - documentations.add(documentation); - List updatedDocumentation = computeCurrentRelease(documentations); - updateProjectDocumentation(projectSlug, updatedDocumentation, sha); + catch (HttpClientErrorException ex) { + ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json"); + } + } private List convertToProjectDocumentation(String content) { @@ -201,15 +214,25 @@ private static ProjectDocumentation updateCurrent(ProjectDocumentation documenta } public void deleteDocumentation(String projectSlug, String version) { - ResponseEntity> response = getFile(projectSlug, "documentation.json"); - NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json"); - String content = getFileContents(response); - String sha = getFileSha(response); - List documentation = convertToProjectDocumentation(content); - NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug, version); - documentation.removeIf((y) -> y.getVersion().equals(version)); - List documentations1 = computeCurrentRelease(documentation); - updateProjectDocumentation(projectSlug, documentations1, sha); + try { + this.retryTemplate.execute((context) -> { + ResponseEntity> response = getFile(projectSlug, "documentation.json"); + NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json"); + String content = getFileContents(response); + String sha = getFileSha(response); + List documentation = convertToProjectDocumentation(content); + NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug, + version); + documentation.removeIf((y) -> y.getVersion().equals(version)); + List documentations1 = computeCurrentRelease(documentation); + updateProjectDocumentation(projectSlug, documentations1, sha); + return null; + }); + } + catch (HttpClientErrorException ex) { + ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json"); + } + } private ResponseEntity> getFile(String projectSlug, String fileName) { diff --git a/src/main/java/io/spring/projectapi/web/error/ExceptionAdvice.java b/src/main/java/io/spring/projectapi/web/error/ExceptionAdvice.java index 36cdf3f..334e208 100644 --- a/src/main/java/io/spring/projectapi/web/error/ExceptionAdvice.java +++ b/src/main/java/io/spring/projectapi/web/error/ExceptionAdvice.java @@ -16,6 +16,7 @@ package io.spring.projectapi.web.error; +import io.spring.projectapi.github.ConflictingGithubContentException; import io.spring.projectapi.github.NoSuchGithubProjectDocumentationFoundException; import io.spring.projectapi.github.NoSuchGithubProjectException; @@ -34,6 +35,8 @@ public class ExceptionAdvice { private static final ResponseEntity NOT_FOUND = ResponseEntity.notFound().build(); + private static final ResponseEntity CONFLICT = ResponseEntity.status(409).build(); + @ExceptionHandler private ResponseEntity noSuchGithubProjectExceptionHandler(NoSuchGithubProjectException ex) { return NOT_FOUND; @@ -45,4 +48,9 @@ private ResponseEntity noSuchGithubProjectDocumentationExceptionHandler( return NOT_FOUND; } + @ExceptionHandler + private ResponseEntity conflictingGithubContentsExceptionHandler(ConflictingGithubContentException ex) { + return CONFLICT; + } + } diff --git a/src/test/java/io/spring/projectapi/ApplicationTests.java b/src/test/java/io/spring/projectapi/ApplicationTests.java new file mode 100644 index 0000000..fb901c5 --- /dev/null +++ b/src/test/java/io/spring/projectapi/ApplicationTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2022-2023 the original author or authors. + * + * 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 + * + * https://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 io.spring.projectapi; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests. + */ +@SpringBootTest +class ApplicationTests { + + @Autowired + private RetryTemplate retryTemplate; + + @MockBean + private ProjectRepository projectRepository; + + @Test + void retryTemplate() { + ExponentialBackOffPolicy backOffPolicy = (ExponentialBackOffPolicy) ReflectionTestUtils + .getField(this.retryTemplate, "backOffPolicy"); + assertThat(ReflectionTestUtils.getField(backOffPolicy, "initialInterval")).isEqualTo(100L); + assertThat(ReflectionTestUtils.getField(backOffPolicy, "multiplier")).isEqualTo(2.0); + assertThat(ReflectionTestUtils.getField(backOffPolicy, "maxInterval")).isEqualTo(10000L); + } + +} diff --git a/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java b/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java index a3efc53..3bcff9e 100644 --- a/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java +++ b/src/test/java/io/spring/projectapi/github/GithubOperationsTests.java @@ -35,7 +35,9 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.retry.support.RetryTemplate; import org.springframework.util.FileCopyUtils; +import org.springframework.web.client.HttpClientErrorException; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; @@ -60,6 +62,8 @@ class GithubOperationsTests { private static final String DOCUMENTATION_URI = "/project/test-project/documentation.json?ref=test"; + private RetryTemplate retryTemplate; + @BeforeEach void setup() { this.customizer = new MockServerRestTemplateCustomizer(); @@ -67,8 +71,18 @@ void setup() { objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES)); objectMapper.enable(SerializationFeature.INDENT_OUTPUT); objectMapper.registerModule(new JavaTimeModule()); + this.retryTemplate = getRetryTemplate(); this.operations = new GithubOperations(new RestTemplateBuilder(this.customizer), objectMapper, "test-token", - "test"); + "test", this.retryTemplate); + } + + private static RetryTemplate getRetryTemplate() { + return RetryTemplate.builder().maxAttempts(2).retryOn((throwable) -> { + if (throwable instanceof HttpClientErrorException ex) { + return ex.getStatusCode().value() == 409; + } + return false; + }).build(); } @Test @@ -78,6 +92,28 @@ void addProjectDocumentationWhenProjectDoesNotExistThrowsException() throws Exce .addProjectDocumentation("does-not-exist", getDocumentation("1.0", Status.GENERAL_AVAILABILITY))); } + @Test + void addProjectDocumentationWhenConflictShouldRetry() throws Exception { + setupFile("project-documentation-response.json", DOCUMENTATION_URI); + this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT)); + setupFile("project-documentation-response.json", DOCUMENTATION_URI); + setupFileUpdate("project-documentation-updated-content.json", "Update documentation", + "2d2f875ca7d476d8b01bc1db07d29b5eba1d5120"); + ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY); + this.operations.addProjectDocumentation("test-project", documentation); + } + + @Test + void addProjectDocumentationWhenConflictShouldThrowWhenRetriesFail() throws Exception { + setupFile("project-documentation-response.json", DOCUMENTATION_URI); + this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT)); + setupFile("project-documentation-response.json", DOCUMENTATION_URI); + this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT)); + ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY); + assertThatExceptionOfType(ConflictingGithubContentException.class) + .isThrownBy(() -> this.operations.addProjectDocumentation("test-project", documentation)); + } + @Test void addProjectDocumentation() throws Exception { setupFile("project-documentation-response.json", DOCUMENTATION_URI);