syncName(long projectId) {
- try {
- var project = projectService.getProject(projectId);
- var externalId = project.getExternalId();
- var sdlcSystemId = project.getSdlcSystem().getId();
+ public void sync(long projectId) throws NotFoundException {
+ var project = projectService.getProject(projectId);
+ var externalId = project.getExternalId();
+ var sdlcSystemId = project.getSdlcSystem().getId();
- var externalProject = externalProjectService.refreshProject(project.getSdlcSystem(), externalId);
- if (externalProject.isEmpty()) {
- log.warn("Project {} ({}) is unknown to its SDLC system", projectId, externalId);
- return completedFuture(null);
- }
- var externalName = externalProject.get().name();
- if (externalName == null || externalName.equals(project.getName())) {
- log.info("Project {} name is unchanged in its SDLC system", projectId);
- return completedFuture(null);
- }
+ var externalProject = externalProjectService.refreshProject(project.getSdlcSystem(), externalId);
+ if (externalProject.isEmpty()) {
+ log.warn("Project {} ({}) is unknown to its SDLC system", projectId, externalId);
+ return;
+ }
+ var externalName = externalProject.get().name();
+ if (externalName == null || externalName.equals(project.getName())) {
+ log.info("Project {} name is unchanged in its SDLC system", projectId);
+ return;
+ }
- var applied = new AtomicBoolean();
- projectService.updateProject(projectId, stored -> {
- // the project may have been re-keyed while its SDLC system was being asked
- if (stored.getExternalId().equals(externalId) && stored.getSdlcSystem().getId() == sdlcSystemId) {
- stored.setName(externalName);
- applied.set(true);
- }
- });
- if (applied.get()) {
- log.info("Synced project {} name from its SDLC system", projectId);
- } else {
- log.info("Project {} changed while syncing; SDLC system name not applied", projectId);
+ var applied = new AtomicBoolean();
+ projectService.updateProject(projectId, stored -> {
+ // the project may have been re-keyed while its SDLC system was being asked
+ if (stored.getExternalId().equals(externalId) && stored.getSdlcSystem().getId() == sdlcSystemId) {
+ stored.setName(externalName);
+ applied.set(true);
}
- return completedFuture(null);
- } catch (Exception e) {
- // the status only: an error response's message would include the upstream body
- var reason = e instanceof RestClientResponseException response
- ? "HTTP " + response.getStatusCode().value()
- : e.getClass().getSimpleName() + ": " + e.getMessage();
- log.warn("Syncing project {} from its SDLC system failed: {}", projectId, reason);
- return CompletableFuture.failedFuture(e);
+ });
+ if (applied.get()) {
+ log.info("Synced project {} name from its SDLC system", projectId);
+ } else {
+ log.info("Project {} changed while syncing; SDLC system name not applied", projectId);
}
}
}
diff --git a/src/main/java/com/company/resourceapi/services/SyncOnProjectChange.java b/src/main/java/com/company/resourceapi/services/SyncOnProjectChange.java
index f913c4c..24875e0 100644
--- a/src/main/java/com/company/resourceapi/services/SyncOnProjectChange.java
+++ b/src/main/java/com/company/resourceapi/services/SyncOnProjectChange.java
@@ -13,8 +13,9 @@
* project there, so clients don't have to request a sync themselves.
*
* Runs after the change is committed (a rolled-back change starts nothing), on the request
- * thread, which only starts the sync: the sync itself runs in the background. A sync that can't start
- * now (one is running, rate limited, no free slot) is logged and skipped; it can be requested later.
+ * thread, which only queues the sync: the sync itself runs in the background. A sync that can't be
+ * queued now (one is queued or running, rate limited, RabbitMQ unreachable) is logged and skipped; it
+ * can be requested later.
*/
@Component
@ConditionalOnBooleanProperty(name = "resourceapi.sync.on-change", matchIfMissing = true)
diff --git a/src/main/java/com/company/resourceapi/web/ApiExceptionHandler.java b/src/main/java/com/company/resourceapi/web/ApiExceptionHandler.java
index cdb0b5d..842b5e1 100644
--- a/src/main/java/com/company/resourceapi/web/ApiExceptionHandler.java
+++ b/src/main/java/com/company/resourceapi/web/ApiExceptionHandler.java
@@ -10,6 +10,7 @@
import com.company.resourceapi.exceptions.RateLimitExceededException;
import com.company.resourceapi.exceptions.SyncInProgressException;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.AmqpException;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -50,6 +51,13 @@ ResponseEntity handleRateLimitExceeded(RateLimitExceededException e) {
return ResponseEntity.status(status).header(RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds())).build();
}
+ /** RabbitMQ is unreachable or refused a message the request needed to queue (e.g. a sync). */
+ @ExceptionHandler(AmqpException.class)
+ ResponseEntity handleMessagingFailure(AmqpException e) {
+ log.warn("Message broker unavailable: {}", e.getMessage());
+ return ResponseEntity.status(SERVICE_UNAVAILABLE).header(RETRY_AFTER, "5").build();
+ }
+
/** The background task executor is at its concurrency limit; the client can retry shortly. */
@ExceptionHandler(TaskRejectedException.class)
ResponseEntity handleTaskRejected(TaskRejectedException e) {
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 7ed62ab..feb782c 100755
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -3,9 +3,9 @@ spring.application.name=resourceapi
# (incoming W3C and B3 headers are both accepted). Nothing is exported; when adding a reporter
# (e.g. Zipkin/OTLP), lower the sampling probability to what the tracing backend can take.
management.tracing.sampling.probability=1.0
-# virtual threads for request handling and the @Async task executor
+# virtual threads for request handling, the @Async task executor and RabbitMQ listeners
spring.threads.virtual.enabled=true
-# at most 8 background tasks at once; more are rejected (503) instead of piling up
+# at most 8 @Async tasks at once; more are rejected instead of piling up (syncs are queued in RabbitMQ)
# created even though the application has other executors (notifications use their own)
spring.task.execution.mode=force
spring.task.execution.thread-name-prefix=async-
@@ -31,7 +31,8 @@ resourceapi.cache.missing-external-projects-ttl=30s
resourceapi.redisson.key-prefix=resourceapi:
# sync a project from its SDLC system after it is created or re-keyed (pointed at another SDLC project)
resourceapi.sync.on-change=true
-# a project's sync lock expires after this even if never released (e.g. the instance died)
+# a project's sync lock is held from the request until its sync ends, and expires after this even if never
+# released (e.g. the instance died); a request waiting in the queue longer than this loses its lock
resourceapi.sync.lock-lease-time=60s
# distributed rate limits (Redisson): calls to one SDLC system (503 + Retry-After when exceeded after
# waiting max-wait), and sync requests per project (429 + Retry-After)
@@ -51,8 +52,19 @@ server.forward-headers-strategy=native
# for the broker's confirmation before the event counts as sent
spring.rabbitmq.connection-timeout=2s
spring.rabbitmq.publisher-confirm-type=simple
-# trace context (traceparent) on sent messages
+# trace context (traceparent) on sent messages, continued by the listeners
spring.rabbitmq.template.observation-enabled=true
+spring.rabbitmq.listener.simple.observation-enabled=true
+# sync consumers per instance; each takes one request at a time, so waiting requests go to whichever
+# instance is free. A failing listener's message goes to the dead-letter queue, never back to the queue
+spring.rabbitmq.listener.simple.concurrency=4
+spring.rabbitmq.listener.simple.prefetch=1
+spring.rabbitmq.listener.simple.default-requeue-rejected=false
+# sync requests (ProjectSyncQueueConfig); failed syncs are retried after 1s and 2s, then dead-lettered
+resourceapi.sync.queue=resourceapi.project-sync
+resourceapi.sync.max-retries=2
+resourceapi.sync.retry-delay=1s
+resourceapi.sync.retry-multiplier=2
# outbox (Spring Modulith): events sent to RabbitMQ are deleted from event_publication once confirmed;
# unsent ones are sent again after resourceapi.notifications.resubmit-after
spring.modulith.events.completion-mode=delete
diff --git a/src/test/java/com/company/resourceapi/RabbitTestContainerInitializer.java b/src/test/java/com/company/resourceapi/RabbitTestContainerInitializer.java
index 1e59a4f..3a83cc2 100644
--- a/src/test/java/com/company/resourceapi/RabbitTestContainerInitializer.java
+++ b/src/test/java/com/company/resourceapi/RabbitTestContainerInitializer.java
@@ -13,8 +13,8 @@
* Gives every test application context a real RabbitMQ: one container, started on first use and
* shared by all contexts of the test run (registered in META-INF/spring.factories).
*
- * Each context gets its own notifications exchange: contexts have their own in-memory database,
- * so the same project id can be sent by two contexts. A test can still point at another broker with
+ *
Each context gets its own notifications exchange and sync queue: contexts have their own
+ * in-memory database, so the same project id means different projects in two contexts. A test can still point at another broker with
* its own {@code spring.rabbitmq.*} properties.
*/
public class RabbitTestContainerInitializer implements ApplicationContextInitializer {
@@ -33,8 +33,10 @@ public void initialize(ConfigurableApplicationContext context) {
"spring.rabbitmq.host", RABBITMQ.getHost(),
"spring.rabbitmq.port", RABBITMQ.getMappedPort(5672))));
// overrides application.properties
- propertySources.addFirst(new MapPropertySource("rabbitTestContainerExchange", Map.of(
- "resourceapi.notifications.exchange", "test-" + UUID.randomUUID())));
+ var contextId = "test-" + UUID.randomUUID();
+ propertySources.addFirst(new MapPropertySource("rabbitTestContainerNames", Map.of(
+ "resourceapi.notifications.exchange", contextId,
+ "resourceapi.sync.queue", contextId + "-sync")));
}
private static synchronized void startOnce() {
diff --git a/src/test/java/com/company/resourceapi/services/ProjectSyncBackpressureTest.java b/src/test/java/com/company/resourceapi/services/ProjectSyncBackpressureTest.java
deleted file mode 100644
index 947f5fa..0000000
--- a/src/test/java/com/company/resourceapi/services/ProjectSyncBackpressureTest.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package com.company.resourceapi.services;
-
-import static com.github.tomakehurst.wiremock.client.WireMock.get;
-import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
-import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
-import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-import com.company.resourceapi.controllers.ProjectRestController;
-import com.company.resourceapi.entities.Project;
-import com.company.resourceapi.entities.SdlcSystem;
-import com.company.resourceapi.repositories.ProjectRepository;
-import com.company.resourceapi.repositories.SdlcSystemRepository;
-import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
-import java.time.Duration;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
-import org.springframework.test.web.servlet.MockMvc;
-
-/** Background syncs are bounded: beyond the executor's limit they are rejected, not queued. */
-@SpringBootTest(properties = {
- "spring.task.execution.simple.concurrency-limit=1",
- // this test retries refused syncs; the per-project sync rate limit isn't what it is about
- "resourceapi.rate-limit.sync.permits=1000"})
-@AutoConfigureMockMvc
-class ProjectSyncBackpressureTest {
-
- @RegisterExtension
- static WireMockExtension sdlcSystemServer = WireMockExtension.newInstance()
- .options(wireMockConfig().dynamicPort())
- .build();
-
- @Autowired
- private MockMvc mockMvc;
-
- @Autowired
- private SdlcSystemRepository sdlcSystemRepository;
-
- @Autowired
- private ProjectRepository projectRepository;
-
- @Test
- void givenBusyTaskExecutorWhenSyncThenReturnStatusServiceUnavailable() throws Exception {
- // given: the only background slot stays busy for 1s
- var sdlcSystem = new SdlcSystem();
- sdlcSystem.setBaseUrl(sdlcSystemServer.baseUrl());
- sdlcSystem = sdlcSystemRepository.save(sdlcSystem);
- var syncUrl = syncUrl(sdlcSystem, "BUSY");
- var otherSyncUrl = syncUrl(sdlcSystem, "OTHER");
- sdlcSystemServer.stubFor(get(urlPathMatching("/rest/api/2/project/.*"))
- .willReturn(okJson("{\"key\": \"BUSY\", \"name\": \"Busy\"}").withFixedDelay(1_000)));
-
- // when / then: another project's sync (the same project's would be refused by its lock)
- mockMvc.perform(post(syncUrl)).andExpect(status().isAccepted());
- mockMvc.perform(post(otherSyncUrl))
- .andExpect(status().isServiceUnavailable())
- .andExpect(header().string("Retry-After", "1"));
-
- // the rejected sync released its lock: it can be started once the first sync freed the slot
- var deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos();
- int status;
- while ((status = mockMvc.perform(post(otherSyncUrl)).andReturn().getResponse().getStatus()) == 503) {
- assertThat(System.nanoTime()).as("no free slot within 10s").isLessThan(deadline);
- Thread.sleep(100);
- }
- assertThat(status).isEqualTo(202);
- }
-
- private String syncUrl(SdlcSystem sdlcSystem, String externalId) {
- var project = new Project();
- project.setExternalId(externalId);
- project.setSdlcSystem(sdlcSystem);
- return ProjectRestController.ENDPOINT + "/" + projectRepository.save(project).getId() + "/sync";
- }
-}
diff --git a/src/test/java/com/company/resourceapi/services/ProjectSyncQueueTest.java b/src/test/java/com/company/resourceapi/services/ProjectSyncQueueTest.java
new file mode 100644
index 0000000..1be4c54
--- /dev/null
+++ b/src/test/java/com/company/resourceapi/services/ProjectSyncQueueTest.java
@@ -0,0 +1,179 @@
+package com.company.resourceapi.services;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.company.resourceapi.config.ProjectSyncQueueConfig.SyncQueueProperties;
+import com.company.resourceapi.controllers.ProjectRestController;
+import com.company.resourceapi.entities.Project;
+import com.company.resourceapi.entities.SdlcSystem;
+import com.company.resourceapi.repositories.ProjectRepository;
+import com.company.resourceapi.repositories.SdlcSystemRepository;
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.function.BooleanSupplier;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.springframework.amqp.core.AmqpAdmin;
+import org.springframework.amqp.core.Message;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/** Syncs are queued in RabbitMQ, retried when the SDLC system fails, and dead-lettered if it keeps failing. */
+@SpringBootTest(properties = {
+ // one consumer, so requests have to wait for it
+ "spring.rabbitmq.listener.simple.concurrency=1",
+ "spring.rabbitmq.listener.simple.max-concurrency=1"})
+@AutoConfigureMockMvc
+class ProjectSyncQueueTest {
+
+ @RegisterExtension
+ static WireMockExtension sdlcSystemServer = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort())
+ .build();
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private SdlcSystemRepository sdlcSystemRepository;
+
+ @Autowired
+ private ProjectRepository projectRepository;
+
+ @Autowired
+ private RabbitTemplate rabbitTemplate;
+
+ @Autowired
+ private AmqpAdmin amqpAdmin;
+
+ @Autowired
+ private SyncQueueProperties syncQueue;
+
+ private SdlcSystem sdlcSystem;
+
+ @BeforeEach
+ void setup() {
+ sdlcSystem = new SdlcSystem();
+ sdlcSystem.setBaseUrl(sdlcSystemServer.baseUrl());
+ sdlcSystem = sdlcSystemRepository.save(sdlcSystem);
+ amqpAdmin.purgeQueue(syncQueue.deadLetterQueue(), false);
+ }
+
+ @Test
+ void givenMoreRequestsThanConsumersThenAllAreAcceptedAndSynced() throws Exception {
+ sdlcSystemServer.stubFor(get(urlPathMatching("/rest/api/2/project/QUEUED-.*"))
+ .willReturn(okJson("{\"key\": \"Q\", \"name\": \"Synced\"}").withFixedDelay(200)));
+ var ids = new ArrayList();
+ for (int i = 0; i < 5; i++) {
+ ids.add(project("QUEUED-" + i));
+ }
+
+ // nothing refused for lack of a free slot: the requests wait in the queue
+ for (long id : ids) {
+ mockMvc.perform(post(syncUrl(id))).andExpect(status().isAccepted());
+ }
+
+ await(() -> ids.stream().allMatch(id -> "Synced".equals(nameOf(id))), Duration.ofSeconds(10));
+ }
+
+ @Test
+ void givenSdlcSystemFailingOnceThenSyncSucceedsOnRetry() throws Exception {
+ var url = "/rest/api/2/project/FLAKY";
+ sdlcSystemServer.stubFor(get(urlEqualTo(url)).inScenario("flaky").whenScenarioStateIs(STARTED)
+ .willReturn(aResponse().withStatus(503)).willSetStateTo("recovered"));
+ sdlcSystemServer.stubFor(get(urlEqualTo(url)).inScenario("flaky").whenScenarioStateIs("recovered")
+ .willReturn(okJson("{\"key\": \"FLAKY\", \"name\": \"After retry\"}")));
+ long id = project("FLAKY");
+
+ mockMvc.perform(post(syncUrl(id))).andExpect(status().isAccepted());
+
+ await(() -> "After retry".equals(nameOf(id)), Duration.ofSeconds(5));
+ sdlcSystemServer.verify(2, getRequestedFor(urlEqualTo(url)));
+ }
+
+ @Test
+ void givenSdlcSystemKeepsFailingThenRequestIsDeadLetteredAndLockReleased() throws Exception {
+ var url = "/rest/api/2/project/BROKEN";
+ sdlcSystemServer.stubFor(get(urlEqualTo(url)).willReturn(aResponse().withStatus(500)));
+ long id = project("BROKEN");
+
+ mockMvc.perform(post(syncUrl(id))).andExpect(status().isAccepted());
+
+ // the first try and 2 retries, then kept for inspection
+ assertThat(body(receiveDeadLetter())).contains("\"projectId\":" + id);
+ sdlcSystemServer.verify(3, getRequestedFor(urlEqualTo(url)));
+ // the lock was released: a new sync can be requested right away
+ await(() -> statusOfSync(id) == 202, Duration.ofSeconds(2));
+ }
+
+ @Test
+ void givenSdlcSystemRejectingTheRequestThenItIsNotRetried() throws Exception {
+ var url = "/rest/api/2/project/FORBIDDEN";
+ sdlcSystemServer.stubFor(get(urlEqualTo(url)).willReturn(aResponse().withStatus(403)));
+ long id = project("FORBIDDEN");
+
+ mockMvc.perform(post(syncUrl(id))).andExpect(status().isAccepted());
+
+ assertThat(body(receiveDeadLetter())).contains("\"projectId\":" + id);
+ sdlcSystemServer.verify(1, getRequestedFor(urlEqualTo(url)));
+ }
+
+ private Message receiveDeadLetter() {
+ var deadLetter = rabbitTemplate.receive(syncQueue.deadLetterQueue(), 5_000);
+ assertThat(deadLetter).as("dead-lettered within 5s").isNotNull();
+ return deadLetter;
+ }
+
+ private static String body(Message message) {
+ return new String(message.getBody(), StandardCharsets.UTF_8);
+ }
+
+ private long project(String externalId) {
+ var project = new Project();
+ project.setExternalId(externalId);
+ project.setName("Old name");
+ project.setSdlcSystem(sdlcSystem);
+ return projectRepository.save(project).getId();
+ }
+
+ private int statusOfSync(long id) {
+ try {
+ return mockMvc.perform(post(syncUrl(id))).andReturn().getResponse().getStatus();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private String nameOf(long id) {
+ return projectRepository.findById(id).orElseThrow().getName();
+ }
+
+ private static String syncUrl(long id) {
+ return ProjectRestController.ENDPOINT + "/" + id + "/sync";
+ }
+
+ private static void await(BooleanSupplier condition, Duration timeout) throws InterruptedException {
+ var deadline = Instant.now().plus(timeout);
+ while (!condition.getAsBoolean()) {
+ assertThat(Instant.now()).as("condition not met within " + timeout).isBefore(deadline);
+ Thread.sleep(50);
+ }
+ }
+}
diff --git a/src/test/java/com/company/resourceapi/services/RabbitUnavailableTest.java b/src/test/java/com/company/resourceapi/services/RabbitUnavailableTest.java
new file mode 100644
index 0000000..3bc7ada
--- /dev/null
+++ b/src/test/java/com/company/resourceapi/services/RabbitUnavailableTest.java
@@ -0,0 +1,50 @@
+package com.company.resourceapi.services;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.company.resourceapi.controllers.ProjectRestController;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/** Without RabbitMQ the application still starts and serves requests; only syncs are refused. */
+@SpringBootTest(properties = {
+ "spring.rabbitmq.port=1",
+ "spring.rabbitmq.connection-timeout=200ms"})
+@AutoConfigureMockMvc
+class RabbitUnavailableTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ProjectSyncLock projectSyncLock;
+
+ @Test
+ void givenRabbitUnreachableWhenSyncThenServiceUnavailableAndLockReleased() throws Exception {
+ long started = System.nanoTime();
+
+ mockMvc.perform(post(ProjectRestController.ENDPOINT + "/1/sync"))
+ .andExpect(status().isServiceUnavailable())
+ .andExpect(header().string("Retry-After", "5"));
+
+ assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(3));
+ // not left locked: the lock can be taken again
+ var lease = projectSyncLock.tryAcquire(1);
+ assertThat(lease).isPresent();
+ lease.get().release();
+ }
+
+ @Test
+ void givenRabbitUnreachableThenProjectsCanStillBeRead() throws Exception {
+ mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+ .get(ProjectRestController.ENDPOINT + "/1"))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/src/test/resources/config/application.properties b/src/test/resources/config/application.properties
index fb0015f..3e03429 100644
--- a/src/test/resources/config/application.properties
+++ b/src/test/resources/config/application.properties
@@ -5,3 +5,5 @@ resourceapi.rate-limit.client.permits=100000
# no automatic syncs after creating or re-keying a project: tests would call SDLC systems they don't
# expect (SyncOnProjectChangeTest turns it on)
resourceapi.sync.on-change=false
+# failed syncs are retried quickly
+resourceapi.sync.retry-delay=100ms