b9096f7c57
ExecuteSafeUnaryAsync capped the whole retry pipeline at one DefaultCallTimeout while each attempt carried the same deadline, so a first attempt ending in DeadlineExceeded spent the entire budget and the retry never ran. The outer cap is now MaxAttempts x DefaultCallTimeout plus MaxDelay per backoff (MxGatewayClientRetryPolicy.SafeUnaryBudget), in both MxGatewayClient and GalaxyRepositoryClient; a budget too large for a cancellation timer leaves only the per-attempt deadlines. Tests: MxGatewayClientWireTests.SafeUnaryRetry_AfterDeadlineExceededFirstAttempt_RunsTheRetry (real gRPC loopback server stalls the first Invoke; fails with Cancelled on the old code), plus two SafeUnaryBudget unit tests.
94 lines
4.0 KiB
C#
94 lines
4.0 KiB
C#
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using Polly;
|
|
using Polly.Retry;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Client;
|
|
|
|
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
|
|
internal static class MxGatewayClientRetryPolicy
|
|
{
|
|
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
|
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
|
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
|
/// <returns>A configured resilience pipeline that retries transient gRPC failures.</returns>
|
|
public static ResiliencePipeline Create(
|
|
MxGatewayClientRetryOptions options,
|
|
ILogger? logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
options.Validate();
|
|
|
|
return new ResiliencePipelineBuilder()
|
|
.AddRetry(new RetryStrategyOptions
|
|
{
|
|
MaxRetryAttempts = Math.Max(0, options.MaxAttempts - 1),
|
|
BackoffType = DelayBackoffType.Exponential,
|
|
UseJitter = options.UseJitter,
|
|
Delay = options.Delay,
|
|
MaxDelay = options.MaxDelay,
|
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsTransientGrpcFailure),
|
|
OnRetry = args =>
|
|
{
|
|
logger?.LogDebug(
|
|
args.Outcome.Exception,
|
|
"Retrying MXAccess Gateway client call after transient gRPC failure. Attempt {Attempt}.",
|
|
args.AttemptNumber + 1);
|
|
return default;
|
|
},
|
|
})
|
|
.Build();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the overall budget for one retried safe unary call: every attempt's
|
|
/// <see cref="MxGatewayClientOptions.DefaultCallTimeout"/> deadline plus the longest backoff
|
|
/// between attempts. Returns <see langword="null"/> when the budget is too large for a
|
|
/// cancellation timer, in which case the per-attempt deadlines and the bounded attempt count
|
|
/// already bound the call.
|
|
/// </summary>
|
|
/// <param name="options">The client options supplying the call timeout and retry settings.</param>
|
|
/// <returns>The overall budget, or <see langword="null"/> for no outer cap.</returns>
|
|
public static TimeSpan? SafeUnaryBudget(MxGatewayClientOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
int attempts = Math.Max(1, options.Retry.MaxAttempts);
|
|
double milliseconds = (options.DefaultCallTimeout.TotalMilliseconds * attempts)
|
|
+ (options.Retry.MaxDelay.TotalMilliseconds * (attempts - 1));
|
|
|
|
// CancellationTokenSource.CancelAfter rejects anything above uint.MaxValue - 1 ms.
|
|
return milliseconds >= uint.MaxValue - 1
|
|
? null
|
|
: TimeSpan.FromMilliseconds(milliseconds);
|
|
}
|
|
|
|
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
|
/// <param name="kind">The command kind to check.</param>
|
|
/// <returns><see langword="true"/> if the command kind may be automatically retried; otherwise, <see langword="false"/>.</returns>
|
|
public static bool IsRetryableCommand(MxCommandKind kind)
|
|
{
|
|
return kind is MxCommandKind.Ping
|
|
or MxCommandKind.GetSessionState
|
|
or MxCommandKind.GetWorkerInfo;
|
|
}
|
|
|
|
private static bool IsTransientGrpcFailure(Exception exception)
|
|
{
|
|
return exception switch
|
|
{
|
|
RpcException rpcException => IsTransientStatus(rpcException.StatusCode),
|
|
MxGatewayException { InnerException: RpcException rpcException } => IsTransientStatus(rpcException.StatusCode),
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
private static bool IsTransientStatus(StatusCode statusCode)
|
|
{
|
|
return statusCode is StatusCode.Unavailable
|
|
or StatusCode.DeadlineExceeded
|
|
or StatusCode.ResourceExhausted;
|
|
}
|
|
}
|