From b84e6a7a376efdd49fddad3486de60a7649bff91 Mon Sep 17 00:00:00 2001 From: BK202503 <199436087+BK202503@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:08:05 +0900 Subject: [PATCH] GH-1143 Add IGNORE QueueNotFoundStrategy option for SQS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the Spring Cloud AWS 2.x behavior where a missing SQS queue at listener startup did not fail the application context. In 3.x today the two strategies are FAIL (throws on missing queue, blocks startup) and CREATE (calls CreateQueue on missing, requires sqs:CreateQueue IAM and can interfere with externally-managed queue configuration); neither fits the common case where a queue may legitimately be absent in some deployments — for example an optional feature queue, or a multi-region rollout that hasn't created the queue yet. Add a third strategy, IGNORE, that logs a warning and skips starting the listener container's message source for that queue. Subsequent polls return empty so the application keeps running cleanly. Wiring: - QueueNotFoundStrategy gets a new IGNORE constant with Javadoc that cross-references the 2.x default behavior. - QueueAttributesResolver.handleException, on QueueDoesNotExistException + IGNORE, logs a warning and fails the future with a new QueueNotFoundException (subtype of QueueAttributesResolvingException so existing catch sites keep working; the dedicated subtype is what the source uses to recognize the IGNORE signal). The CREATE path is unchanged. - QueueAttributesResolver.wrapException passes QueueNotFoundException through unwrapped instead of re-wrapping in QueueAttributesResolvingException, so the source sees the typed signal rather than a generic resolver failure. - AbstractSqsMessageSource.doStart catches CompletionException with QueueNotFoundException as its cause, flips a `skipped` flag, logs a warning, and returns without setting queueAttributes/queueUrl or configuring the conversion context. - AbstractSqsMessageSource.doPollForMessages short-circuits to an empty future when `skipped`, so the polling loop is a no-op for the lifetime of the source. Tests: - QueueAttributesResolverIntegrationTests#shouldIgnoreQueueWhenStrategyIsIgnore mirrors the existing #shouldNotCreateQueue pattern against LocalStack, asserting that resolving a non-existent queue with IGNORE surfaces a QueueNotFoundException whose cause is QueueDoesNotExistException (rather than the generic QueueAttributesResolvingException that FAIL produces). - The full QueueAttributesResolverIntegrationTests class (7 tests) passes locally on LocalStack. Docs: - sqs.adoc table entry for queueNotFoundStrategy gets a sentence on the IGNORE behavior next to the existing FAIL note. The property binding for `spring.cloud.aws.sqs.queue-not-found-strategy` picks up IGNORE automatically because SqsProperties already exposes the enum directly. Closes #1143. Signed-off-by: BK202503 <199436087+BK202503@users.noreply.github.com> --- docs/src/main/asciidoc/sqs.adoc | 1 + .../cloud/sqs/QueueAttributesResolver.java | 25 ++++++--- .../cloud/sqs/QueueNotFoundException.java | 52 +++++++++++++++++++ .../sqs/listener/QueueNotFoundStrategy.java | 11 +++- .../source/AbstractSqsMessageSource.java | 21 +++++++- ...eueAttributesResolverIntegrationTests.java | 23 ++++++++ 6 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueNotFoundException.java diff --git a/docs/src/main/asciidoc/sqs.adoc b/docs/src/main/asciidoc/sqs.adoc index 9b69146b08..0aee29f15e 100644 --- a/docs/src/main/asciidoc/sqs.adoc +++ b/docs/src/main/asciidoc/sqs.adoc @@ -197,6 +197,7 @@ See <> for more information. #CREATE |Set the strategy to use in case a queue is not found. With `QueueNotFoundStrategy#FAIL`, an exception is thrown in case a queue is not found. +With `QueueNotFoundStrategy#IGNORE`, the listener for the missing queue is skipped at startup with a warning log and subsequent polls for it return no messages, so application startup is not blocked. |`queueAttributeNames` |Collection diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolver.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolver.java index 5bf87d8055..d83a22e746 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolver.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueAttributesResolver.java @@ -86,6 +86,13 @@ public CompletableFuture resolveQueueAttributes() { } private CompletableFuture wrapException(Throwable t) { + Throwable unwrapped = t instanceof CompletionException ? t.getCause() : t; + if (unwrapped instanceof QueueNotFoundException) { + // Already a typed "ignore this queue" signal — preserve the type so callers can distinguish it from + // other resolver failures and skip starting the listener gracefully. + return CompletableFutures.failedFuture(unwrapped); + } + String message = "Error resolving attributes for queue " + this.queueName + " with strategy " + this.queueNotFoundStrategy + " and queueAttributesNames " + this.queueAttributeNames; @@ -94,8 +101,7 @@ private CompletableFuture wrapException(Throwable t) { "Please verify your AWS credentials, network settings, and queue configuration."; } - return CompletableFutures.failedFuture(new QueueAttributesResolvingException(message, - t instanceof CompletionException ? t.getCause() : t)); + return CompletableFutures.failedFuture(new QueueAttributesResolvingException(message, unwrapped)); } private CompletableFuture resolveQueueUrl() { @@ -120,10 +126,17 @@ private CompletableFuture doResolveQueueUrl() { } private CompletableFuture handleException(Throwable t) { - return t.getCause() instanceof QueueDoesNotExistException - && QueueNotFoundStrategy.CREATE.equals(this.queueNotFoundStrategy) - ? createQueue() - : CompletableFutures.failedFuture(t); + if (t.getCause() instanceof QueueDoesNotExistException) { + if (QueueNotFoundStrategy.CREATE.equals(this.queueNotFoundStrategy)) { + return createQueue(); + } + if (QueueNotFoundStrategy.IGNORE.equals(this.queueNotFoundStrategy)) { + logger.warn("Queue {} not found and strategy is IGNORE; the listener for this queue will be skipped", + this.queueName); + return CompletableFutures.failedFuture(new QueueNotFoundException(this.queueName, t.getCause())); + } + } + return CompletableFutures.failedFuture(t); } private CompletableFuture createQueue() { diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueNotFoundException.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueNotFoundException.java new file mode 100644 index 0000000000..96f9763495 --- /dev/null +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/QueueNotFoundException.java @@ -0,0 +1,52 @@ +/* + * Copyright 2013-2026 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.awspring.cloud.sqs; + +import io.awspring.cloud.sqs.listener.QueueNotFoundStrategy; + +/** + * Signals that a queue was not found and the configured {@link QueueNotFoundStrategy} is + * {@link QueueNotFoundStrategy#IGNORE IGNORE}. The listener container catches this to skip starting the source while + * allowing the application context to come up; other resolver failures continue to surface as + * {@link QueueAttributesResolvingException}. + * + * @author Bill Kim + * @since 4.1 + */ +public class QueueNotFoundException extends QueueAttributesResolvingException { + + private final String queueName; + + /** + * Create an instance for the given queue name. + * @param queueName the name of the queue that was not found. + * @param cause the underlying cause, typically a + * {@code software.amazon.awssdk.services.sqs.model.QueueDoesNotExistException}. + */ + public QueueNotFoundException(String queueName, Throwable cause) { + super("Queue not found: " + queueName, cause); + this.queueName = queueName; + } + + /** + * Return the name of the queue that was not found. + * @return the queue name. + */ + public String getQueueName() { + return this.queueName; + } + +} diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java index 8e99d8d9ad..b830ae155c 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/QueueNotFoundStrategy.java @@ -32,6 +32,15 @@ public enum QueueNotFoundStrategy { * Create queues that are not found at startup. Mind that in production environments the application might not have * permissions to create the queue and throw an exception. */ - CREATE + CREATE, + + /** + * Skip starting the listener for a queue that is not found, log a warning, and allow application startup to + * proceed. Subsequent polls for that listener return no messages. Useful when a queue may legitimately be absent in + * some deployments (for example, optional feature queues) and neither {@link #CREATE} nor {@link #FAIL} fits — this + * mirrors the default behavior of Spring Cloud AWS 2.x's {@code spring-cloud-starter-aws-messaging}, which silently + * ignored missing queues at startup. + */ + IGNORE } diff --git a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractSqsMessageSource.java b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractSqsMessageSource.java index 7541262f19..078d996e8a 100644 --- a/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractSqsMessageSource.java +++ b/spring-cloud-aws-sqs/src/main/java/io/awspring/cloud/sqs/listener/source/AbstractSqsMessageSource.java @@ -17,6 +17,7 @@ import io.awspring.cloud.sqs.ConfigUtils; import io.awspring.cloud.sqs.QueueAttributesResolver; +import io.awspring.cloud.sqs.QueueNotFoundException; import io.awspring.cloud.sqs.listener.ContainerOptions; import io.awspring.cloud.sqs.listener.QueueAttributes; import io.awspring.cloud.sqs.listener.QueueAttributesAware; @@ -34,6 +35,7 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.jspecify.annotations.Nullable; @@ -92,6 +94,8 @@ public abstract class AbstractSqsMessageSource extends AbstractPollingMessage private int pollTimeout; + private boolean skipped; + @Override public void setSqsAsyncClient(SqsAsyncClient sqsAsyncClient) { Assert.notNull(sqsAsyncClient, "sqsAsyncClient cannot be null."); @@ -119,7 +123,19 @@ protected void doConfigure(ContainerOptions containerOptions) { protected void doStart() { Assert.notNull(this.sqsAsyncClient, "sqsAsyncClient not set"); Assert.notNull(this.queueAttributeNames, "queueAttributeNames not set"); - this.queueAttributes = resolveQueueAttributes(); + try { + this.queueAttributes = resolveQueueAttributes(); + } + catch (CompletionException ex) { + if (ex.getCause() instanceof QueueNotFoundException qnfe) { + // QueueNotFoundStrategy.IGNORE — log warning and short-circuit the source so the application + // context can come up. doPollForMessages() will return empty for the lifetime of this source. + logger.warn("Skipping start for queue {}: {}", qnfe.getQueueName(), qnfe.getMessage()); + this.skipped = true; + return; + } + throw ex; + } this.queueUrl = this.queueAttributes.getQueueUrl(); configureConversionContextAndAcknowledgement(); } @@ -170,6 +186,9 @@ protected AcknowledgementExecutor createAcknowledgementExecutorInstance() { @Override protected CompletableFuture> doPollForMessages( int maxNumberOfMessages) { + if (this.skipped) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } logger.debug("Polling queue {} for {} messages.", this.queueUrl, maxNumberOfMessages); return maxNumberOfMessages <= 10 ? executePoll(maxNumberOfMessages) diff --git a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java index 41f43fbadc..117fae2558 100644 --- a/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java +++ b/spring-cloud-aws-sqs/src/test/java/io/awspring/cloud/sqs/integration/QueueAttributesResolverIntegrationTests.java @@ -20,6 +20,7 @@ import io.awspring.cloud.sqs.QueueAttributesResolver; import io.awspring.cloud.sqs.QueueAttributesResolvingException; +import io.awspring.cloud.sqs.QueueNotFoundException; import io.awspring.cloud.sqs.config.SqsBootstrapConfiguration; import io.awspring.cloud.sqs.listener.QueueAttributes; import io.awspring.cloud.sqs.listener.QueueNotFoundStrategy; @@ -113,6 +114,28 @@ void shouldNotCreateQueue() { .isInstanceOf(QueueDoesNotExistException.class); } + @Test + void shouldIgnoreQueueWhenStrategyIsIgnore() { + // GH-1143: With QueueNotFoundStrategy.IGNORE, a missing queue must surface as QueueNotFoundException + // (a subtype of QueueAttributesResolvingException) so the listener container can catch it and skip + // startup for that queue rather than failing the application context. + String queueName = "testQueueName-" + UUID.randomUUID(); + SqsAsyncClient client = createAsyncClient(); + QueueAttributesResolver resolver = QueueAttributesResolver + .builder() + .queueAttributeNames(Collections.emptyList()) + .sqsAsyncClient(client) + .queueName(queueName) + .queueNotFoundStrategy(QueueNotFoundStrategy.IGNORE) + .build(); + assertThatThrownBy(() -> resolver.resolveQueueAttributes().join()) + .isInstanceOf(CompletionException.class) + .extracting(Throwable::getCause) + .isInstanceOf(QueueNotFoundException.class) + .extracting(Throwable::getCause) + .isInstanceOf(QueueDoesNotExistException.class); + } + @Test void shouldGetQueueAttributes() { String queueName = "should-get-queue-attributes";