Skip to content

Commit 6dc789c

Browse files
committed
Retry project documentation add/delete if conflict
Closes gh-25
1 parent 4889b3e commit 6dc789c

File tree

7 files changed

+192
-23
lines changed

7 files changed

+192
-23
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies {
4141
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
4242
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
4343
implementation 'org.springframework.boot:spring-boot-starter-graphql'
44+
implementation 'org.springframework.retry:spring-retry:2.0.10'
4445
implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets'
4546
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
4647
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2'

src/main/java/io/spring/projectapi/Application.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,20 @@
2626
import org.springframework.boot.context.properties.EnableConfigurationProperties;
2727
import org.springframework.boot.web.client.RestTemplateBuilder;
2828
import org.springframework.context.annotation.Bean;
29+
import org.springframework.retry.support.RetryTemplate;
30+
import org.springframework.web.client.HttpClientErrorException;
2931

3032
@SpringBootApplication
3133
@EnableConfigurationProperties(ApplicationProperties.class)
3234
public class Application {
3335

3436
@Bean
3537
public GithubOperations githubOperations(RestTemplateBuilder builder, ObjectMapper objectMapper,
36-
ApplicationProperties properties) {
38+
ApplicationProperties properties, RetryTemplate retryTemplate) {
3739
Github github = properties.getGithub();
3840
String accessToken = github.getAccesstoken();
3941
String branch = github.getBranch();
40-
return new GithubOperations(builder, objectMapper, accessToken, branch);
42+
return new GithubOperations(builder, objectMapper, accessToken, branch, retryTemplate);
4143
}
4244

4345
@Bean
@@ -49,6 +51,16 @@ public GithubQueries githubQueries(RestTemplateBuilder builder, ObjectMapper obj
4951
return new GithubQueries(builder, objectMapper, accessToken, branch);
5052
}
5153

54+
@Bean
55+
public RetryTemplate retryTemplate() {
56+
return RetryTemplate.builder().maxAttempts(10).exponentialBackoff(100, 2, 10000).retryOn((throwable) -> {
57+
if (throwable instanceof HttpClientErrorException ex) {
58+
return (ex.getStatusCode().value() == 409);
59+
}
60+
return false;
61+
}).build();
62+
}
63+
5264
public static void main(String[] args) {
5365
SpringApplication.run(Application.class, args);
5466
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.projectapi.github;
18+
19+
import org.springframework.web.client.HttpClientErrorException;
20+
21+
/**
22+
* {@link GithubException} thrown when an update results in a conflict.
23+
*
24+
* @author Madhura Bhave
25+
*/
26+
public class ConflictingGithubContentException extends GithubException {
27+
28+
ConflictingGithubContentException(String projectSlug, String fileName) {
29+
super("Conflicting update for slug '%s' and file %s".formatted(projectSlug, fileName));
30+
}
31+
32+
static void throwIfConflict(HttpClientErrorException ex, String projectSlug, String fileName) {
33+
if (ex.getStatusCode().value() == 409) {
34+
throw new ConflictingGithubContentException(projectSlug, fileName);
35+
}
36+
}
37+
38+
}

src/main/java/io/spring/projectapi/github/GithubOperations.java

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.http.MediaType;
4545
import org.springframework.http.RequestEntity;
4646
import org.springframework.http.ResponseEntity;
47+
import org.springframework.retry.support.RetryTemplate;
4748
import org.springframework.util.StringUtils;
4849
import org.springframework.web.client.HttpClientErrorException;
4950
import org.springframework.web.client.RestTemplate;
@@ -84,8 +85,11 @@ public class GithubOperations {
8485

8586
private final String branch;
8687

88+
private final RetryTemplate retryTemplate;
89+
8790
public GithubOperations(RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper, String token,
88-
String branch) {
91+
String branch, RetryTemplate retryTemplate) {
92+
this.retryTemplate = retryTemplate;
8993
this.restTemplate = restTemplateBuilder.rootUri(GITHUB_URI)
9094
.defaultHeader("Authorization", "Bearer " + token)
9195
.build();
@@ -102,17 +106,26 @@ private static int compare(ProjectDocumentation o1, ProjectDocumentation o2) {
102106
}
103107

104108
public void addProjectDocumentation(String projectSlug, ProjectDocumentation documentation) {
105-
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
106-
List<ProjectDocumentation> documentations = new ArrayList<>();
107-
String sha = null;
108-
if (response != null) {
109-
String content = getFileContents(response);
110-
sha = getFileSha(response);
111-
documentations.addAll(convertToProjectDocumentation(content));
109+
try {
110+
this.retryTemplate.execute((context) -> {
111+
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
112+
List<ProjectDocumentation> documentations = new ArrayList<>();
113+
String sha = null;
114+
if (response != null) {
115+
String content = getFileContents(response);
116+
sha = getFileSha(response);
117+
documentations.addAll(convertToProjectDocumentation(content));
118+
}
119+
documentations.add(documentation);
120+
List<ProjectDocumentation> updatedDocumentation = computeCurrentRelease(documentations);
121+
updateProjectDocumentation(projectSlug, updatedDocumentation, sha);
122+
return null;
123+
});
112124
}
113-
documentations.add(documentation);
114-
List<ProjectDocumentation> updatedDocumentation = computeCurrentRelease(documentations);
115-
updateProjectDocumentation(projectSlug, updatedDocumentation, sha);
125+
catch (HttpClientErrorException ex) {
126+
ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json");
127+
}
128+
116129
}
117130

118131
private List<ProjectDocumentation> convertToProjectDocumentation(String content) {
@@ -201,15 +214,25 @@ private static ProjectDocumentation updateCurrent(ProjectDocumentation documenta
201214
}
202215

203216
public void deleteDocumentation(String projectSlug, String version) {
204-
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
205-
NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json");
206-
String content = getFileContents(response);
207-
String sha = getFileSha(response);
208-
List<ProjectDocumentation> documentation = convertToProjectDocumentation(content);
209-
NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug, version);
210-
documentation.removeIf((y) -> y.getVersion().equals(version));
211-
List<ProjectDocumentation> documentations1 = computeCurrentRelease(documentation);
212-
updateProjectDocumentation(projectSlug, documentations1, sha);
217+
try {
218+
this.retryTemplate.execute((context) -> {
219+
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
220+
NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json");
221+
String content = getFileContents(response);
222+
String sha = getFileSha(response);
223+
List<ProjectDocumentation> documentation = convertToProjectDocumentation(content);
224+
NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug,
225+
version);
226+
documentation.removeIf((y) -> y.getVersion().equals(version));
227+
List<ProjectDocumentation> documentations1 = computeCurrentRelease(documentation);
228+
updateProjectDocumentation(projectSlug, documentations1, sha);
229+
return null;
230+
});
231+
}
232+
catch (HttpClientErrorException ex) {
233+
ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json");
234+
}
235+
213236
}
214237

215238
private ResponseEntity<Map<String, Object>> getFile(String projectSlug, String fileName) {

src/main/java/io/spring/projectapi/web/error/ExceptionAdvice.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.spring.projectapi.web.error;
1818

19+
import io.spring.projectapi.github.ConflictingGithubContentException;
1920
import io.spring.projectapi.github.NoSuchGithubProjectDocumentationFoundException;
2021
import io.spring.projectapi.github.NoSuchGithubProjectException;
2122

@@ -34,6 +35,8 @@ public class ExceptionAdvice {
3435

3536
private static final ResponseEntity<Object> NOT_FOUND = ResponseEntity.notFound().build();
3637

38+
private static final ResponseEntity<Object> CONFLICT = ResponseEntity.status(409).build();
39+
3740
@ExceptionHandler
3841
private ResponseEntity<?> noSuchGithubProjectExceptionHandler(NoSuchGithubProjectException ex) {
3942
return NOT_FOUND;
@@ -45,4 +48,9 @@ private ResponseEntity<?> noSuchGithubProjectDocumentationExceptionHandler(
4548
return NOT_FOUND;
4649
}
4750

51+
@ExceptionHandler
52+
private ResponseEntity<?> conflictingGithubContentsExceptionHandler(ConflictingGithubContentException ex) {
53+
return CONFLICT;
54+
}
55+
4856
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.projectapi;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
import org.springframework.boot.test.context.SpringBootTest;
23+
import org.springframework.boot.test.mock.mockito.MockBean;
24+
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
25+
import org.springframework.retry.support.RetryTemplate;
26+
import org.springframework.test.util.ReflectionTestUtils;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
/**
31+
* Integration tests.
32+
*/
33+
@SpringBootTest
34+
class ApplicationTests {
35+
36+
@Autowired
37+
private RetryTemplate retryTemplate;
38+
39+
@MockBean
40+
private ProjectRepository projectRepository;
41+
42+
@Test
43+
void retryTemplate() {
44+
ExponentialBackOffPolicy backOffPolicy = (ExponentialBackOffPolicy) ReflectionTestUtils
45+
.getField(this.retryTemplate, "backOffPolicy");
46+
assertThat(ReflectionTestUtils.getField(backOffPolicy, "initialInterval")).isEqualTo(100L);
47+
assertThat(ReflectionTestUtils.getField(backOffPolicy, "multiplier")).isEqualTo(2.0);
48+
assertThat(ReflectionTestUtils.getField(backOffPolicy, "maxInterval")).isEqualTo(10000L);
49+
}
50+
51+
}

src/test/java/io/spring/projectapi/github/GithubOperationsTests.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@
3535
import org.springframework.http.HttpMethod;
3636
import org.springframework.http.HttpStatus;
3737
import org.springframework.http.MediaType;
38+
import org.springframework.retry.support.RetryTemplate;
3839
import org.springframework.util.FileCopyUtils;
40+
import org.springframework.web.client.HttpClientErrorException;
3941

4042
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4143
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
@@ -60,15 +62,27 @@ class GithubOperationsTests {
6062

6163
private static final String DOCUMENTATION_URI = "/project/test-project/documentation.json?ref=test";
6264

65+
private RetryTemplate retryTemplate;
66+
6367
@BeforeEach
6468
void setup() {
6569
this.customizer = new MockServerRestTemplateCustomizer();
6670
ObjectMapper objectMapper = new ObjectMapper();
6771
objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
6872
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
6973
objectMapper.registerModule(new JavaTimeModule());
74+
this.retryTemplate = getRetryTemplate();
7075
this.operations = new GithubOperations(new RestTemplateBuilder(this.customizer), objectMapper, "test-token",
71-
"test");
76+
"test", this.retryTemplate);
77+
}
78+
79+
private static RetryTemplate getRetryTemplate() {
80+
return RetryTemplate.builder().maxAttempts(2).retryOn((throwable) -> {
81+
if (throwable instanceof HttpClientErrorException ex) {
82+
return ex.getStatusCode().value() == 409;
83+
}
84+
return false;
85+
}).build();
7286
}
7387

7488
@Test
@@ -78,6 +92,28 @@ void addProjectDocumentationWhenProjectDoesNotExistThrowsException() throws Exce
7892
.addProjectDocumentation("does-not-exist", getDocumentation("1.0", Status.GENERAL_AVAILABILITY)));
7993
}
8094

95+
@Test
96+
void addProjectDocumentationWhenConflictShouldRetry() throws Exception {
97+
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
98+
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
99+
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
100+
setupFileUpdate("project-documentation-updated-content.json", "Update documentation",
101+
"2d2f875ca7d476d8b01bc1db07d29b5eba1d5120");
102+
ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY);
103+
this.operations.addProjectDocumentation("test-project", documentation);
104+
}
105+
106+
@Test
107+
void addProjectDocumentationWhenConflictShouldThrowWhenRetriesFail() throws Exception {
108+
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
109+
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
110+
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
111+
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
112+
ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY);
113+
assertThatExceptionOfType(ConflictingGithubContentException.class)
114+
.isThrownBy(() -> this.operations.addProjectDocumentation("test-project", documentation));
115+
}
116+
81117
@Test
82118
void addProjectDocumentation() throws Exception {
83119
setupFile("project-documentation-response.json", DOCUMENTATION_URI);

0 commit comments

Comments
 (0)