diff --git a/src/Aspire.Hosting.Azure.Kusto/AzureKustoBuilderExtensions.cs b/src/Aspire.Hosting.Azure.Kusto/AzureKustoBuilderExtensions.cs index d86bf9f2379..bb4158f8c0d 100644 --- a/src/Aspire.Hosting.Azure.Kusto/AzureKustoBuilderExtensions.cs +++ b/src/Aspire.Hosting.Azure.Kusto/AzureKustoBuilderExtensions.cs @@ -13,7 +13,6 @@ using Kusto.Data.Net.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Polly; namespace Aspire.Hosting.Azure.Kusto; @@ -22,15 +21,6 @@ namespace Aspire.Hosting.Azure.Kusto; /// public static class AzureKustoBuilderExtensions { - private static readonly ResiliencePipeline s_pipeline = new ResiliencePipelineBuilder() - .AddRetry(new() - { - MaxRetryAttempts = 3, - Delay = TimeSpan.FromSeconds(2), - ShouldHandle = new PredicateBuilder().Handle(), - }) - .Build(); - /// /// Adds an Azure Data Explorer (Kusto) cluster resource to the application model. /// @@ -293,7 +283,7 @@ private static async Task CreateDatabaseAsync(ICslAdminProvider adminProvider, A try { - await s_pipeline.ExecuteAsync(async cancellationToken => await adminProvider.ExecuteControlCommandAsync(databaseResource.DatabaseName, script, crp).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + await AzureKustoEmulatorResiliencePipelines.Default.ExecuteAsync(async ct => await adminProvider.ExecuteControlCommandAsync(databaseResource.DatabaseName, script, crp).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); logger.LogDebug("Database '{DatabaseName}' created successfully", databaseResource.DatabaseName); } catch (KustoBadRequestException e) when (e.Message.Contains("EntityNameAlreadyExistsException")) diff --git a/src/Aspire.Hosting.Azure.Kusto/AzureKustoEmulatorResiliencePipelines.cs b/src/Aspire.Hosting.Azure.Kusto/AzureKustoEmulatorResiliencePipelines.cs new file mode 100644 index 00000000000..c8964d96aca --- /dev/null +++ b/src/Aspire.Hosting.Azure.Kusto/AzureKustoEmulatorResiliencePipelines.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Kusto.Cloud.Platform.Utils; +using Polly; + +namespace Aspire.Hosting.Azure; + +/// +/// Provides pre-configured resilience pipelines for Azure Kusto emulator operations. +/// +internal static class AzureKustoEmulatorResiliencePipelines +{ + /// + /// Gets a resilience pipeline configured to handle non-permanent exceptions. + /// + public static ResiliencePipeline Default { get; } = new ResiliencePipelineBuilder() + .AddRetry(new() + { + MaxRetryAttempts = 10, + Delay = TimeSpan.FromMilliseconds(100), + BackoffType = DelayBackoffType.Exponential, + ShouldHandle = new PredicateBuilder().Handle(IsTransient), + }) + .Build(); + + /// + /// Determines whether the specified exception represents a transient error that may succeed if retried. + /// + /// + /// There's no common base exception type between exceptions in the Kusto.Data and Kusto.Ingest libraries, however + /// they do share a common interface, , which has the IsPermanent property. + /// + private static bool IsTransient(Exception ex) => ex is ICloudPlatformException cpe && !cpe.IsPermanent; +} diff --git a/tests/Aspire.Hosting.Azure.Kusto.Tests/KustoResiliencePipelinesTests.cs b/tests/Aspire.Hosting.Azure.Kusto.Tests/KustoResiliencePipelinesTests.cs new file mode 100644 index 00000000000..daf2a51f59e --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Kusto.Tests/KustoResiliencePipelinesTests.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Kusto.Data.Exceptions; + +namespace Aspire.Hosting.Azure.Kusto.Tests; + +public class KustoResiliencePipelinesTests +{ + [Fact] + public async Task ShouldRetryOnTemporaryExceptions() + { + // Arrange + var attemptCount = 0; + ValueTask work(CancellationToken ct) + { + attemptCount++; + throw new KustoRequestThrottledException(); + } + + // Act + Assert + await Assert.ThrowsAsync(async () => + { + await AzureKustoEmulatorResiliencePipelines.Default.ExecuteAsync(work, TestContext.Current.CancellationToken); + }); + Assert.True(attemptCount > 1, "Operation should have been retried"); + } + + [Fact] + public async Task ShouldNotRetryOnOtherExceptions() + { + // Arrange + var attemptCount = 0; + ValueTask work(CancellationToken ct) + { + attemptCount++; + throw new InvalidOperationException(); + } + + // Act + Assert + await Assert.ThrowsAsync(async () => + { + await AzureKustoEmulatorResiliencePipelines.Default.ExecuteAsync(work, TestContext.Current.CancellationToken); + }); + Assert.Equal(1, attemptCount); + } + + [Fact] + public async Task ShouldNotRetryOnPermanentExceptions() + { + // Arrange + var attemptCount = 0; + ValueTask work(CancellationToken ct) + { + attemptCount++; + throw new KustoBadRequestException(); + } + + // Act + Assert + await Assert.ThrowsAsync(async () => + { + await AzureKustoEmulatorResiliencePipelines.Default.ExecuteAsync(work, TestContext.Current.CancellationToken); + }); + Assert.Equal(1, attemptCount); + } +}