Skip to content

Commit 71c91ab

Browse files
Harden embedding retry implementation after dual-model validation
- Switch to ASP.NET-style nested options path AIOptions:EmbeddingRetry - Rename retry options model to avoid Azure.Core RetryOptions ambiguity - Add data annotations and runtime validation for retry configuration - Handle ClientResultException transient status codes (429/5xx/408) - Parse and honor Retry-After header when present - Use LoggerMessage source-generated logging instead of CA1848 suppression - Use Random.Shared for thread-safe jitter in singleton service - Preserve caller cancellation semantics (no retry/wrap on requested cancel) - Use CancellationToken.None for staging cleanup to avoid masking root failures - Cap exponential delay with MaxDelayMs to avoid overflow
1 parent d65c8f2 commit 71c91ab

4 files changed

Lines changed: 266 additions & 59 deletions

File tree

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Extensions.AI;
77
using Microsoft.Extensions.Configuration;
88
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Options;
910
using Microsoft.SemanticKernel;
1011
using Npgsql;
1112

@@ -66,13 +67,14 @@ public static IServiceCollection AddAzureOpenAIServices(
6667
.UseOpenTelemetry();
6768
#pragma warning restore SKEXP0010
6869

69-
// Register retry options with default or configuration values
70-
services.Configure<EssentialCSharp.Chat.Common.Models.RetryOptions>(options =>
71-
{
72-
// Default values are set in RetryOptions class
73-
// These can be overridden via environment variables:
74-
// EmbeddingRetry:MaxRetries, EmbeddingRetry:BaseDelayMs, etc.
75-
});
70+
// Ensure options are available even when caller provides AIOptions directly.
71+
services.AddOptions<EmbeddingRetryOptions>()
72+
.ValidateDataAnnotations()
73+
.Validate(options =>
74+
{
75+
options.Validate();
76+
return true;
77+
}, "Embedding retry configuration is invalid.");
7678

7779
// Register shared AI services
7880
services.AddSingleton<EmbeddingService>();
@@ -100,8 +102,14 @@ public static IServiceCollection AddAzureOpenAIServices(
100102

101103
// Configure retry options from configuration section
102104
// Environment variables like EmbeddingRetry:MaxRetries will override defaults
103-
services.Configure<EssentialCSharp.Chat.Common.Models.RetryOptions>(
104-
configuration.GetSection(EssentialCSharp.Chat.Common.Models.RetryOptions.SectionName));
105+
services.AddOptions<EmbeddingRetryOptions>()
106+
.Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath))
107+
.ValidateDataAnnotations()
108+
.Validate(options =>
109+
{
110+
options.Validate();
111+
return true;
112+
}, "Embedding retry configuration is invalid.");
105113

106114
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>();
107115
if (aiOptions == null)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace EssentialCSharp.Chat.Common.Models;
4+
5+
/// <summary>
6+
/// Configuration options for retry logic when calling external services like Azure OpenAI.
7+
/// </summary>
8+
public sealed class EmbeddingRetryOptions
9+
{
10+
/// <summary>
11+
/// Configuration section path in appsettings.json.
12+
/// </summary>
13+
public const string SectionPath = "AIOptions:EmbeddingRetry";
14+
15+
/// <summary>
16+
/// Maximum number of retry attempts for transient failures.
17+
/// Default is 5 attempts (initial attempt + 4 retries).
18+
/// </summary>
19+
[Range(0, 20)]
20+
public int MaxRetries { get; set; } = 5;
21+
22+
/// <summary>
23+
/// Base delay in milliseconds before the first retry.
24+
/// Subsequent retries use exponential backoff: baseDelay * (backoffMultiplier ^ attemptNumber).
25+
/// Default is 1000ms (1 second).
26+
/// </summary>
27+
[Range(0, 600000)]
28+
public int BaseDelayMs { get; set; } = 1000;
29+
30+
/// <summary>
31+
/// Maximum delay in milliseconds for exponential backoff before jitter.
32+
/// This caps retry delays to avoid overflow and unbounded waits.
33+
/// </summary>
34+
[Range(1, 600000)]
35+
public int MaxDelayMs { get; set; } = 60000;
36+
37+
/// <summary>
38+
/// Exponential backoff multiplier. Each retry delay is multiplied by this value.
39+
/// For example, with baseDelay=1000ms and multiplier=2.0:
40+
/// - 1st retry: 1000ms
41+
/// - 2nd retry: 2000ms
42+
/// - 3rd retry: 4000ms
43+
/// - 4th retry: 8000ms
44+
/// Default is 2.0 (double each time).
45+
/// </summary>
46+
[Range(1.0, 10.0)]
47+
public double BackoffMultiplier { get; set; } = 2.0;
48+
49+
/// <summary>
50+
/// Maximum jitter fraction added to each retry delay to prevent thundering herd.
51+
/// Jitter is a random value in range [0, maxDelay * maxJitterFraction].
52+
/// For example, with maxJitterFraction=0.2 and delay=1000ms:
53+
/// actual delay will be between 1000ms and 1200ms.
54+
/// Default is 0.2 (20% jitter).
55+
/// </summary>
56+
[Range(0.0, 1.0)]
57+
public double MaxJitterFraction { get; set; } = 0.2;
58+
59+
/// <summary>
60+
/// Validates that configuration values are reasonable.
61+
/// </summary>
62+
/// <exception cref="InvalidOperationException">Thrown if configuration is invalid.</exception>
63+
public void Validate()
64+
{
65+
if (MaxRetries < 0)
66+
throw new InvalidOperationException("MaxRetries must be non-negative.");
67+
68+
if (BaseDelayMs < 0)
69+
throw new InvalidOperationException("BaseDelayMs must be non-negative.");
70+
71+
if (MaxDelayMs <= 0)
72+
throw new InvalidOperationException("MaxDelayMs must be positive.");
73+
74+
if (BaseDelayMs > MaxDelayMs)
75+
throw new InvalidOperationException("BaseDelayMs must be less than or equal to MaxDelayMs.");
76+
77+
if (BackoffMultiplier < 1.0)
78+
throw new InvalidOperationException("BackoffMultiplier must be >= 1.0.");
79+
80+
if (MaxJitterFraction < 0.0 || MaxJitterFraction > 1.0)
81+
throw new InvalidOperationException("MaxJitterFraction must be between 0.0 and 1.0.");
82+
}
83+
}

0 commit comments

Comments
 (0)