Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a3faf26
Add tiered send/receive recovery matching Go SDK parity
EldertGrootenboerMS Mar 18, 2026
5e28c33
fix(ci): build azure-core-amqp from source for tiered recovery PR
EldertGrootenboerMS Mar 18, 2026
f0c6e53
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 18, 2026
0bd7282
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 18, 2026
6116b7b
fix(review): address Copilot PR review feedback on tiered recovery
EldertGrootenboerMS Mar 18, 2026
f3a29a7
fix(review): address second Copilot review on tiered recovery
EldertGrootenboerMS Mar 18, 2026
79c52f9
fix(review): address third Copilot review on tiered recovery
EldertGrootenboerMS Mar 18, 2026
1fec9e5
fix(servicebus): make link/connection force-close non-blocking in rec…
EldertGrootenboerMS Mar 18, 2026
d4c64fd
fix(amqp): prevent NONE failure from consuming quick-retry flag
EldertGrootenboerMS Mar 18, 2026
52d4435
fix(amqp): classify disposed-link IllegalStateException as LINK recovery
EldertGrootenboerMS Mar 18, 2026
ce10aa2
fix(amqp): narrow disposed-ISE match to prevent tier misclassification
EldertGrootenboerMS Mar 18, 2026
5448c97
fix(amqp): clarify virtual-time reason in retry tests
EldertGrootenboerMS Mar 18, 2026
4bd93bb
fix(amqp): tie forceCloseConnection invalidation to specific connecti…
EldertGrootenboerMS Mar 18, 2026
2e80396
fix(amqp): rename test methods to camelCase for checkstyle compliance
EldertGrootenboerMS Mar 18, 2026
1860dd3
fix(amqp): use distinct log message for force-invalidation path
EldertGrootenboerMS Mar 18, 2026
c2d2086
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 19, 2026
bf2b560
fix(amqp): guard backoff overflow and normalize RecoveryKind message …
EldertGrootenboerMS Mar 19, 2026
e76c2d8
fix(servicebus): scope send-link recovery to per-operation reference,…
EldertGrootenboerMS Mar 19, 2026
3eea3b2
fix(servicebus): include exception in connection-recovery warning log
EldertGrootenboerMS Mar 19, 2026
1f9bf92
fix(ci): move azure-core-amqp source comment to correct entry
EldertGrootenboerMS Mar 19, 2026
c0e2d8b
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 19, 2026
0c6cb7a
refactor: address reviewer feedback on tiered recovery
EldertGrootenboerMS Apr 2, 2026
cb1e86a
fix(servicebus): restore comment explaining Mono.delay(Duration.ZERO)…
EldertGrootenboerMS Apr 9, 2026
a3b639c
fix(servicebus): include exception in recovery warning logs
EldertGrootenboerMS Apr 9, 2026
ea2961b
docs(servicebus): clarify invalidateConnection v1/v2 behavior in javadoc
EldertGrootenboerMS Apr 9, 2026
591a8c7
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboerMS May 6, 2026
43a27f4
test(amqp): add unit tests for ReactorConnectionCache.invalidateConne…
EldertGrootenboerMS May 6, 2026
43d5ff1
fix(servicebus): wrap getLinkSize() in retry+recovery boundary
EldertGrootenboerMS May 6, 2026
e1b382a
fix(servicebus): remove unused static import for RetryUtil.withRetry
EldertGrootenboerMS May 6, 2026
95e9070
docs(servicebus): clarify session-removal recovery comment for LINK a…
EldertGrootenboerMS May 7, 2026
9494fe5
Merge remote-tracking branch 'origin/main' into fix/servicebus-tiered…
EldertGrootenboerMS May 7, 2026
36d9848
refactor(servicebus): extract shared RecoveryUtils.asRetriable helper
EldertGrootenboerMS May 7, 2026
1ce02a1
fix(servicebus): remove unused RetryUtil import in RecoveryUtils
EldertGrootenboerMS May 7, 2026
c5ef09b
merge(servicebus): resolve conflicts with #48214 batch-size cap
EldertGrootenboerMS May 13, 2026
e469d49
ci: retrigger after infrastructure flakes
EldertGrootenboerMS May 13, 2026
a94a0b6
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 13, 2026
65f9d34
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 14, 2026
41e92f1
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 14, 2026
4c64da5
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Jun 17, 2026
2cd3326
Use current version tag for azure-core-amqp dependency
EldertGrootenboerMS Jun 17, 2026
d8f7153
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,15 @@ private void setAndClearChannel() {
close(oldChannel);
}

/**
* Force-closes the current cached channel so that the next subscriber receives a fresh one.
* This is used for connection-level recovery when the current connection is stale
* but the processor has not detected it (e.g., heartbeats echoed by intermediate infrastructure).
*/
public void forceCloseChannel() {
setAndClearChannel();
}

/**
* Checks the current state of the channel for this channel and returns true if the channel is null or if this
* processor is disposed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,31 @@ public boolean isCurrentConnectionClosed() {
return (currentConnection != null && currentConnection.isDisposed()) || terminated;
}

/**
* Closes the current cached connection (if any) so that the next {@link #get()} call creates
* a fresh connection. This is used for connection-level recovery when the current connection
* is in a stale state that the cache's normal error detection (via endpoint state signals)
* has not detected — for example, when intermediate infrastructure (load balancers, NAT gateways)
* is echoing AMQP heartbeats on behalf of a dead connection.
*
* <p>This is modeled after the Go SDK's {@code Namespace.Recover()} which explicitly closes
* the old connection and increments the connection revision.</p>
*
* <p>This method is safe to call concurrently. If the connection is already closed or being
* closed, this is a no-op.</p>
*/
public void forceCloseConnection() {
final T connection = currentConnection;
if (connection != null && !connection.isDisposed()) {
Comment thread
EldertGrootenboer marked this conversation as resolved.
withConnectionId(logger, connection.getId())
.log("Force-closing connection for recovery. Next get() will create a fresh connection.");
// Call dispose() rather than closeAsync() so that isDisposed() returns true synchronously.
// This ensures cacheInvalidateIf immediately invalidates the cached reference on the next
// get() call, rather than waiting for the async close handshake to complete.
connection.dispose();
}
Comment thread
EldertGrootenboer marked this conversation as resolved.
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
Comment thread
EldertGrootenboer marked this conversation as resolved.
Comment thread
EldertGrootenboer marked this conversation as resolved.
}

/**
* Terminate so that consumers will no longer be able to request connection. If there is a current (cached)
* connection then it will be closed.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.core.amqp.implementation;

import com.azure.core.amqp.exception.AmqpErrorCondition;
import com.azure.core.amqp.exception.AmqpException;

import java.util.concurrent.TimeoutException;

/**
* Classifies errors into recovery tiers, determining what resources should be closed
* between retry attempts. This follows the tiered recovery pattern used by the Go, .NET,
* Python, and JS Azure SDKs.
*
* <ul>
* <li>{@link #NONE} — Retry on the same link (server-busy, timeouts).</li>
* <li>{@link #LINK} — Close the send/receive link; next retry creates a fresh link on the same connection.</li>
* <li>{@link #CONNECTION} — Close the entire connection; next retry creates a fresh connection and link.</li>
* <li>{@link #FATAL} — Do not retry (unauthorized, not-found, message too large).</li>
* </ul>
*/
public enum RecoveryKind {
/**
* No recovery needed — retry on the same link and connection.
* Applies to: server-busy, timeouts, resource-limit-exceeded.
*/
NONE,

/**
* Close the link (and its session) before retrying. The next retry creates a fresh link
* on the same connection.
* Applies to: link:detach-forced, link:stolen, transient AMQP errors on the link.
*/
LINK,

/**
* Close the entire connection before retrying. The next retry creates a fresh connection,
* session, and link.
* Applies to: connection:forced, connection:framing-error, proton:io, internal-error.
*/
CONNECTION,

/**
* Do not retry — the error is permanent.
* Applies to: unauthorized-access, not-found, message-size-exceeded.
*/
FATAL;

/**
* Classifies the given error into a {@link RecoveryKind} that determines what resources
* should be invalidated between retry attempts.
*
* @param error The error to classify.
* @return The recovery kind for the given error.
*/
public static RecoveryKind classify(Throwable error) {
if (error == null) {
return NONE;
}

// Timeouts — retry on same link, the link may still be healthy.
if (error instanceof TimeoutException) {
return NONE;
}

if (error instanceof AmqpException) {
final AmqpException amqpError = (AmqpException) error;
final AmqpErrorCondition condition = amqpError.getErrorCondition();

if (condition != null) {
switch (condition) {
// Connection-level errors — close the entire connection.
case CONNECTION_FORCED:
case CONNECTION_FRAMING_ERROR:
case CONNECTION_REDIRECT:
case PROTON_IO:
case INTERNAL_ERROR:
return CONNECTION;

// Link-level errors — close the link, keep the connection.
case LINK_DETACH_FORCED:
case LINK_STOLEN:
case LINK_REDIRECT:
case PARTITION_NOT_OWNED_ERROR:
case TRANSFER_LIMIT_EXCEEDED:
// operation-cancelled can signal "AMQP layer unexpectedly aborted or disconnected"
// (e.g. ReceiverUnsettledDeliveries remote Released outcome), requiring link recovery.
case OPERATION_CANCELLED:
return LINK;

// Fatal errors — do not retry.
case NOT_FOUND:
case UNAUTHORIZED_ACCESS:
case LINK_PAYLOAD_SIZE_EXCEEDED:
case NOT_ALLOWED:
Comment thread
EldertGrootenboer marked this conversation as resolved.
case NOT_IMPLEMENTED:
case ENTITY_DISABLED_ERROR:
case ENTITY_ALREADY_EXISTS:
case PUBLISHER_REVOKED_ERROR:
case ARGUMENT_ERROR:
case ARGUMENT_OUT_OF_RANGE_ERROR:
case ILLEGAL_STATE:
case MESSAGE_LOCK_LOST:
case STORE_LOCK_LOST_ERROR:
return FATAL;

// Server-busy, timeouts, and resource-limit errors — retry on same link.
// RESOURCE_LIMIT_EXCEEDED is treated as transient here because ReactorSender
// groups it alongside SERVER_BUSY and TIMEOUT in its send-error retry logic.
case SERVER_BUSY_ERROR:
case TIMEOUT_ERROR:
case RESOURCE_LIMIT_EXCEEDED:
return NONE;
Comment thread
EldertGrootenboer marked this conversation as resolved.

// Session/lock errors — link-level recovery.
// Session lock loss means the session link is invalid and
// a fresh link must be acquired for a new session.
case SESSION_LOCK_LOST:
case SESSION_CANNOT_BE_LOCKED:
case SESSION_NOT_FOUND:
case MESSAGE_NOT_FOUND:
return LINK;

Comment thread
EldertGrootenboer marked this conversation as resolved.
default:
break;
}
}

// Transient AMQP errors without a specific condition — link recovery.
if (amqpError.isTransient()) {
return LINK;
}

// Non-transient AMQP errors without a recognized condition — fatal.
return FATAL;
}

// RequestResponseChannelClosedException — link-level (parent connection disposing).
if (error instanceof RequestResponseChannelClosedException) {
return LINK;
}

// Unknown non-AMQP errors — treat as fatal (don't retry application or SDK bugs).
// The Go SDK defaults to CONNECTION for unknown errors, but those are AMQP-layer
// errors (io.EOF, net.Error). Java's non-AMQP exceptions (e.g., AzureException,
// RuntimeException) should fail fast rather than trigger connection recovery.
return FATAL;
Comment thread
EldertGrootenboer marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

import java.time.Duration;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;

/**
* Helper class to help with retry policies.
Expand Down Expand Up @@ -106,6 +109,44 @@ public static <T> Mono<T> withRetry(Mono<T> source, AmqpRetryOptions retryOption
return withRetry(source, retryOptions, timeoutMessage, false);
}

/**
* Applies the retry policy with tiered recovery between attempts. Before each retry,
* the error is classified via {@link RecoveryKind#classify(Throwable)} and the recovery
* callback is invoked so the caller can close the appropriate resources (link or connection).
*
* <p>This matches the tiered recovery pattern used by the Go, .NET, Python, and JS SDKs.</p>
*
* @param <T> Type of value in the {@link Mono}.
* @param source The publisher to apply the retry policy to.
* @param retryOptions A {@link AmqpRetryOptions}.
* @param errorMessage Text added to error logs.
* @param recoveryAction Called between retry attempts with the classified {@link RecoveryKind}.
* The caller should close the link (for {@link RecoveryKind#LINK}) or connection
* (for {@link RecoveryKind#CONNECTION}) so the next retry creates fresh resources.
*
* @return A publisher that returns the results of the {@link Mono} if any of the retry attempts
* are successful. Otherwise, propagates the last error.
*/
public static <T> Mono<T> withRetryAndRecovery(Mono<T> source, AmqpRetryOptions retryOptions, String errorMessage,
Consumer<RecoveryKind> recoveryAction) {
return withRetryAndRecovery(source, retryOptions, errorMessage, false, recoveryAction);
}

/**
* Like {@link #withRetryAndRecovery(Mono, AmqpRetryOptions, String, Consumer)} but with an option to allow
* long-running operations that should not be subject to the per-attempt timeout.
*
* @param allowsLongOperation If true, the source Mono will not be wrapped with a per-attempt timeout.
*/
public static <T> Mono<T> withRetryAndRecovery(Mono<T> source, AmqpRetryOptions retryOptions, String errorMessage,
boolean allowsLongOperation, Consumer<RecoveryKind> recoveryAction) {
if (!allowsLongOperation) {
source = source.timeout(retryOptions.getTryTimeout());
}
return source.retryWhen(createRetryWithRecovery(retryOptions, recoveryAction))
.doOnError(error -> LOGGER.error(errorMessage, error));
}

static Retry createRetry(AmqpRetryOptions options) {
final Duration delay = options.getDelay().plus(SERVER_BUSY_WAIT_TIME);
final RetryBackoffSpec retrySpec;
Expand All @@ -129,4 +170,66 @@ static Retry createRetry(AmqpRetryOptions options) {
.filter(error -> error instanceof TimeoutException
|| (error instanceof AmqpException && ((AmqpException) error).isTransient()));
}

/**
* Creates a Reactor {@link Retry} spec that performs tiered recovery between retry attempts.
* Before each retry, the error is classified and the recovery callback is invoked.
*
* <p>Includes a quick-retry optimization matching the Go SDK: on the first LINK or CONNECTION
* error, the retry fires immediately (no backoff) since the error may come from a previously
* stale link and recovery has just created a fresh one.</p>
*/
static Retry createRetryWithRecovery(AmqpRetryOptions options, Consumer<RecoveryKind> recoveryAction) {
final int maxRetries = options.getMaxRetries();
final Duration baseDelay = options.getDelay().plus(SERVER_BUSY_WAIT_TIME);
final Duration maxDelay = options.getMaxDelay();
final boolean isFixed = options.getMode() == com.azure.core.amqp.AmqpRetryMode.FIXED;
final AtomicBoolean didQuickRetry = new AtomicBoolean(false);

Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
return Retry.from(retrySignals -> retrySignals.flatMap(signal -> {
final Throwable failure = signal.failure();
final long attempt = signal.totalRetriesInARow();
final RecoveryKind kind = RecoveryKind.classify(failure);

// FATAL errors — do not retry.
if (kind == RecoveryKind.FATAL) {
return Mono.<Long>error(failure);
}

// Check retry budget.
if (attempt >= maxRetries) {
return Mono.<Long>error(failure);
}

// Perform recovery before retry.
if (kind != RecoveryKind.NONE && recoveryAction != null) {
try {
recoveryAction.accept(kind);
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
} catch (Exception e) {
LOGGER.atWarning().log("Recovery action failed.", e);
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
}
}

// Quick retry: on the FIRST LINK/CONNECTION error, retry immediately (no backoff).
// Uses didQuickRetry flag to prevent repeated immediate retries under persistent
// errors — matching the Go SDK's didQuickRetry + ResetAttempts() pattern.
if (!didQuickRetry.getAndSet(true) && (kind == RecoveryKind.LINK || kind == RecoveryKind.CONNECTION)) {
LOGGER.atInfo().log("Quick retry after {} recovery (first occurrence).", kind);
return Mono.just(attempt);
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
}

// Standard backoff delay.
final Duration delay;
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
if (isFixed) {
delay = baseDelay;
} else {
long millis = baseDelay.toMillis() * (1L << Math.min(attempt, 30));
delay = Duration.ofMillis(Math.min(millis, maxDelay.toMillis()));
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
}
final double jitter = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * JITTER_FACTOR;
final Duration jitteredDelay = Duration.ofMillis((long) (delay.toMillis() * jitter));

return Mono.delay(jitteredDelay).thenReturn(attempt);
Comment thread
EldertGrootenboer marked this conversation as resolved.
Outdated
}));
}
}
Comment thread
EldertGrootenboer marked this conversation as resolved.
Comment thread
EldertGrootenboer marked this conversation as resolved.
Loading
Loading