Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 21:45:32 +02:00
510 changed files with 138401 additions and 51276 deletions

View File

@@ -0,0 +1,269 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.Worker.Options;
namespace StellaOps.Notifier.Worker.Dispatch;
/// <summary>
/// Background worker that dispatches pending notification deliveries.
/// </summary>
public sealed class DeliveryDispatchWorker : BackgroundService
{
private readonly IServiceProvider _services;
private readonly NotifierWorkerOptions _options;
private readonly ILogger<DeliveryDispatchWorker> _logger;
private readonly string _workerId;
public DeliveryDispatchWorker(
IServiceProvider services,
IOptions<NotifierWorkerOptions> options,
ILogger<DeliveryDispatchWorker> logger)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_workerId = $"dispatch-{Environment.MachineName}-{Guid.NewGuid():N}";
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} starting.", _workerId);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessPendingDeliveriesAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "DeliveryDispatchWorker {WorkerId} encountered error.", _workerId);
}
var interval = _options.DispatchInterval > TimeSpan.Zero
? _options.DispatchInterval
: TimeSpan.FromSeconds(5);
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
}
_logger.LogInformation("DeliveryDispatchWorker {WorkerId} stopping.", _workerId);
}
private async Task ProcessPendingDeliveriesAsync(CancellationToken cancellationToken)
{
using var scope = _services.CreateScope();
var deliveryRepository = scope.ServiceProvider.GetService<INotifyDeliveryRepository>();
var templateRepository = scope.ServiceProvider.GetService<INotifyTemplateRepository>();
var channelRepository = scope.ServiceProvider.GetService<INotifyChannelRepository>();
var renderer = scope.ServiceProvider.GetService<INotifyTemplateRenderer>();
var dispatchers = scope.ServiceProvider.GetServices<INotifyChannelDispatcher>().ToArray();
if (deliveryRepository is null || templateRepository is null ||
channelRepository is null || renderer is null || dispatchers.Length == 0)
{
_logger.LogDebug("Required services not registered; skipping dispatch cycle.");
return;
}
var batchSize = _options.DispatchBatchSize > 0 ? _options.DispatchBatchSize : 100;
var pendingDeliveries = await deliveryRepository
.ListPendingAsync(batchSize, cancellationToken)
.ConfigureAwait(false);
if (pendingDeliveries.Count == 0)
{
return;
}
_logger.LogDebug("Processing {Count} pending deliveries.", pendingDeliveries.Count);
var channelCache = new Dictionary<(string TenantId, string ChannelId), NotifyChannel?>();
var templateCache = new Dictionary<(string TenantId, string TemplateId), NotifyTemplate?>();
var dispatcherMap = dispatchers
.SelectMany(d => d.SupportedTypes.Select(t => (Type: t, Dispatcher: d)))
.ToDictionary(x => x.Type, x => x.Dispatcher);
foreach (var delivery in pendingDeliveries)
{
try
{
await ProcessDeliveryAsync(
delivery,
deliveryRepository,
templateRepository,
channelRepository,
renderer,
dispatcherMap,
channelCache,
templateCache,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process delivery {DeliveryId} for tenant {TenantId}.",
delivery.DeliveryId,
delivery.TenantId);
await MarkDeliveryFailedAsync(
deliveryRepository,
delivery,
ex.Message,
cancellationToken).ConfigureAwait(false);
}
}
}
private async Task ProcessDeliveryAsync(
NotifyDelivery delivery,
INotifyDeliveryRepository deliveryRepository,
INotifyTemplateRepository templateRepository,
INotifyChannelRepository channelRepository,
INotifyTemplateRenderer renderer,
Dictionary<NotifyChannelType, INotifyChannelDispatcher> dispatcherMap,
Dictionary<(string, string), NotifyChannel?> channelCache,
Dictionary<(string, string), NotifyTemplate?> templateCache,
CancellationToken cancellationToken)
{
// Get channel
var channelId = delivery.Metadata.GetValueOrDefault("channel") ?? string.Empty;
var channelKey = (delivery.TenantId, channelId);
if (!channelCache.TryGetValue(channelKey, out var channel))
{
channel = await channelRepository
.GetAsync(delivery.TenantId, channelId, cancellationToken)
.ConfigureAwait(false);
channelCache[channelKey] = channel;
}
if (channel is null)
{
_logger.LogWarning(
"Channel {ChannelId} not found for delivery {DeliveryId}.",
channelId,
delivery.DeliveryId);
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Channel not found", cancellationToken)
.ConfigureAwait(false);
return;
}
// Get template
var templateId = delivery.Metadata.GetValueOrDefault("template") ?? string.Empty;
var templateKey = (delivery.TenantId, templateId);
if (!templateCache.TryGetValue(templateKey, out var template))
{
template = await templateRepository
.GetAsync(delivery.TenantId, templateId, cancellationToken)
.ConfigureAwait(false);
templateCache[templateKey] = template;
}
if (template is null)
{
_logger.LogWarning(
"Template {TemplateId} not found for delivery {DeliveryId}.",
templateId,
delivery.DeliveryId);
await MarkDeliveryFailedAsync(deliveryRepository, delivery, "Template not found", cancellationToken)
.ConfigureAwait(false);
return;
}
// Get dispatcher
if (!dispatcherMap.TryGetValue(channel.Type, out var dispatcher))
{
_logger.LogWarning(
"No dispatcher for channel type {ChannelType} (delivery {DeliveryId}).",
channel.Type,
delivery.DeliveryId);
await MarkDeliveryFailedAsync(deliveryRepository, delivery, $"No dispatcher for {channel.Type}", cancellationToken)
.ConfigureAwait(false);
return;
}
// Build event from delivery metadata for rendering
var notifyEvent = RebuildEventForRendering(delivery);
// Render template
var content = await renderer.RenderAsync(template, notifyEvent, cancellationToken).ConfigureAwait(false);
// Dispatch
var result = await dispatcher.DispatchAsync(channel, content, delivery, cancellationToken).ConfigureAwait(false);
// Update delivery status
var attempt = new NotifyDeliveryAttempt(
timestamp: DateTimeOffset.UtcNow,
status: result.Success ? NotifyDeliveryAttemptStatus.Success : NotifyDeliveryAttemptStatus.Failed,
reason: result.ErrorMessage);
var updatedDelivery = delivery with
{
Status = result.Status,
StatusReason = result.ErrorMessage,
CompletedAt = result.Success ? DateTimeOffset.UtcNow : null,
Attempts = delivery.Attempts.Add(attempt)
};
await deliveryRepository.UpdateAsync(updatedDelivery, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Delivery {DeliveryId} {Status}: {Message}",
delivery.DeliveryId,
result.Status,
result.Success ? "dispatched successfully" : result.ErrorMessage);
}
private static NotifyEvent RebuildEventForRendering(NotifyDelivery delivery)
{
// Reconstruct a minimal event for template rendering
return NotifyEvent.Create(
eventId: delivery.EventId,
kind: delivery.Kind,
tenant: delivery.TenantId,
ts: delivery.CreatedAt,
payload: System.Text.Json.Nodes.JsonNode.Parse(
System.Text.Json.JsonSerializer.Serialize(delivery.Metadata)) as System.Text.Json.Nodes.JsonObject
?? new System.Text.Json.Nodes.JsonObject(),
version: "1");
}
private async Task MarkDeliveryFailedAsync(
INotifyDeliveryRepository repository,
NotifyDelivery delivery,
string errorMessage,
CancellationToken cancellationToken)
{
var attempt = new NotifyDeliveryAttempt(
timestamp: DateTimeOffset.UtcNow,
status: NotifyDeliveryAttemptStatus.Failed,
reason: errorMessage);
var updated = delivery with
{
Status = NotifyDeliveryStatus.Failed,
StatusReason = errorMessage,
Attempts = delivery.Attempts.Add(attempt)
};
try
{
await repository.UpdateAsync(updated, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update delivery {DeliveryId} status.", delivery.DeliveryId);
}
}
}

View File

@@ -0,0 +1,87 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Dispatch;
/// <summary>
/// Dispatches rendered notifications to channels.
/// </summary>
public interface INotifyChannelDispatcher
{
/// <summary>
/// Dispatches a notification to the specified channel.
/// </summary>
/// <param name="channel">Target channel configuration.</param>
/// <param name="content">Rendered notification content.</param>
/// <param name="delivery">Delivery record for tracking.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dispatch result with status and details.</returns>
Task<NotifyDispatchResult> DispatchAsync(
NotifyChannel channel,
NotifyRenderedContent content,
NotifyDelivery delivery,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the channel types supported by this dispatcher.
/// </summary>
IReadOnlyCollection<NotifyChannelType> SupportedTypes { get; }
}
/// <summary>
/// Result of a notification dispatch attempt.
/// </summary>
public sealed class NotifyDispatchResult
{
/// <summary>
/// Gets a value indicating whether the dispatch succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Gets the resulting delivery status.
/// </summary>
public required NotifyDeliveryStatus Status { get; init; }
/// <summary>
/// Gets an optional error message if dispatch failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Gets a value indicating whether the error is retryable.
/// </summary>
public bool IsRetryable { get; init; }
/// <summary>
/// Gets the number of retry attempts made.
/// </summary>
public int AttemptCount { get; init; }
/// <summary>
/// Gets an optional external reference (e.g., message ID from the channel).
/// </summary>
public string? ExternalReference { get; init; }
/// <summary>
/// Creates a successful dispatch result.
/// </summary>
public static NotifyDispatchResult Succeeded(int attempts = 1, string? externalReference = null) => new()
{
Success = true,
Status = NotifyDeliveryStatus.Delivered,
AttemptCount = attempts,
ExternalReference = externalReference
};
/// <summary>
/// Creates a failed dispatch result.
/// </summary>
public static NotifyDispatchResult Failed(string errorMessage, bool isRetryable = false, int attempts = 1) => new()
{
Success = false,
Status = isRetryable ? NotifyDeliveryStatus.Pending : NotifyDeliveryStatus.Failed,
ErrorMessage = errorMessage,
IsRetryable = isRetryable,
AttemptCount = attempts
};
}

View File

@@ -0,0 +1,47 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Dispatch;
/// <summary>
/// Renders notification templates using event payload data.
/// </summary>
public interface INotifyTemplateRenderer
{
/// <summary>
/// Renders a template with the provided event payload.
/// </summary>
/// <param name="template">The template to render.</param>
/// <param name="notifyEvent">The event containing payload data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Rendered content with body and optional subject.</returns>
Task<NotifyRenderedContent> RenderAsync(
NotifyTemplate template,
NotifyEvent notifyEvent,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Represents rendered notification content.
/// </summary>
public sealed class NotifyRenderedContent
{
/// <summary>
/// Gets the rendered body content.
/// </summary>
public required string Body { get; init; }
/// <summary>
/// Gets the optional rendered subject (for email).
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Gets the content hash for deduplication.
/// </summary>
public required string BodyHash { get; init; }
/// <summary>
/// Gets the render format (Markdown, Html, Json, PlainText).
/// </summary>
public required NotifyDeliveryFormat Format { get; init; }
}

View File

@@ -0,0 +1,200 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Dispatch;
/// <summary>
/// Simple template renderer using Handlebars-style {{variable}} substitution.
/// Supports dot notation for nested properties and {{#each}} for iteration.
/// </summary>
public sealed partial class SimpleTemplateRenderer : INotifyTemplateRenderer
{
private readonly ILogger<SimpleTemplateRenderer> _logger;
private readonly HashSet<string> _redactedKeys;
public SimpleTemplateRenderer(ILogger<SimpleTemplateRenderer> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_redactedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"secret", "password", "token", "key", "apikey", "api_key", "credential"
};
}
public Task<NotifyRenderedContent> RenderAsync(
NotifyTemplate template,
NotifyEvent notifyEvent,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(notifyEvent);
var context = BuildContext(notifyEvent);
var body = RenderTemplate(template.Body, context);
string? subject = null;
if (template.Metadata.TryGetValue("subject", out var subjectTemplate) &&
!string.IsNullOrWhiteSpace(subjectTemplate))
{
subject = RenderTemplate(subjectTemplate, context);
}
var bodyHash = ComputeHash(body);
_logger.LogDebug(
"Rendered template {TemplateId} for event {EventId}: {BodyLength} chars, hash={BodyHash}",
template.TemplateId,
notifyEvent.EventId,
body.Length,
bodyHash);
return Task.FromResult(new NotifyRenderedContent
{
Body = body,
Subject = subject,
BodyHash = bodyHash,
Format = template.Format
});
}
private Dictionary<string, object?> BuildContext(NotifyEvent notifyEvent)
{
var context = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["eventId"] = notifyEvent.EventId.ToString(),
["kind"] = notifyEvent.Kind,
["tenant"] = notifyEvent.Tenant,
["timestamp"] = notifyEvent.Timestamp.ToString("O"),
["actor"] = notifyEvent.Actor,
["version"] = notifyEvent.Version,
};
if (notifyEvent.Payload is JsonObject payload)
{
FlattenJson(payload, context, string.Empty);
}
foreach (var (key, value) in notifyEvent.Attributes)
{
context[$"attr.{key}"] = RedactIfSensitive(key, value);
}
return context;
}
private void FlattenJson(JsonObject obj, Dictionary<string, object?> context, string prefix)
{
foreach (var property in obj)
{
var key = string.IsNullOrEmpty(prefix) ? property.Key : $"{prefix}.{property.Key}";
if (property.Value is JsonObject nested)
{
FlattenJson(nested, context, key);
}
else if (property.Value is JsonArray array)
{
context[key] = array;
}
else
{
var value = property.Value?.GetValue<object>();
context[key] = RedactIfSensitive(property.Key, value?.ToString());
}
}
}
private string? RedactIfSensitive(string key, string? value)
{
if (string.IsNullOrEmpty(value)) return value;
foreach (var redacted in _redactedKeys)
{
if (key.Contains(redacted, StringComparison.OrdinalIgnoreCase))
{
return "[REDACTED]";
}
}
return value;
}
private string RenderTemplate(string template, Dictionary<string, object?> context)
{
if (string.IsNullOrEmpty(template)) return string.Empty;
var result = template;
// Handle {{#each collection}}...{{/each}} blocks
result = EachBlockRegex().Replace(result, match =>
{
var collectionName = match.Groups[1].Value.Trim();
var innerTemplate = match.Groups[2].Value;
if (!context.TryGetValue(collectionName, out var collection) || collection is not JsonArray array)
{
return string.Empty;
}
var sb = new StringBuilder();
foreach (var item in array)
{
var itemContext = new Dictionary<string, object?>(context, StringComparer.OrdinalIgnoreCase)
{
["this"] = item?.ToString()
};
if (item is JsonObject itemObj)
{
foreach (var prop in itemObj)
{
itemContext[$"@{prop.Key}"] = prop.Value?.ToString();
}
}
sb.Append(RenderSimpleVariables(innerTemplate, itemContext));
}
return sb.ToString();
});
// Handle simple {{variable}} substitution
result = RenderSimpleVariables(result, context);
return result;
}
private static string RenderSimpleVariables(string template, Dictionary<string, object?> context)
{
return VariableRegex().Replace(template, match =>
{
var key = match.Groups[1].Value.Trim();
if (context.TryGetValue(key, out var value) && value is not null)
{
return value.ToString() ?? string.Empty;
}
// Return empty string for missing variables
return string.Empty;
});
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
[GeneratedRegex(@"\{\{#each\s+(\w+(?:\.\w+)*)\s*\}\}(.*?)\{\{/each\}\}", RegexOptions.Singleline)]
private static partial Regex EachBlockRegex();
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
private static partial Regex VariableRegex();
}

View File

@@ -0,0 +1,198 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Dispatch;
/// <summary>
/// Dispatches notifications to webhook endpoints (Slack, generic webhook).
/// </summary>
public sealed class WebhookChannelDispatcher : INotifyChannelDispatcher
{
private readonly HttpClient _httpClient;
private readonly ILogger<WebhookChannelDispatcher> _logger;
private static readonly IReadOnlyCollection<NotifyChannelType> Types = new[]
{
NotifyChannelType.Slack,
NotifyChannelType.Webhook,
NotifyChannelType.Custom
};
public WebhookChannelDispatcher(HttpClient httpClient, ILogger<WebhookChannelDispatcher> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public IReadOnlyCollection<NotifyChannelType> SupportedTypes => Types;
public async Task<NotifyDispatchResult> DispatchAsync(
NotifyChannel channel,
NotifyRenderedContent content,
NotifyDelivery delivery,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(delivery);
var endpoint = channel.Config?.Endpoint;
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
_logger.LogError(
"Channel {ChannelId} has invalid endpoint: {Endpoint}",
channel.ChannelId,
endpoint ?? "(null)");
return NotifyDispatchResult.Failed("Invalid webhook endpoint configuration");
}
var payload = BuildPayload(channel, content, delivery);
_logger.LogDebug(
"Dispatching to {ChannelType} channel {ChannelId} at {Endpoint}",
channel.Type,
channel.ChannelId,
uri.Host);
const int maxAttempts = 3;
var attempt = 0;
Exception? lastException = null;
while (attempt < maxAttempts)
{
attempt++;
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Successfully dispatched delivery {DeliveryId} to {ChannelId} (attempt {Attempt})",
delivery.DeliveryId,
channel.ChannelId,
attempt);
return NotifyDispatchResult.Succeeded(attempt);
}
var statusCode = (int)response.StatusCode;
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"Webhook dispatch failed for {DeliveryId}: {StatusCode} {ReasonPhrase}. Response: {Response}",
delivery.DeliveryId,
statusCode,
response.ReasonPhrase,
responseBody.Length > 500 ? responseBody[..500] + "..." : responseBody);
var isRetryable = IsRetryableStatusCode(response.StatusCode);
if (!isRetryable || attempt >= maxAttempts)
{
return NotifyDispatchResult.Failed(
$"HTTP {statusCode}: {response.ReasonPhrase}",
isRetryable,
attempt);
}
// Exponential backoff before retry
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
lastException = ex;
_logger.LogWarning(
ex,
"HTTP request failed for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
delivery.DeliveryId,
attempt,
maxAttempts);
if (attempt >= maxAttempts)
{
return NotifyDispatchResult.Failed(ex.Message, isRetryable: true, attempt);
}
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
// Timeout
lastException = ex;
_logger.LogWarning(
"Request timeout for {DeliveryId} (attempt {Attempt}/{MaxAttempts})",
delivery.DeliveryId,
attempt,
maxAttempts);
if (attempt >= maxAttempts)
{
return NotifyDispatchResult.Failed("Request timeout", isRetryable: true, attempt);
}
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
return NotifyDispatchResult.Failed(
lastException?.Message ?? "Max retry attempts exceeded",
isRetryable: true,
attempt);
}
private static string BuildPayload(NotifyChannel channel, NotifyRenderedContent content, NotifyDelivery delivery)
{
if (channel.Type == NotifyChannelType.Slack)
{
// Slack webhook format
return JsonSerializer.Serialize(new
{
text = content.Body,
channel = channel.Config?.Target,
});
}
// Generic webhook format
return JsonSerializer.Serialize(new
{
deliveryId = delivery.DeliveryId,
eventId = delivery.EventId,
kind = delivery.Kind,
tenant = delivery.TenantId,
body = content.Body,
subject = content.Subject,
bodyHash = content.BodyHash,
format = content.Format.ToString(),
timestamp = DateTimeOffset.UtcNow.ToString("O")
});
}
private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
{
return statusCode switch
{
HttpStatusCode.TooManyRequests => true,
HttpStatusCode.InternalServerError => true,
HttpStatusCode.BadGateway => true,
HttpStatusCode.ServiceUnavailable => true,
HttpStatusCode.GatewayTimeout => true,
_ => false
};
}
}