From 67e46ec906100f9932aeebf22f8825d1452ec62b Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:48:46 -0400 Subject: [PATCH 01/11] replace Delay with PeriodicTimer --- src/SimpleL7Proxy/User/UserProfile.cs | 42 ++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/SimpleL7Proxy/User/UserProfile.cs b/src/SimpleL7Proxy/User/UserProfile.cs index bc395eb4..eda32ae8 100644 --- a/src/SimpleL7Proxy/User/UserProfile.cs +++ b/src/SimpleL7Proxy/User/UserProfile.cs @@ -52,8 +52,12 @@ public class UserProfile : BackgroundService, IUserProfileService, IConfigChange private int NormalDelayMs; private const int s_ErrorDelayMs = 3000; // 3 seconds + private const int s_ConfigDelayMs = 30*1000; // 30 seconds private bool _configRequired = false; + private PeriodicTimer _timer =null!; // these are defined in InitVars() + private PeriodicTimer _ErrorTimer =null!; + private PeriodicTimer _waitingConfigTimer = null!; public UserProfile(ProxyConfig options, ConfigChangeNotifier configChangeNotifier, ILogger logger) { ArgumentNullException.ThrowIfNull(options, nameof(options)); @@ -66,6 +70,7 @@ public UserProfile(ProxyConfig options, ConfigChangeNotifier configChangeNotifie _logger.LogInformation("[PROFILE] Required: {required}, Header: {Header}, Interval: {RefreshInterval}s, Soft-delete TTL: {SoftDeleteTTL} min, Config: {ConfigUrl}, Suspended: {SuspendedConfigUrl}, AuthAppID: {AuthAppIDConfigUrl}", options.UserConfigRequired, options.UserIDFieldName, options.UserConfigRefreshIntervalSecs, options.UserSoftDeleteTTLMinutes, options.UserConfigUrl, options.SuspendedUserConfigUrl, options.ValidateAuthAppIDUrl); + _waitingConfigTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(s_ConfigDelayMs)); InitVars(); // Subscribe to config change notifications @@ -90,6 +95,18 @@ public void InitVars() NormalDelayMs = _options.UserConfigRefreshIntervalSecs * 1000; // Configurable interval _configRequired = _options.UserConfigRequired; + if (_timer != null) + { + _timer.Dispose(); + } + + if (_ErrorTimer != null) + { + _ErrorTimer.Dispose(); + } + + _timer = new PeriodicTimer(TimeSpan.FromMilliseconds(NormalDelayMs)); + _ErrorTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(s_ErrorDelayMs)); } public Task OnConfigChangedAsync( @@ -150,7 +167,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - DateTime startTime = DateTime.UtcNow; + if (!doUserConfig && !doSuspendedUserConfig && !doAuthAppIDConfig ) + { + await _waitingConfigTimer.WaitForNextTickAsync(_cancellationTokenSource.Token).ConfigureAwait(false); + isInitialized = true; + continue; + } + sb.Clear(); sb.Append("[PROFILE] "); bool success = false; @@ -224,23 +247,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) sb.Append($", Auth: {authAppIDsConfigStatus}"); } - if (sb.Length > 0) - _logger.LogInformation(sb.ToString()); - - if (profileTask == null && suspendedTask == null && authTask == null) - { - success = true; // No configs to load means we're ready by default - } - - int baseDelay = success ? NormalDelayMs : s_ErrorDelayMs; - int elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; - int remainingDelay = Math.Max(0, baseDelay - elapsedMs); + _logger.LogInformation(sb.ToString()); // initilized if: no errors && hasData || users not required isInitialized = !_configRequired || localIsInitialized; // Console.WriteLine($"[PROFILE-DEBUG] Load {(success ? "succeeded" : "failed")}, Initialized: {isInitialized}, LocalIsInitialized: {localIsInitialized}"); - await Task.Delay(remainingDelay, stoppingToken).ConfigureAwait(false); + if (success) + await _timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false); + else + await _ErrorTimer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false); } catch (TaskCanceledException) when (stoppingToken.IsCancellationRequested) { From 82e803756238ff44121d570109b7132fe5d4ba13 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:49:25 -0400 Subject: [PATCH 02/11] potential memory leak --- .../Events/ServiceBus/ServiceBusRequestService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs b/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs index f39c43b2..0816fbe4 100644 --- a/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs +++ b/src/SimpleL7Proxy/Events/ServiceBus/ServiceBusRequestService.cs @@ -112,7 +112,8 @@ public async Task EventWriter(CancellationToken token) finally { // Flush all items in batches - var cts = new CancellationTokenSource().Token; + using var ctsSource = new CancellationTokenSource(); + var cts = ctsSource.Token; var drained = new List(); while (_statusQueue.TryDequeue(out var statusMessage)) From 4b547777851bfdf9cf06996499781d03ccb57cad Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:50:41 -0400 Subject: [PATCH 03/11] bug fix for logging events to console --- src/SimpleL7Proxy/Events/LogTargetAttr.cs | 13 ++++++++++-- src/SimpleL7Proxy/Events/ProxyEvent.cs | 24 ++++++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/SimpleL7Proxy/Events/LogTargetAttr.cs b/src/SimpleL7Proxy/Events/LogTargetAttr.cs index f1b6dc66..2c6c4729 100644 --- a/src/SimpleL7Proxy/Events/LogTargetAttr.cs +++ b/src/SimpleL7Proxy/Events/LogTargetAttr.cs @@ -9,6 +9,7 @@ public class LogTargetAttr public bool Async; public bool BackendRequest; public bool Probe; + public bool Poller; public bool CircuitBreakerError; public bool Console; public bool CustomEvent; @@ -24,14 +25,15 @@ public class LogTargetAttr public bool IsEnabled(EventType type) => type switch { EventType.AsyncProcessing => Async, - EventType.Backend or EventType.Poller => BackendRequest, + EventType.Backend or EventType.BackendRequest => BackendRequest, + EventType.Poller => Poller, EventType.Probe => Probe, EventType.CircuitBreakerError => CircuitBreakerError, EventType.Console => Console, EventType.CustomEvent => CustomEvent, EventType.Exception or EventType.ServerError => Exception, EventType.ProfileError => ProfileError, - EventType.ProxyError or EventType.BackendRequest + EventType.ProxyError or EventType.ProxyRequest or EventType.ProxyRequestExpired or EventType.ProxyRequestRequeued => ProxyRequest, EventType.ProxyRequestEnqueued => ProxyRequestEnqueued, @@ -54,6 +56,7 @@ public static LogTargetAttr From(List? configList) Async = all || set!.Contains("async"), BackendRequest = all || set!.Contains("backend"), Probe = all || set!.Contains("probe"), + Poller = all || set!.Contains("poller"), CircuitBreakerError = all || set!.Contains("circuitbreaker"), Console = all || set!.Contains("console"), CustomEvent = all || set!.Contains("custom"), @@ -64,4 +67,10 @@ public static LogTargetAttr From(List? configList) Authentication = all || set!.Contains("auth"), }; } + + public string ToString() + { + return $"Async: {Async}, BackendRequest: {BackendRequest}, Probe: {Probe}, CircuitBreakerError: {CircuitBreakerError}, Console: {Console}, CustomEvent: {CustomEvent}, Exception: {Exception}, ProfileError: {ProfileError}, ProxyRequest: {ProxyRequest}, ProxyRequestEnqueued: {ProxyRequestEnqueued}, Authentication: {Authentication}"; + } + } diff --git a/src/SimpleL7Proxy/Events/ProxyEvent.cs b/src/SimpleL7Proxy/Events/ProxyEvent.cs index 3416768b..60d73dbc 100644 --- a/src/SimpleL7Proxy/Events/ProxyEvent.cs +++ b/src/SimpleL7Proxy/Events/ProxyEvent.cs @@ -38,6 +38,11 @@ public void InitVars() ProxyEvent.ConAttr = LogTargetAttr.From(options.Value.LogToConsole); ProxyEvent.EventAttr = LogTargetAttr.From(options.Value.LogToEvents); ProxyEvent.AIAttr = LogTargetAttr.From(options.Value.LogToAI); + + // Console.WriteLine($"INIT --- : ConAttr: {string.Join(", ", options.Value.LogToConsole)} - {ProxyEvent.ConAttr.ToString()}"); + // Console.WriteLine($"INIT --- : EventAttr: {string.Join(", ", options.Value.LogToEvents)} - {ProxyEvent.EventAttr.ToString()}"); + // Console.WriteLine($"INIT --- : AIAttr: {string.Join(", ", options.Value.LogToAI)} - {ProxyEvent.AIAttr.ToString()}"); + } public Task OnConfigChangedAsync( @@ -160,6 +165,7 @@ public void SendEvent() switch (Type) { case EventType.BackendRequest: + case EventType.Poller: TrackDependancy(); break; @@ -193,7 +199,23 @@ public void SendEvent() if (logToConsole) { - Console.WriteLine($"Event: {Type}, MID: {MID}, Uri: {Uri}, Status: {(int)Status}, Duration: {Duration.TotalMilliseconds}ms"); + StringBuilder sb = new StringBuilder(); + switch (Type) + { + case EventType.Exception: + case EventType.ServerError: + case EventType.ProxyError: + Console.WriteLine("Sending event data to the console .. type: " + Type); + break; + + default: + foreach (var kvp in this) + { + sb.Append($"{kvp.Key}: {kvp.Value} "); + } + Console.WriteLine($"[{Type}]: {sb} "); + break; + } } } catch (Exception ex) From 45e1fa16ff8f70f15401e40f42ea987dc31c907a Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:51:22 -0400 Subject: [PATCH 04/11] reorder configs to make more sense in app config --- src/SimpleL7Proxy/Config/ProxyConfig.cs | 75 +++++++++++-------------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/src/SimpleL7Proxy/Config/ProxyConfig.cs b/src/SimpleL7Proxy/Config/ProxyConfig.cs index 7c3b77d9..24856cae 100644 --- a/src/SimpleL7Proxy/Config/ProxyConfig.cs +++ b/src/SimpleL7Proxy/Config/ProxyConfig.cs @@ -47,8 +47,6 @@ public class ProxyConfig // ── Server ── [ConfigOption("LoadBalancing:IterationMode")] public IterationModeEnum IterationMode { get; set; } = IterationModeEnum.SinglePass; - [ConfigOption("Server:Timeout")] - public int Timeout { get; set; } = 60*20*1000; // 20 minutes // ── Logging ── [ConfigOption("Logging:LogToConsole")] @@ -56,7 +54,7 @@ public class ProxyConfig [ConfigOption("Logging:LogToEvents")] public List LogToEvents { get; set; } = ["async","backend","probe","circuitbreaker","custom","exception","profile","proxy","enqueued","auth"]; [ConfigOption("Logging:LogToAI")] - public List LogToAI { get; set; } = ["*"]; + public List LogToAI { get; set; } = [""]; [ConfigOption("Logging:LogHeaders")] public List LogHeaders { get; set; } = []; [ConfigOption("Logging:LogAllRequestHeaders")] @@ -71,33 +69,42 @@ public class ProxyConfig [ConfigOption("Logging:ReuseEvents", Mode = ConfigMode.Cold)] public bool ReuseEvents { get; set; } = false; - // ── Priority ── - [ConfigOption("Priority:DefaultPriority")] + // ── Processing ── + [ConfigOption("server:DefaultPriority")] public int DefaultPriority { get; set; } = 2; - [ConfigOption("Priority:DefaultTTLSecs")] + [ConfigOption("server:DefaultTTLSecs")] public int DefaultTTLSecs { get; set; } = 300; - [ConfigOption("Priority:PriorityKeyHeader")] - public string PriorityKeyHeader { get; set; } = "S7PPriorityKey"; - [ConfigOption("Priority:PriorityKeys")] + [ConfigOption("Server:GreedyUserThreshold")] + public float UserPriorityThreshold { get; set; } = 0.1f; + [ConfigOption("server:PriorityKeys")] public List PriorityKeys { get; set; } = ["12345", "234"]; - [ConfigOption("Priority:PriorityValues")] + [ConfigOption("server:PriorityValues")] public List PriorityValues { get; set; } = [1, 3]; - + [ConfigOption("server:DefaultTimeout")] + public int Timeout { get; set; } = 60*20*1000; // 20 minutes + [ConfigOption("Server:UseSharedIterators", Mode = ConfigMode.Hidden)] + public bool UseSharedIterators { get; set; } = true; // ── Request ── [ConfigOption("Request:DependancyHeaders")] public List DependancyHeaders { get; set; } = ["Backend-Host", "Host-URL", "Status", "Duration", "Error", "Message", "Request-Date", "backendLog"]; [ConfigOption("Request:DisallowedHeaders")] public List DisallowedHeaders { get; set; } = []; + [ConfigOption("Request:Headers:PriorityKeyHeader")] + public string PriorityKeyHeader { get; set; } = "S7PPriorityKey"; + [ConfigOption("Request:Headers:TimeoutHeader")] + public string TimeoutHeader { get; set; } = "S7PTimeout"; + [ConfigOption("Request:Headers:TTLHeader")] + public string TTLHeader { get; set; } = "S7PTTL"; + [ConfigOption("Request:Headers:UniqueUserHeaders")] + public List UniqueUserHeaders { get; set; } = ["X-UserID"]; + [ConfigOption("Request:Headers:ValidateHeaders")] + public Dictionary ValidateHeaders { get; set; } = []; [ConfigOption("Request:MaxAttempts")] public int MaxAttempts { get; set; } = 10; [ConfigOption("Request:RequiredHeaders")] public List RequiredHeaders { get; set; } = []; [ConfigOption("Request:StripRequestHeaders")] public List StripRequestHeaders { get; set; } = []; - [ConfigOption("Request:TimeoutHeader")] - public string TimeoutHeader { get; set; } = "S7PTimeout"; - [ConfigOption("Request:TTLHeader")] - public string TTLHeader { get; set; } = "S7PTTL"; // ── Response ── [ConfigOption("Response:AcceptableStatusCodes")] @@ -134,6 +141,8 @@ public class ProxyConfig // ── Server ── [ConfigOption("Server:MaxQueueLength", Mode = ConfigMode.Cold)] public int MaxQueueLength { get; set; } = 1000; + [ConfigOption("Server:MaxUndrainedEvents", ConfigName = "EVENTHUB_MAX_UNDRAINED_EVENTS", Mode = ConfigMode.Cold)] + public int MaxUndrainedEvents { get; set; } = 100; [ConfigOption("Server:PollInterval", Mode = ConfigMode.Cold)] public int PollInterval { get; set; } = 15000; [ConfigOption("Server:PollTimeout", Mode = ConfigMode.Cold)] @@ -144,6 +153,8 @@ public class ProxyConfig public int SuccessRate { get; set; } = 80; [ConfigOption("Server:TerminationGracePeriodSeconds", ConfigName = "TERMINATION_GRACE_PERIOD_SECONDS", Mode = ConfigMode.Cold)] public int TerminationGracePeriodSeconds { get; set; } = 30; + [ConfigOption("Server:GC2InternalSecs", ConfigName = "GC2InternalSecs", Mode = ConfigMode.Cold)] + public int GC2InternalSecs { get; set; } = 300; // 5 minutes [ConfigOption("Server:Workers", Mode = ConfigMode.Cold)] public int Workers { get; set; } = 10; @@ -161,7 +172,7 @@ public class ProxyConfig public string StorageDbContainerName { get; set; } = "Requests"; // ── User ── COLD -- - [ConfigOption("Profiles:User:ConfigRequired", Mode = ConfigMode.Cold)] + [ConfigOption("Profiles:User:ConfigRequired")] public bool UserConfigRequired { get; set; } = false; [ConfigOption("Profiles:RefreshIntervalSecs", Mode = ConfigMode.Cold)] public int UserConfigRefreshIntervalSecs { get; set; } = 3600; // 1 hour @@ -170,21 +181,15 @@ public class ProxyConfig // ── User ── WARM -- [ConfigOption("Profiles:SuspendedUser:ConfigUrl")] public string SuspendedUserConfigUrl { get; set; } = ""; // e.g. "file:suspended.json" or "http://configservice/suspended" - [ConfigOption("Profiles:UniqueUserHeaders")] - public List UniqueUserHeaders { get; set; } = ["X-UserID"]; [ConfigOption("Profiles:User:UserConfigUrl")] public string UserConfigUrl { get; set; } = ""; // e.g. "file:users.json" or "http://configservice/users" - [ConfigOption("Profiles:UserIDFieldName")] + [ConfigOption("Profiles:User:IDFieldName")] public string UserIDFieldName { get; set; } = "userId"; - [ConfigOption("Profiles:UserPriorityThreshold")] - public float UserPriorityThreshold { get; set; } = 0.1f; - [ConfigOption("Profiles:UserProfileHeader")] + [ConfigOption("Profiles:User:ProfileHeader")] public string UserProfileHeader { get; set; } = "X-UserProfile"; [ConfigOption("Profiles:User:UseProfiles")] public bool UseProfiles { get; set; } = false; // ── Validation ── - [ConfigOption("Profiles:Headers:ValidateHeaders")] - public Dictionary ValidateHeaders { get; set; } = []; [ConfigOption("Profiles:Auth:ValidateAppIDEnabled")] public bool ValidateAuthAppID { get; set; } = false; [ConfigOption("Profiles:Auth:ConfigUrl")] @@ -206,18 +211,16 @@ public class ProxyConfig public string LogFileName { get; set; } = "eventslog.json"; // ── EventHub ── - [ConfigOption("EventHub:ConnectionString", ConfigName = "EVENTHUB_CONNECTIONSTRING", Mode = ConfigMode.Cold)] + [ConfigOption("Logging:EventHub:ConnectionString", ConfigName = "EVENTHUB_CONNECTIONSTRING", Mode = ConfigMode.Cold)] public string EventHubConnectionString { get; set; } = ""; - [ConfigOption("EventHub:Name", ConfigName = "EVENTHUB_NAME", Mode = ConfigMode.Cold)] + [ConfigOption("Logging:EventHub:Name", ConfigName = "EVENTHUB_NAME", Mode = ConfigMode.Cold)] public string EventHubName { get; set; } = ""; - [ConfigOption("EventHub:Namespace", ConfigName = "EVENTHUB_NAMESPACE", Mode = ConfigMode.Cold)] + [ConfigOption("Logging:EventHub:Namespace", ConfigName = "EVENTHUB_NAMESPACE", Mode = ConfigMode.Cold)] public string EventHubNamespace { get; set; } = ""; - [ConfigOption("EventHub:StartupSeconds", ConfigName = "EVENTHUB_STARTUP_SECONDS", Mode = ConfigMode.Cold)] + [ConfigOption("Logging:EventHub:StartupSeconds", ConfigName = "EVENTHUB_STARTUP_SECONDS", Mode = ConfigMode.Cold)] public int EventHubStartupSeconds { get; set; } = 10; - [ConfigOption("EventHub:MaxReconnectAttempts", ConfigName = "EVENTHUB_MAX_RECONNECT_ATTEMPTS", Mode = ConfigMode.Cold)] + [ConfigOption("Logging:EventHub:MaxReconnectAttempts", ConfigName = "EVENTHUB_MAX_RECONNECT_ATTEMPTS", Mode = ConfigMode.Cold)] public int EventHubMaxReconnectAttempts { get; set; } = 5; - [ConfigOption("EventHub:MaxUndrainedEvents", ConfigName = "EVENTHUB_MAX_UNDRAINED_EVENTS", Mode = ConfigMode.Cold)] - public int MaxUndrainedEvents { get; set; } = 100; // ════════════════════════════════════════════════════════════════════ // Hidden — not published (runtime-derived / parsed / composite) @@ -261,14 +264,6 @@ public class ProxyConfig [ConfigOption("Logging:LogDateTime", ConfigName = "LOGDATETIME", Mode = ConfigMode.Cold)] public bool LogDateTime { get; set; } = false; - // ── Shared Iterators ── - /// - /// When true, requests to the same path share the same host iterator, - /// ensuring fair round-robin distribution across concurrent requests. - /// - [ConfigOption("Server:UseSharedIterators", Mode = ConfigMode.Hidden)] - public bool UseSharedIterators { get; set; } = true; - // ── App Config ── [ConfigOption("AppConfig:Endpoint", ConfigName = "AZURE_APPCONFIG_ENDPOINT", Mode = ConfigMode.Hidden)] public string? AppConfigEndpoint { get; set; } @@ -306,8 +301,6 @@ public class ProxyConfig public string ReplicaName { get; set; } = ""; [ConfigOption("Metadata:Revision", ConfigName = "CONTAINER_APP_REVISION", Mode = ConfigMode.Hidden)] public string Revision { get; set; } = "revisionID"; - [ConfigOption("Metadata:GC2InternalSecs", ConfigName = "GC2InternalSecs", Mode = ConfigMode.Cold)] - public int GC2InternalSecs { get; set; } = 300; // 5 minutes // ── Runtime-derived (no attribute) ── public HttpClient? Client { get; set; } From eb4d77162c3386927693110baffda647716f41df Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:51:45 -0400 Subject: [PATCH 05/11] potential memory leak --- src/SimpleL7Proxy/Config/AppConfigService.cs | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/SimpleL7Proxy/Config/AppConfigService.cs b/src/SimpleL7Proxy/Config/AppConfigService.cs index 5ee9c89d..272b8925 100644 --- a/src/SimpleL7Proxy/Config/AppConfigService.cs +++ b/src/SimpleL7Proxy/Config/AppConfigService.cs @@ -22,6 +22,7 @@ public class AppConfigService : BackgroundService private readonly DefaultCredential _defaultCredential; private readonly TimeSpan _refreshInterval; private bool _isInitialized = false; + private DateTime _lastRefreshTime = DateTime.MinValue; // App Config key path → config name (e.g. "Logging:LogConsole" → "LogConsole"). // Built once from static descriptors; never changes. @@ -44,6 +45,13 @@ public class AppConfigService : BackgroundService private ProxyConfig _options = null!; public static ProxyConfig DEFAULT_OPTIONS { get; set; } = null!; + public String Status() + { + if (_lastRefreshTime == DateTime.MinValue) + return "AppConfigService not initialized"; + + return $"Label: {_labelFilter} Last Refresh: {_lastRefreshTime}, Last Sentinel: {_lastSentinel}"; + } public AppConfigService(ILogger logger, ProxyConfig backendOptions, DefaultCredential defaultCredential) { @@ -198,9 +206,17 @@ private ConfigurationClient GetConfigurationClient() try { + // Disable distributed tracing and logging on the client used for periodic polling. + // Each sentinel check goes through the Azure SDK HTTP pipeline, which creates + // Activity objects that Application Insights converts into DependencyTelemetry. + // At short refresh intervals this promotes ~10 KB/cycle into Gen2. + var options = new ConfigurationClientOptions(); + options.Diagnostics.IsDistributedTracingEnabled = false; + options.Diagnostics.IsLoggingEnabled = false; + _cachedClient = !string.IsNullOrEmpty(_endpoint) - ? new ConfigurationClient(new Uri(_endpoint), _defaultCredential.Credential) - : new ConfigurationClient(_connectionString!); + ? new ConfigurationClient(new Uri(_endpoint), _defaultCredential.Credential, options) + : new ConfigurationClient(_connectionString!, options); } catch (Exception ex) { @@ -253,7 +269,9 @@ private ConfigurationClient GetConfigurationClient() target[resolvedKey] = value; } + + _lastRefreshTime = DateTime.UtcNow; return (warm, cold); } catch (Exception ex) From d33b389c3ea14c9e67bc38c29b6e68054ca40b46 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:52:29 -0400 Subject: [PATCH 06/11] return forbidden if user not found and its required --- src/SimpleL7Proxy/server.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/SimpleL7Proxy/server.cs b/src/SimpleL7Proxy/server.cs index 8e5e9d67..0a9fde8b 100644 --- a/src/SimpleL7Proxy/server.cs +++ b/src/SimpleL7Proxy/server.cs @@ -444,6 +444,7 @@ public async Task Run(CancellationToken cancellationToken) rd.Path = "/" + rd.Path; rd.Headers["S7Path"] = rd.Path; // Copy path // Lookup the user profile and add the headers to the request + Console.WriteLine($"Looking up? {doUserProfile} user profile for {rd.Headers[_options.UserProfileHeader]}"); if (doUserProfile) { var requestUser = rd.Headers[_options.UserProfileHeader]; @@ -474,6 +475,14 @@ public async Task Run(CancellationToken cancellationToken) "User profile not found: " + requestUser + "\n" ); } + } + else if ( _options.UserConfigRequired) + { + throw new ProxyErrorException( + ProxyErrorException.ErrorType.UnknownProfile, + HttpStatusCode.Forbidden, + "User profile not found: " + requestUser + "\n" + ); } } From 3d7c08be4a744f92e49fe33e831ee4a85d00393d Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:53:20 -0400 Subject: [PATCH 07/11] output ACA label in the banner --- src/SimpleL7Proxy/Banner.cs | 3 ++- src/SimpleL7Proxy/Program.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/SimpleL7Proxy/Banner.cs b/src/SimpleL7Proxy/Banner.cs index e1330aac..78247fde 100644 --- a/src/SimpleL7Proxy/Banner.cs +++ b/src/SimpleL7Proxy/Banner.cs @@ -12,7 +12,7 @@ public static class Banner { public const string VERSION = Constants.VERSION; - public static void Display(ProxyConfig options) + public static void Display(ProxyConfig options, string ConfigStatus) { Console.WriteLine("======================================================================================="); Console.WriteLine(" ##### # ####### "); @@ -24,5 +24,6 @@ public static void Display(ProxyConfig options) Console.WriteLine(" ##### # # # # ###### ###### ####### # # # # #### # # #"); Console.WriteLine("======================================================================================="); Console.WriteLine($"Version: {VERSION} LogLevel: {options.LogLevel} ContainerApp: {options.ContainerApp} Replica: {options.ReplicaName}"); + Console.WriteLine($"[AppConfig] {ConfigStatus}"); } } diff --git a/src/SimpleL7Proxy/Program.cs b/src/SimpleL7Proxy/Program.cs index 8dc848a9..970fe657 100644 --- a/src/SimpleL7Proxy/Program.cs +++ b/src/SimpleL7Proxy/Program.cs @@ -90,7 +90,7 @@ public static async Task Main(string[] args) var logger = loggerFactory.CreateLogger("StreamProcessor"); var options = serviceProvider.GetRequiredService>(); - Banner.Display(options.Value); + Banner.Display(options.Value, appConfigBootstrap.Status()); appConfigBootstrap.Notifier = serviceProvider.GetRequiredService(); appConfigBootstrap.HostCollection = serviceProvider.GetRequiredService(); From 50e558cc3e1cfe7aef628cf42d53d623042f7c09 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 10:53:48 -0400 Subject: [PATCH 08/11] output ACA label in the health probes --- src/SimpleL7Proxy/HealthCheckService.cs | 44 ++++--------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/src/SimpleL7Proxy/HealthCheckService.cs b/src/SimpleL7Proxy/HealthCheckService.cs index 20975115..bfa3c458 100644 --- a/src/SimpleL7Proxy/HealthCheckService.cs +++ b/src/SimpleL7Proxy/HealthCheckService.cs @@ -33,6 +33,7 @@ public class HealthCheckService private readonly IServiceBusRequestService? _serviceBusRequestService; private readonly IBlobWriter? _blobWriter; private readonly IUserProfileService? _userProfileService; + private readonly AppConfigService _appConfigService; private readonly Func _getWorkerState; private readonly ILogger _logger; @@ -78,7 +79,7 @@ public HealthCheckService( IUserPriorityService? userPriority, IEventClient? eventClient, ILogger logger, - + AppConfigService appConfigService, IServiceBusRequestService? serviceBusRequestService = null, IBlobWriter? blobWriter = null, IBackupAPIService? backupAPIService = null, @@ -86,6 +87,7 @@ public HealthCheckService( { _backends = backends ?? throw new ArgumentNullException(nameof(backends)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); _requestsQueue = requestsQueue; _userPriority = userPriority; _userProfileService = userProfileService; @@ -264,41 +266,6 @@ public static void DecrementActiveWorkers(int workerId) } } - // public async Task HealthResponseAsync(HttpListenerContext lc) - // { - // int hostCount = _backends.ActiveHostCount(); - // bool hasFailedHosts = _backends.CheckFailedStatus(); - // BuildHealthResponse(hostCount, hasFailedHosts, out int probeStatus, out string probeMessage); - - // try - // { - // lc.Response.ContentType = "text/plain"; - // lc.Response.Headers["Cache-Control"] = "no-cache"; - // lc.Response.Headers["Connection"] = "close"; - - // switch (probeStatus) - // { - // case 200: - // lc.Response.StatusCode = (int)HttpStatusCode.OK; - // break; - // default: - // lc.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; - // break; - // } - - // lc.Response.ContentLength64 = probeMessage.Length; - // await lc.Response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(probeMessage), 0, probeMessage.Length); - // } - // finally - // { - // try - // { - // lc.Response.Close(); - // } - // catch { } - // } - // } - public void BuildHealthResponse(string path, int hostCount, bool hasFailedHosts, DateTime requestTimestamp, out int probeStatus, out string probeMessage) { using var process = System.Diagnostics.Process.GetCurrentProcess(); @@ -311,7 +278,8 @@ public void BuildHealthResponse(string path, int hostCount, bool hasFailedHosts, .Append(" Elapsed: ").Append(elapsedMs.ToString("F1")).Append(" ms").Append('\n') .Append(" Hosts: ").Append(hostCount) .Append(hasFailedHosts ? " [FAILED]" : " [OK]") - .Append(" NextGC: ").Append(gcRemaining > TimeSpan.Zero ? gcRemaining.TotalSeconds.ToString("F0") + "s" : "ready"); + .Append(" NextGC: ").Append(gcRemaining > TimeSpan.Zero ? gcRemaining.TotalSeconds.ToString("F0") + "s" : "ready") + .Append(" AppConfig Status: ").Append(_appConfigService.Status()); switch (path) { @@ -615,7 +583,7 @@ public void RunPeriodicGC() public (HealthStatusEnum, HealthStatusEnum, int) GetStatus() { int hostCount = _backends.ActiveHostCount(); - bool hasFailed = _backends.CheckFailedStatusAsync(true).Result; // this call will not block + bool hasFailed = _backends.CheckFailedStatusAsync(true).Result; bool profilesReady = _userProfileService?.ServiceIsReady() ?? true; // if user profile service is not configured, consider it ready int activeEvents = _eventClient?.Count ?? 0; From 129f3980aba382e0778713049f663cec2d09f2a0 Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 12:20:34 -0400 Subject: [PATCH 09/11] improve docs --- docs/AZURE_APP_CONFIGURATION.md | 267 +++++++--------- docs/AsyncTimeouts.png | Bin 0 -> 40648 bytes docs/BACKEND_HOSTS.md | 215 ++++++------- docs/CIRCUIT_BREAKER.md | 157 ++++++--- docs/CONFIGURATION_SETTINGS.md | 447 +++++++++++++------------- docs/CONTAINER_DEPLOYMENT.md | 545 ++++++++++++++++---------------- docs/DEVELOPMENT.md | 308 +++++++----------- docs/LOAD_BALANCING.md | 287 ++++++----------- docs/REQUEST_VALIDATION.md | 254 ++++++++++++++- docs/RESPONSE_CODES.md | 152 +++++++-- docs/SIDECAR_DEPLOYMENT.md | 223 +++++++++++++ docs/SyncTimeouts.png | Bin 0 -> 43228 bytes docs/TIMEOUTS.md | 135 ++++++++ docs/timeout-image.png | Bin 0 -> 102968 bytes 14 files changed, 1768 insertions(+), 1222 deletions(-) create mode 100644 docs/AsyncTimeouts.png create mode 100644 docs/SIDECAR_DEPLOYMENT.md create mode 100644 docs/SyncTimeouts.png create mode 100644 docs/TIMEOUTS.md create mode 100644 docs/timeout-image.png diff --git a/docs/AZURE_APP_CONFIGURATION.md b/docs/AZURE_APP_CONFIGURATION.md index 1413c7a6..f6521f89 100644 --- a/docs/AZURE_APP_CONFIGURATION.md +++ b/docs/AZURE_APP_CONFIGURATION.md @@ -1,78 +1,59 @@ # Azure App Configuration Integration -This document describes how to use Azure App Configuration for hot-reloading **[Warm]** settings without restarting the proxy. +Azure App Configuration lets you change **Warm** settings across all proxy instances in ~30 seconds without a restart. -## Overview +> **TL;DR** +> - **Warm settings** are hot-reloaded; **Cold settings** require a restart — both live under the `Warm:*` key prefix, distinguished by their label. +> - **Changing any setting takes effect only after you update `Warm:Sentinel`** — that is the refresh trigger. +> - **Managed Identity is the recommended auth method**; connection strings work for local development. -The proxy supports three types of configuration settings: +--- -| Type | Behavior | Label in App Config | Example | -|------|----------|--------------------|---------| -| **Warm** | Hot-reloaded from Azure App Config | *(none)* or `APPCONFIG_LABEL` | `MaxAttempts`, `LogConsole`, `DefaultPriority` | -| **Cold** | Read at startup, requires restart | `Cold` | `PollInterval`, `Timeout`, `Workers` | -| **Hidden** | Not published | — | `AsyncBlobStorageConnectionString` (parsed at runtime) | +## Reference — Configuration Types -Both Warm and Cold settings are stored under the `Warm:` key prefix (for a single `Select("Warm:*")` query), but are distinguished by their **label**: -- Warm settings use the deployment label (default: no label) -- Cold settings always use label `Cold` +| Type | Label in App Config | Reload | Example settings | +|------|---------------------|--------|-----------------| +| **Warm** | *(none)* or `APPCONFIG_LABEL` | ~30 s, no restart | `MaxAttempts`, `DefaultPriority`, `DefaultTTLSecs` | +| **Cold** | `Cold` | Restart required | `Port`, `Workers`, `Timeout`, `PollInterval` | +| **Hidden** | — (not published) | Runtime-derived | `AsyncBlobStorageConnectionString` | -This makes it easy to identify which settings require a restart when browsing the Azure portal's Configuration Explorer — just look at the **Label** column. +All keys share the `Warm:` prefix (single `Select("Warm:*")` query). The **Label** column in Azure Portal's Configuration Explorer immediately shows which settings require a restart. -## Environment Variables +--- -Configure the connection to Azure App Configuration: +## Reference — Environment Variables -```bash -# Option 1: Managed Identity (recommended for production) -AZURE_APPCONFIG_ENDPOINT=https://your-appconfig.azconfig.io +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `AZURE_APPCONFIG_ENDPOINT` | One of these two | — | Managed Identity endpoint (recommended) | +| `AZURE_APPCONFIG_CONNECTION_STRING` | One of these two | — | Connection string (dev/fallback) | +| `AZURE_APPCONFIG_LABEL` | No | *(none)* | Label filter for Warm settings | +| `AZURE_APPCONFIG_REFRESH_SECONDS` | No | `30` | Sentinel poll interval in seconds | -# Option 2: Connection String (for development) -AZURE_APPCONFIG_CONNECTION_STRING=Endpoint=https://...;Id=...;Secret=... +--- -# Optional: Label filter (default: no label) -AZURE_APPCONFIG_LABEL=Production +## How Refresh Works -# Optional: Refresh interval in seconds (default: 30) -AZURE_APPCONFIG_REFRESH_SECONDS=30 ``` - -## Azure App Configuration Key Structure - -All settings are stored under the `Warm:` key prefix. **Labels** distinguish the reload mode: - +Every AZURE_APPCONFIG_REFRESH_SECONDS + │ + ▼ + Check Warm:Sentinel ──changed?──Yes──► Reload ALL Warm settings → apply live + ──No──────────► Nothing happens (no extra API calls) ``` -# Warm settings (label = none or APPCONFIG_LABEL) — hot-reloaded -Warm:MaxAttempts = 3 -Warm:LogConsole = true -Warm:DefaultPriority = 5 -Warm:Sentinel = 1 # Change this to trigger refresh - -# Cold settings (label = Cold) — read at startup only -Warm:Server:Port = 8000 -Warm:Server:Workers = 100 -Warm:Server:Timeout = 100000 -``` - -### Sentinel Key Pattern -The `Warm:Sentinel` key is used to trigger configuration refresh: +**Update `Warm:Sentinel` to any new value to push a config change to all running instances.** -1. The refresh service polls Azure App Configuration every N seconds -2. It only checks if `Warm:Sentinel` has changed -3. If changed, **all** Warm settings are reloaded -4. This minimizes API calls while allowing instant updates +--- -**To trigger a refresh:** Update `Warm:Sentinel` to any new value (e.g., increment a counter or use a timestamp). +## Setting Up -## Setting Up Azure App Configuration +**Rule: Create the resource, assign the role, import your settings, then set the environment variable on the Container App.** -### 1. Create the Resource +### 1. Create the resource ```bash -# Create resource group az group create --name rg-proxy --location eastus - -# Create App Configuration store az appconfig create \ --name appconfig-proxy \ --resource-group rg-proxy \ @@ -80,45 +61,43 @@ az appconfig create \ --sku Standard ``` -### 2. Assign Managed Identity Access +### 2. Assign Managed Identity access ```bash -# Get the Container App's managed identity IDENTITY_ID=$(az containerapp show \ --name your-proxy-app \ --resource-group rg-proxy \ --query identity.principalId -o tsv) -# Get App Configuration resource ID APPCONFIG_ID=$(az appconfig show \ --name appconfig-proxy \ --resource-group rg-proxy \ --query id -o tsv) -# Assign App Configuration Data Reader role az role assignment create \ --role "App Configuration Data Reader" \ --assignee $IDENTITY_ID \ --scope $APPCONFIG_ID ``` -### 3. Import Initial Settings +> [!NOTE] +> **Default role:** `App Configuration Data Reader` is sufficient — the proxy only reads settings, never writes them. -Create a JSON file with your warm settings: +> [!TIP] +> **Troubleshooting:** If the proxy logs authentication failures, confirm the Container App's system-assigned managed identity is enabled and the role assignment has propagated (can take a few minutes). + +### 3. Import initial settings ```json { "Warm:MaxAttempts": 3, "Warm:DefaultPriority": 5, - "Warm:LogConsole": true, - "Warm:LogProbes": false, + "Warm:LogAllRequestHeaders": false, "Warm:AcceptableStatusCodes": "[200, 201, 202]", "Warm:Sentinel": "1" } ``` -Import to App Configuration: - ```bash az appconfig kv import \ --name appconfig-proxy \ @@ -128,7 +107,7 @@ az appconfig kv import \ --label Production ``` -### 4. Update Container App Environment +### 4. Configure the Container App ```bash az containerapp update \ @@ -140,114 +119,24 @@ az containerapp update \ AZURE_APPCONFIG_REFRESH_SECONDS=30 ``` -## Available Warm Settings - -These settings can be hot-reloaded: - -### Logging -- `LogConsole` - Enable console logging -- `LogConsoleEvent` - Enable console event logging -- `LogPoller` - Log poller activity -- `LogProbes` - Log health probe activity -- `LogHeaders` - Headers to include in logs -- `LogAllRequestHeaders` - Log all request headers -- `LogAllRequestHeadersExcept` - Exclude specific request headers -- `LogAllResponseHeaders` - Log all response headers -- `LogAllResponseHeadersExcept` - Exclude specific response headers - -### Request Processing -- `MaxAttempts` - Maximum retry attempts -- `DefaultPriority` - Default request priority -- `DefaultTTLSecs` - Default time-to-live -- `TimeoutHeader` - Header containing timeout value -- `TTLHeader` - Header containing TTL value - -### Validation -- `RequiredHeaders` - Headers that must be present -- `DisallowedHeaders` - Headers that are not allowed -- `ValidateHeaders` - Header validation rules -- `ValidateAuthAppID` - Enable app ID validation -- `ValidateAuthAppIDUrl` - URL for app ID validation -- `ValidateAuthAppFieldName` - Field name for app ID -- `ValidateAuthAppIDHeader` - Header containing app ID - -### User Management -- `UserConfigUrl` - URL for user configuration -- `SuspendedUserConfigUrl` - URL for suspended users -- `UserProfileHeader` - Header containing user profile -- `UserIDFieldName` - Field name for user ID -- `UniqueUserHeaders` - Headers that identify unique users -- `UserPriorityThreshold` - Priority threshold for users - -### Priority -- `PriorityKeyHeader` - Header containing priority key -- `PriorityKeys` - List of priority keys -- `PriorityValues` - Priority values for each key - -### Response Handling -- `AcceptableStatusCodes` - Status codes to accept -- `StripResponseHeaders` - Headers to remove from response -- `StripRequestHeaders` - Headers to remove from request - -### Async Settings (timing only) -- `AsyncTimeout` - Async operation timeout -- `AsyncTTLSecs` - Async TTL in seconds -- `AsyncTriggerTimeout` - Trigger timeout -- `AsyncClientRequestHeader` - Client async header -- `AsyncClientConfigFieldName` - Config field name - -## Monitoring Refresh - -The proxy logs configuration refresh activity: - -``` -[CONFIG] ✓ Azure App Configuration initialized with Warm settings refresh -[CONFIG] Azure App Configuration refresh service started with 30s interval -[CONFIG] Configuration refresh check completed - changes detected -[CONFIG] Warm settings changed - applying to BackendOptions -[CONFIG] ✓ Warm settings applied successfully -``` - -## Troubleshooting - -### Settings Not Refreshing - -1. Check the sentinel key was updated: - ```bash - az appconfig kv show --name appconfig-proxy --key "Warm:Sentinel" - ``` - -2. Verify the label filter matches: - ```bash - az appconfig kv list --name appconfig-proxy --label Production - ``` - -3. Check proxy logs for refresh errors - -### Authentication Failures +> [!WARNING] +> **Error:** If `AZURE_APPCONFIG_ENDPOINT` is set but the managed identity has no role assignment, the proxy will fail to start. Set `AZURE_APPCONFIG_CONNECTION_STRING` as a fallback during initial setup. -1. Verify managed identity is enabled on the Container App -2. Check role assignment is correct (App Configuration Data Reader) -3. Ensure the endpoint URL is correct +--- -### Performance Considerations +## Per-Request Override -- Default refresh interval is 30 seconds -- Only the sentinel key is checked on each poll -- Full refresh only occurs when sentinel changes -- Consider longer intervals for production (60-120 seconds) - -## Example: Changing MaxAttempts at Runtime +**Rule: To change a Warm setting at runtime, update the key value then bump `Warm:Sentinel` — both steps are required.** ```bash -# Update the setting +# 1. Update the setting az appconfig kv set \ --name appconfig-proxy \ --key "Warm:MaxAttempts" \ --value "5" \ --label Production -# Trigger refresh by updating sentinel +# 2. Trigger refresh az appconfig kv set \ --name appconfig-proxy \ --key "Warm:Sentinel" \ @@ -255,4 +144,60 @@ az appconfig kv set \ --label Production ``` -Within 30 seconds (or your configured interval), all proxy instances will pick up the new value without restart. +> [!NOTE] +> All instances pick up the change within `AZURE_APPCONFIG_REFRESH_SECONDS` (default 30 s) — no rolling restart needed. + +--- + +## Available Warm Settings + +| Category | Settings | +|----------|----------| +| **Logging** | `LogAllRequestHeaders`, `LogAllRequestHeadersExcept`, `LogAllResponseHeaders`, `LogAllResponseHeadersExcept`, `LogHeaders` | +| **Request processing** | `MaxAttempts`, `DefaultPriority`, `DefaultTTLSecs`, `TimeoutHeader`, `TTLHeader` | +| **Validation** | `RequiredHeaders`, `DisallowedHeaders`, `ValidateHeaders`, `ValidateAuthAppID`, `ValidateAuthAppIDUrl`, `ValidateAuthAppIDHeader`, `ValidateAuthAppFieldName` | +| **User management** | `UserConfigUrl`, `SuspendedUserConfigUrl`, `UserProfileHeader`, `UserIDFieldName`, `UniqueUserHeaders`, `UserPriorityThreshold` | +| **Priority** | `PriorityKeyHeader`, `PriorityKeys`, `PriorityValues` | +| **Response** | `AcceptableStatusCodes`, `StripResponseHeaders`, `StripRequestHeaders` | +| **Async (timing)** | `AsyncTimeout`, `AsyncTTLSecs`, `AsyncTriggerTimeout`, `AsyncClientRequestHeader`, `AsyncClientConfigFieldName` | + +--- + +## Worked Example + +> **Goal:** Raise `MaxAttempts` from 3 to 5 on a live deployment without restarting. + +| Step | Command | Expected result | +|------|---------|----------------| +| Check current value | `az appconfig kv show --name appconfig-proxy --key "Warm:MaxAttempts" --label Production` | `"value": "3"` | +| Update setting | `az appconfig kv set ... --key "Warm:MaxAttempts" --value "5"` | Setting saved | +| Bump sentinel | `az appconfig kv set ... --key "Warm:Sentinel" --value "$(date +%s)"` | Refresh triggered | +| Wait ≤30 s | — | Proxy logs: `✓ Warm settings applied successfully` | +| Verify | Send a request that exercises retries | Proxy now retries up to 5 times | + +**No deployment or restart is needed — the sentinel bump propagates to every running instance within the poll interval.** + +--- + +## Monitoring + +The proxy emits these log entries around refresh: + +``` +[CONFIG] ✓ Azure App Configuration initialized with Warm settings refresh +[CONFIG] Azure App Configuration refresh service started with 30s interval +[CONFIG] Configuration refresh check completed - changes detected +[CONFIG] Warm settings changed - applying to BackendOptions +[CONFIG] ✓ Warm settings applied successfully +``` + +> [!TIP] +> **Troubleshooting:** If `changes detected` never appears after a sentinel update, verify the label filter (`AZURE_APPCONFIG_LABEL`) matches the label used when importing the keys. + +--- + +## Related Documentation + +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — Full list of all settings and their reload types +- [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md) — All environment variables +- [DEVELOPMENT.md](DEVELOPMENT.md) — Local development setup diff --git a/docs/AsyncTimeouts.png b/docs/AsyncTimeouts.png new file mode 100644 index 0000000000000000000000000000000000000000..2d9fc09f7287cccc70271520e245240f8a14cb8f GIT binary patch literal 40648 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 literal 0 HcmV?d00001 diff --git a/docs/BACKEND_HOSTS.md b/docs/BACKEND_HOSTS.md index 549b7ec0..6761b500 100644 --- a/docs/BACKEND_HOSTS.md +++ b/docs/BACKEND_HOSTS.md @@ -1,168 +1,157 @@ # Backend Host Configuration -SimpleL7Proxy supports defining backend hosts using two methods: a strictly environment-variable-based legacy method and a more flexible connection-string-based method. You can configure up to 9 backend hosts using variables `Host1` through `Host9`. +Configure up to 9 backend hosts (`Host1`…`Host9`) using a semicolon-separated connection string, or the simpler legacy per-variable format. -## Configuration Methods +> **TL;DR** +> - **Connection string format is recommended** — all per-host options in one variable. +> - **`mode=direct` skips health probes entirely** — the host is always considered healthy; use it for serverless/on-demand backends. +> - **The health poller runs every `PollInterval` ms** and drops hosts below `SuccessRate`% from the active pool until they recover. -### 1. Advanced Connection String (Recommended) - -This method allows you to define all properties for a single host within the `HostN` environment variable itself. It uses a semicolon-separated key=value format. +--- -**Format:** -`Host1="host=https://api.backend.com;probe=/health;mode=apim"` +## Reference — Connection String Keys -**Supported Keys:** +> **Units:** all timeouts in milliseconds unless noted. Delimiters: `;` or `,` (both accepted). -| Key | Description | Default | -|-----|-------------|---------| -| **host** | The base URL of the backend service. | (Required) | -| **probe** | The path to the health probe endpoint (e.g., `/health`, `/status`). | `echo/resource?param1=sample` | -| **ipaddress** | Overrides the DNS resolution by forcing a specific IP address for requests. | (Empty) | -| **mode** | Connectivity mode. Use `direct` to skip APIM-style handling and probes, or omit for standard proxying. | Standard/APIM | -| **path** | A partial path to append to requests or match against (depending on internal routing logic). | `/` | -| **processor** | Specifies a custom request processor if available. | (Empty) | -| **useoauth** / **usemi** | If `true`, enables Managed Identity/OAuth authentication for this host. | `false` | -| **audience** | The expected audience claim for OAuth tokens. | (Empty) | -| **stripprefix** / **strippathprefix** | If `true`, the matched path prefix is stripped when forwarding to the backend. If `false`, the original request path is preserved. | `true` | -| **retryafter** | If `true`, respects the `Retry-After` header from the backend. | `true` | +| Key | Default | Description | +|-----|---------|-------------| +| `host` | *(required)* | Backend base URL. Protocol defaults to `https://` if omitted. Trailing slashes are stripped. | +| `probe` | `echo/resource?param1=sample` | Health probe path. Ignored when `mode=direct`. | +| `path` | `/` | Path prefix used for routing. Requests matching this prefix are sent to this host. | +| `mode` | *(standard)* | Set to `direct` to disable probing and assume the host is always healthy. | +| `ipaddress` | *(empty)* | Override DNS — force all requests to this IP. | +| `processor` | *(empty)* | Custom stream processor name. Required and auto-defaulted in `direct` mode. | +| `usemi` / `useoauth` | `false` | Attach a Managed Identity / OAuth2 Bearer token to every request and probe. | +| `audience` | *(empty)* | OAuth token audience. Required when `usemi=true`. | +| `stripprefix` / `strippathprefix` | `true` | Strip the matched `path` prefix before forwarding. Set `false` to preserve the full original path. | +| `retryafter` / `useretryafter` | `true` | Honour the `Retry-After` header returned by the backend. | -**Examples:** +> [!WARNING] +> An **unrecognised key** in the connection string throws `UriFormatException` at startup and prevents the proxy from starting. -* **Standard Service with Health Check:** - ```bash - Host1="host=https://my-api.internal;probe=/api/health" - ``` +--- -* **Service with IP Override (No DNS):** - ```bash - Host2="host=https://my-api.internal;ipaddress=10.0.1.5;probe=/health" - ``` +## Configuring Hosts -* **Direct Mode (No Health Probes):** - Useful for simple forwarding or testing where active health checking is not required. - ```bash - Host3="host=https://simple-service.internal;mode=direct" - ``` +**Rule: Use the connection string format for all new hosts — it keeps every option for a host in one variable.** -* **Authenticated Service:** - ```bash - Host4="host=https://secure-api.internal;usemi=true;audience=api://my-app-id" - ``` +```bash +# Minimal — standard probed host +Host1="host=https://api.backend.com;probe=/health" -### 3. Direct Mode (Serverless/On-Demand) +# Path-routed host (strip prefix, default) +Host2="host=https://chat-service.internal;path=/chat;probe=/health" -Direct Mode is designed for backends where active health probing is undesirable (e.g., Azure Container Apps, Azure Functions, or other serverless endpoints that scale to zero). By disabling probes, you avoid waking up the service unnecessarily. +# Preserve full path (backend owns its own routing) +Host3="host=https://passthrough.internal;path=/api/v1;stripprefix=false" -**Mechanism:** -* **No Active Probing**: The proxy will **never** send health probe requests to the backend. -* **Always Healthy**: The host is assumed to be 100% available (`SuccessRate = 1.0`). -* **Latency Load Balancing**: Since no probe latency is recorded, the "Average Latency" for these hosts defaults to 0. This means they will effectively be treated equally (Round Robin) amongst themselves and prioritized over high-latency probed hosts. +# Authenticated host (Managed Identity) +Host4="host=https://secure-api.internal;usemi=true;audience=api://my-app-id;probe=/health" -**Required Keys:** -To enable this mode, your connection string **must** include: -* `mode=direct` -* `host` -* `path` (The base path to route to) -* `processor` (If a specific request processor logic is required, otherwise standard) +# Direct mode — serverless, no probing +Host5="host=https://my-func.azurewebsites.net;mode=direct;path=/api/v1" -**Example:** -```bash -Host5="host=https://my-func.azurewebsites.net;mode=direct;path=/api/v1;processor=OpenAI" +# IP override — skip DNS +Host6="host=https://api.backend.com;ipaddress=10.0.1.5;probe=/health" ``` -### 4. Legacy Simple Configuration +> [!NOTE] +> **Legacy format** (`Host1=https://...`, `Probe_path1=/health`, `IP1=10.0.1.5`) is still supported but cannot express `path`, `mode`, `usemi`, or other per-host options. Do not mix legacy and connection-string keys for the same host number. -This method splits configuration across multiple environment variables (`HostN`, `Probe_pathN`, `IPN`). It is simpler but less flexible than the connection string format. +--- -| Variable | Description | -|----------|-------------| -| **HostN** | The base URL. Example: `https://api.backend.com` | -| **Probe_pathN** | The health probe path. Example: `/health` | -| **IPN** | (Optional) The IP address to use instead of DNS resolution. | +## Direct Mode -**Example:** +**Rule: Use `mode=direct` for any backend that scales to zero — the proxy will never probe it, so it will never wake it unnecessarily.** ```bash -Host1="https://api.backend.com" -Probe_path1="/health" -IP1="10.0.1.5" +Host5="host=https://my-func.azurewebsites.net;mode=direct;path=/api/v1" ``` -## Mixing Methods +In direct mode: +- No health probe is ever sent. +- The host is always treated as healthy (`SuccessRate = 1.0`). +- Average latency defaults to `0`, so direct-mode hosts sort first in `latency` load-balance mode. +- `processor` is auto-set to the default stream processor if not specified. -You can mix methods across different hosts (e.g., `Host1` uses a connection string, `Host2` uses the legacy format), but you should not mix definitions for the *same* host number. If `Host1` is a connection string, `Probe_path1` and `IP1` will be ignored. +> [!TIP] +> **Troubleshooting:** If a direct-mode host starts returning errors, the circuit breaker still tracks failures per request — the host will be excluded once it breaches `CBErrorThreshold`. --- ## Path-Based Routing -The `path` parameter in the connection string controls which requests are routed to each host. +**Rule: Specific-path hosts always win over catch-all hosts; within matched hosts the load balancer decides.** -### How Path Matching Works - -1. **Specific paths take precedence**: Hosts with explicit paths (e.g., `/api/v1`) are matched before catch-all hosts. -2. **Path prefix is stripped by default**: When forwarding to a matched host, the matching prefix is removed from the request path. This behavior can be disabled per-host by setting `stripprefix=false` in the connection string. -3. **Catch-all fallback**: Hosts with `path=/` or no path specified handle requests that don't match any specific path. - -### Path Matching Examples - -**Configuration:** ```bash Host1="host=https://chat-service.internal;path=/chat" Host2="host=https://embed-service.internal;path=/embeddings" -Host3="host=https://default-service.internal;path=/" +Host3="host=https://default-service.internal" # catch-all (path=/) ``` -**Request Routing:** - -| Incoming Request | Matched Host | Forwarded Path | -|-----------------|--------------|----------------| +| Incoming request | Matched host | Forwarded path (`stripprefix=true`) | +|------------------|--------------|--------------------------------------| | `GET /chat/completions` | Host1 | `GET /completions` | | `POST /embeddings/create` | Host2 | `POST /create` | | `GET /models` | Host3 | `GET /models` | -| `GET /chat` | Host1 | `GET /` | -### Path Configuration Options +Path matching rules: +1. Hosts with an explicit `path` prefix are checked first. +2. `/`, `/*`, or empty `path` is a catch-all and is tried only when no specific path matches. +3. Wildcards (`/api/*`) match the same as the bare prefix (`/api`). -| Path Value | Behavior | -|------------|----------| -| `/api/v1` | Matches requests starting with `/api/v1`, strips prefix | -| `/api/v1/*` | Same as above (wildcard is implicit) | -| `/` | Catch-all, matches any path, no stripping | -| `/*` | Same as `/` | -| (empty) | Same as `/` | +> [!NOTE] +> **`stripprefix=false`** preserves the full original request path on the forwarded request. Use this when the backend application handles its own sub-routing under the same prefix. -### Controlling Path Prefix Stripping +--- -By default, when a request matches a host's path prefix, that prefix is removed before forwarding (`stripprefix=true`). You can disable this per-host so the original request path is preserved. +## Health Polling -**Configuration:** -```bash -# Default: prefix is stripped -Host1="host=https://chat-service.internal;path=/chat" +**Rule: The poller runs every `PollInterval` ms; a host is active only while its rolling success rate is ≥ `SuccessRate`%.** -# Prefix preserved: backend receives the full original path -Host2="host=https://passthrough-service.internal;path=/api/v1;stripprefix=false" ``` +Every PollInterval ms: + For each probed host: + GET (timeout = PollTimeout ms) + ├── 2xx → AddCallSuccess(true) → latency recorded + └── else → AddCallSuccess(false) → latency not recorded + +FilterActiveHosts: + active = hosts where SuccessRate() >= threshold + if latency order changed → invalidate shared iterator cache +``` + +| Config | Default | Description | +|--------|---------|-------------| +| `PollInterval` | `15000` ms | How often each host is probed | +| `PollTimeout` | `3000` ms | Max wait for a probe response | +| `SuccessRate` | `80` % | Minimum rolling success rate to stay active | + +> [!NOTE] +> Direct-mode hosts skip `GetHostStatus` entirely — they always return `true` and are included in `FilterActiveHosts` unconditionally. -**Routing comparison:** +> [!TIP] +> **Troubleshooting:** If all hosts fall below the threshold the proxy returns `503`. Lower `SuccessRate` or increase `PollTimeout` if backends are slow but functional. -| Incoming Request | Host Config | `stripprefix` | Forwarded Path | -|-----------------|-------------|---------------|----------------| -| `GET /chat/completions` | `path=/chat` | `true` (default) | `GET /completions` | -| `GET /api/v1/users` | `path=/api/v1` | `true` (default) | `GET /users` | -| `GET /api/v1/users` | `path=/api/v1;stripprefix=false` | `false` | `GET /api/v1/users` | -| `GET /chat` | `path=/chat` | `true` (default) | `GET /` | -| `GET /chat` | `path=/chat;stripprefix=false` | `false` | `GET /chat` | +--- + +## Worked Example + +> **Setup:** 3 hosts, `LoadBalanceMode=latency`, `SuccessRate=80`, `PollInterval=15000`. -This is useful when the backend expects the full path including the routing prefix — for example, when the backend application handles its own path-based routing. +| Host | Probe result | Rolling rate | Active? | Avg latency | +|------|-------------|--------------|---------|-------------| +| `chat-service` | 9/10 success | 90% | Yes | 120 ms | +| `embed-service` | 6/10 success | 60% | **No** | — | +| `func-direct` | `mode=direct` | always 100% | Yes | 0 ms | -### Best Practices +**In latency mode, `func-direct` (0 ms) is tried first, then `chat-service` (120 ms). `embed-service` is excluded until its rolling rate recovers above 80%.** + +--- -1. **Use specific paths for service isolation**: Route different AI models or API versions to dedicated backends. -2. **Always have a catch-all**: Include at least one host with `path=/` to handle unexpected routes. -3. **Avoid overlapping paths**: If you have `/api` and `/api/v1`, the more specific path (`/api/v1`) should be tried first. -4. **Use `stripprefix=false` when backends own their routing**: If the backend expects the full original path (e.g., it has its own `/api/v1` routes), disable prefix stripping. +## Related Documentation -See [LOAD_BALANCING.md](LOAD_BALANCING.md) for details on how hosts are selected after path filtering. +- [LOAD_BALANCING.md](LOAD_BALANCING.md) — How hosts are ordered and retried per request +- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) — Per-request failure tracking and circuit state +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — `PollInterval`, `PollTimeout`, `SuccessRate` config keys diff --git a/docs/CIRCUIT_BREAKER.md b/docs/CIRCUIT_BREAKER.md index 74403a5a..f89c47d6 100644 --- a/docs/CIRCUIT_BREAKER.md +++ b/docs/CIRCUIT_BREAKER.md @@ -1,74 +1,133 @@ -# Circuit Breaker & Resilience +# Circuit Breaker -SimpleL7Proxy implements a robust, self-healing **Circuit Breaker** pattern to prevent cascading failures when backend services become unstable. Instead of continuously hammering a failing service (which makes outages worse), the proxy "breaks the circuit" and stops sending traffic to that specific host for a period of time. +The circuit breaker stops traffic to a failing backend host automatically, then restores it once recent failures drop back below the threshold — no manual intervention required. -## Support Logic +> **TL;DR** +> - **Open circuit = host skipped** — the load balancer moves on to the next host without counting an attempt. +> - **Auto-recovery** — old failures age out of the sliding window; the circuit closes itself when the count drops below `CBErrorThreshold`. +> - **Progressive delays** — as failures accumulate toward the threshold, the proxy adds a small artificial delay (100–500 ms) to slow traffic before fully opening the circuit. -The circuit breaker operates on a **Sliding Time Window** principle. +--- + +## Reference — Settings + +| Config name | Default | Description | +|-------------|---------|-------------| +| `CBErrorThreshold` | `50` | Number of failures inside the window that opens the circuit | +| `CBTimeslice` | `60` s | Sliding window width — failures older than this are discarded | +| `AcceptableStatusCodes` | `[200,202,400,401,403,404,408,410,412,417]` | HTTP codes **not** counted as failures | + +> [!NOTE] +> `CBErrorThreshold` and `CBTimeslice` are **Warm** settings — change them in Azure App Configuration and bump `Sentinel`; no restart needed. -1. **Tracking**: Every request to a backend is monitored. -2. **Failure Detection**: If a request returns a status code **not** in the `AcceptableStatusCodes` list (e.g., 500, 502, 503) or throws a network exception, it is recorded as a failure. -3. **Threshold Check**: The proxy counts the number of failures that occurred within the last `CBTimeslice` seconds. -4. **Tripping**: If the count of recent failures exceeds `CBErrorThreshold`, the circuit **Opens** (breaks). -5. **Blocking**: While Open, the host is marked as "Unhealthy." The Load Balancer will skip this host and route traffic to other healthy backends. -6. **Recovery (Auto-Healing)**: As time passes, failure timestamps fall out of the `CBTimeslice` window. Once the count drops below the threshold, the circuit **Closes** automatically, and traffic resumes. +--- + +## How the Circuit Breaker Works -## Configuration +``` +Request to host + │ + ▼ +CheckFailedStatusAsync() + │ + ├── failures in window < threshold? + │ │ + │ ├── count ≥ 50% threshold → add delay (100–500 ms), then CLOSED → proceed + │ └── count < 50% threshold → CLOSED → proceed immediately + │ + └── failures in window ≥ threshold? + │ + └── prune expired entries → still ≥ threshold? + ├── Yes → OPEN → return true (host skipped by load balancer) + └── No → CLOSED → proceed (circuit self-heals) + +TrackStatus(code, wasFailure, state) — called after every backend response + │ + └── code not in AcceptableStatusCodes OR wasFailure=true + └── enqueue failure timestamp → emit CircuitBreakerError event +``` -Control the sensitivity of the circuit breaker using these environment variables: +**Progressive delay thresholds (not configurable):** -| Variable | Default | Description | -| :--- | :--- | :--- | -| **`CBErrorThreshold`** | `50` | The number of errors required to trip the circuit. Lower values make it more sensitive. | -| **`CBTimeslice`** | `60` | The sliding window duration (in seconds). Errors older than this are ignored. | -| **`AcceptableStatusCodes`** | `200, 202, 401...` | List of HTTP codes considered "Success". Anything else counts towards the error threshold. | +| Failure count | Delay added | +|---------------|-------------| +| ≥ 50% of threshold | 100 ms | +| ≥ 60% | 200 ms | +| ≥ 70% | 300 ms | +| ≥ 80% | 400 ms | +| ≥ 90% | 500 ms | + +--- + +## Configuring the Circuit Breaker + +**Rule: Lower `CBErrorThreshold` for fast failover; raise it for flaky backends you want to tolerate.** + +```bash +# Fast failover — opens after 5 errors in 10 s +CBErrorThreshold=5 +CBTimeslice=10 + +# Tolerant — absorbs bursts before opening +CBErrorThreshold=100 +CBTimeslice=60 +``` -### Example Scenarios +> [!NOTE] +> **Default:** `CBErrorThreshold=50`, `CBTimeslice=60`. At defaults, the circuit opens after 50 failures within the last 60 seconds. -* **Fast Failover**: Set `CBErrorThreshold=5` and `CBTimeslice=10`. The proxy will stop using a host almost immediately after a burst of 5 errors. -* **Tolerant**: Set `CBErrorThreshold=100`. Useful for "flaky" non-critical backends where you strictly prefer retries over disabling the host. +> [!TIP] +> **Troubleshooting:** If hosts are opening too aggressively, check whether transient `5xx` codes are in `AcceptableStatusCodes`. Adding `503` to that list means 503 responses will not count as failures. + +--- ## Global Safety Net -The proxy monitors the state of **all** circuit breakers. If **all** configured backends are tripped (meaning the entire backend tier is down), the proxy returns a `503 Service Unavailable` to the client immediately, protecting the proxy itself from resource exhaustion. +**Rule: When every registered circuit breaker is OPEN simultaneously, the proxy returns `503` immediately without trying any host.** + +`AreAllCircuitBreakersBlocked()` returns `true` when `blockedCount >= totalCount`. This prevents resource exhaustion when the entire backend tier is down. + +> [!WARNING] +> **Error:** `503 Service Unavailable` with all circuit breakers OPEN means every backend has hit its failure threshold. Address the backend health issue — raising thresholds is a workaround, not a fix. --- -## Integration with Load Balancing +## Worked Example -The circuit breaker is checked **per-host** during the backend selection loop. This means: +> **Setup:** `CBErrorThreshold=10`, `CBTimeslice=30`. Three hosts A, B, C. -1. **A single tripped host doesn't block the request** - the proxy simply skips to the next host in the iterator. -2. **Healthy hosts continue receiving traffic** - only the failing host is isolated. -3. **Automatic recovery** - as the circuit closes, traffic resumes without manual intervention. +| Time | Event | Window failures | Circuit state | +|------|-------|-----------------|---------------| +| 0 s | Startup | 0 | CLOSED | +| 5 s | 8 failures from Host A | 8 | CLOSED + 400 ms delay (80%) | +| 10 s | 2 more failures | 10 | **OPEN** — Host A skipped | +| 10 s | Requests route to B, C | — | B=CLOSED, C=CLOSED | +| 40 s | All 10 failures age out of 30 s window | 0 | **Auto-CLOSED** — Host A back in pool | -### Request Flow with Circuit Breaker +**Host A rejoins the active pool automatically once all its failures age out of the `CBTimeslice` window — no restart or manual reset needed.** -``` -FOR EACH HOST in load balancer: - │ - ├─ CheckFailedStatus() ──[OPEN]──► SKIP (log and continue to next host) - │ - └─[CLOSED]──► Send request to host - │ - ├─[Success]──► Return response ✓ - │ - └─[Failure]──► Record failure, try next host - (may trip circuit if threshold exceeded) -``` +--- + +## Integration with Load Balancing -### Example Scenario +During iteration the load balancer calls `CheckFailedStatusAsync()` before sending to each host: ``` -Hosts: [A, B, C] -Circuit Breaker Status: A=OPEN, B=CLOSED, C=CLOSED - -Request arrives: - 1. Iterator selects Host A → Circuit OPEN → SKIP - 2. Iterator selects Host B → Circuit CLOSED → Send request → 200 OK ✓ - -Result: Request succeeds despite Host A being unhealthy +FOR EACH HOST in iterator: + CheckFailedStatusAsync() + OPEN → skip (no attempt counted) + CLOSED → send request + success → return to client ✓ + failure → TrackStatus() → try next host ``` -See [LOAD_BALANCING.md](LOAD_BALANCING.md) for details on how hosts are selected and iterated. +See [LOAD_BALANCING.md](LOAD_BALANCING.md) for how hosts are ordered and how `MaxAttempts` interacts with skipped hosts. + +--- + +## Related Documentation + +- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) — Per-host configuration and health polling +- [LOAD_BALANCING.md](LOAD_BALANCING.md) — Iterator and retry settings +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — Full settings reference diff --git a/docs/CONFIGURATION_SETTINGS.md b/docs/CONFIGURATION_SETTINGS.md index 629ee351..1326546a 100644 --- a/docs/CONFIGURATION_SETTINGS.md +++ b/docs/CONFIGURATION_SETTINGS.md @@ -1,255 +1,280 @@ -# BackendOptions Settings - Organized by Restart Requirement +# Configuration Settings -## Legend +All proxy settings live in `ProxyConfig.cs` and are sourced from environment variables, Azure App Configuration, or both. -| Tag | Description | -|-----|-------------| -| **[WARM]** | Hot-reloadable (read per-request or periodically refreshed) | -| **[DRAIN]** | Requires draining all workers (stop accepting, wait for in-flight to complete) | -| **[COLD]** | Requires cold restart (read once at startup, configures DI/infrastructure) | -| **[PARTIAL]** | Mixed - some settings WARM, others COLD/DRAIN | +> **TL;DR** +> - **Warm** settings are hot-reloaded from Azure App Configuration (~30 s) — no restart needed. +> - **Cold** settings are published to App Configuration but take effect only after a restart. +> - **Hidden** settings are never published — they are runtime-derived, parsed from composite strings, or infrastructure-only. --- -## [WARM] Settings - Can be changed without restart - -### Async (per-request settings) - -| Setting | Property Name | -|---------|---------------| -| Timeout | `AsyncTimeout` | -| TTLSecs | `AsyncTTLSecs` | -| TriggerTimeout | `AsyncTriggerTimeout` | -| ClientRequestHeader | `AsyncClientRequestHeader` | -| ClientConfigFieldName | `AsyncClientConfigFieldName` | - -### Logging - Read per-request or per-event - -| Setting | Property Name | -|---------|---------------| -| LogToConsole | `LogToConsole` | -| LogToEvents | `LogToEvents` | -| LogToAI | `LogToAI` | -| Probes | `LogProbes` | -| Headers | `LogHeaders` | -| AllRequestHeaders | `LogAllRequestHeaders` | -| AllRequestHeadersExcept | `LogAllRequestHeadersExcept` | -| AllResponseHeaders | `LogAllResponseHeaders` | -| AllResponseHeadersExcept | `LogAllResponseHeadersExcept` | - -### Request - Read per-request - -| Setting | Property Name | -|---------|---------------| -| MaxAttempts | `MaxAttempts` | -| TimeoutHeader | `TimeoutHeader` | -| TTLHeader | `TTLHeader` | -| DefaultTTLSecs | `DefaultTTLSecs` | -| RequiredHeaders | `RequiredHeaders` | -| StripHeaders | `StripRequestHeaders` | -| DisallowedHeaders | `DisallowedHeaders` | -| DependencyHeaders | `DependancyHeaders` | - -### Response - Read per-response - -| Setting | Property Name | -|---------|---------------| -| StripHeaders | `StripResponseHeaders` | - -### StatusCodes - Read per-response - -| Setting | Property Name | -|---------|---------------| -| Acceptable | `AcceptableStatusCodes` | - -### Validation - Read per-request - -| Setting | Property Name | -|---------|---------------| -| Headers | `ValidateHeaders` | -| AuthAppID.Enabled | `ValidateAuthAppID` | -| AuthAppID.Url | `ValidateAuthAppIDUrl` | -| AuthAppID.FieldName | `ValidateAuthAppFieldName` | -| AuthAppID.Header | `ValidateAuthAppIDHeader` | - -### Server (metadata only) - -| Setting | Property Name | -|---------|---------------| -| IDStr | `IDStr` | -| ContainerApp | `ContainerApp` | -| Revision | `Revision` | +> **Units used in this doc:** timeout/interval values in **milliseconds** unless the property name ends in `Secs` or `Minutes` (those are seconds/minutes). ---- +## How Settings Are Loaded -## [DRAIN] Settings - Require stopping all workers before restart +``` +Startup + │ + ├── Read ALL settings (Warm + Cold + Hidden) from env vars / App Configuration + │ + └── Every AZURE_APPCONFIG_REFRESH_INTERVAL_SECONDS + └── Re-apply Warm settings only (sentinel-triggered) +``` -> ⚠️ These settings affect shared state, external connections, or would cause inconsistency during rolling update. Drain all in-flight requests before changing. +**Changing a Cold setting requires a restart; changing a Warm setting only requires bumping `Sentinel`.** -### Async - Switching modes or connections with in-flight requests causes data loss +--- -| Setting | Property Name | Reason | -|---------|---------------|--------| -| Enabled | `AsyncModeEnabled` | Mode switch with in-flight requests | -| BlobStorage.ConnectionString | `AsyncBlobStorageConnectionString` | Connection change with pending writes | -| BlobStorage.UseMI | `AsyncBlobStorageUseMI` | Auth change with pending writes | -| BlobStorage.AccountUri | `AsyncBlobStorageAccountUri` | Connection change with pending writes | -| ServiceBus.ConnectionString | `AsyncSBConnectionString` | Connection change with pending messages | -| ServiceBus.Queue | `AsyncSBQueue` | Queue change with pending messages | -| ServiceBus.UseMI | `AsyncSBUseMI` | Auth change with pending messages | -| ServiceBus.Namespace | `AsyncSBNamespace` | Namespace change with pending messages | +## Warm Settings — hot-reloaded, no restart needed -### Hosts - Changing backends with in-flight requests causes routing errors +### Async -| Setting | Property Name | Reason | -|---------|---------------|--------| -| Hosts | `Hosts` | Backend routing changes | +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `AsyncClientRequestHeader` | `AsyncClientRequestHeader` | `AsyncMode` | Request header that enables async mode | +| `AsyncClientConfigFieldName` | `AsyncClientConfigFieldName` | `async-config` | JSON field in async client config | +| `AsyncTimeout` | `AsyncTimeout` | `1800000` ms (30 min) | Max backend processing time in async mode | +| `AsyncTTLSecs` | `AsyncTTLSecs` | `86400` s (24 h) | Async result blob retention | +| `AsyncTriggerTimeout` | `AsyncTriggerTimeout` | `10000` ms (10 s) | Wait before converting a queued request to async | + +### Circuit Breaker + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `CBErrorThreshold` | `CircuitBreakerErrorThreshold` | `50` | Error % that opens the circuit | +| `CBTimeslice` | `CircuitBreakerTimeslice` | `60` s | Rolling window for error rate calculation | + +### Health Probe + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `HealthProbeSidecar` | `HealthProbeSidecar` | `Enabled=false;url=http://localhost:9000` | Sidecar health probe config string | + +### Load Balancing + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `LoadBalanceMode` | `LoadBalanceMode` | `latency` | `roundrobin`, `latency`, or `random` | +| `IterationMode` | `IterationMode` | `SinglePass` | `SinglePass` or `MultiPass` | + +### Logging + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `LogToConsole` | `LogToConsole` | `["*"]` | Event categories written to console | +| `LogToEvents` | `LogToEvents` | `["async","backend","probe",...]` | Event categories written to event store | +| `LogToAI` | `LogToAI` | `[""]` | Event categories sent to Application Insights | +| `LogHeaders` | `LogHeaders` | `[]` | Specific headers to log | +| `LogAllRequestHeaders` | `LogAllRequestHeaders` | `false` | Log all inbound headers | +| `LogAllRequestHeadersExcept` | `LogAllRequestHeadersExcept` | `["Authorization"]` | Headers excluded from full request logging | +| `LogAllResponseHeaders` | `LogAllResponseHeaders` | `false` | Log all outbound headers | +| `LogAllResponseHeadersExcept` | `LogAllResponseHeadersExcept` | `["Api-Key"]` | Headers excluded from full response logging | + +### Request Processing + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `DefaultPriority` | `DefaultPriority` | `2` | Priority assigned when no priority header present | +| `DefaultTTLSecs` | `DefaultTTLSecs` | `300` s | Request TTL when no `S7PTTL` header present | +| `GreedyUserThreshold` | `UserPriorityThreshold` | `0.1` | Fraction of queue a single user may occupy | +| `PriorityKeys` | `PriorityKeys` | `["12345","234"]` | Known priority key values | +| `PriorityValues` | `PriorityValues` | `[1,3]` | Priority level assigned per key | +| `DefaultTimeout` | `Timeout` | `1200000` ms (20 min) | Per-host request timeout | +| `MaxAttempts` | `MaxAttempts` | `10` | Max backend attempts per request | +| `S7PTimeout` *(header name)* | `TimeoutHeader` | `S7PTimeout` | Header clients use to override per-request timeout | +| `S7PTTL` *(header name)* | `TTLHeader` | `S7PTTL` | Header clients use to override per-request TTL | +| `S7PPriorityKey` *(header name)* | `PriorityKeyHeader` | `S7PPriorityKey` | Header clients use to set priority | +| `UniqueUserHeaders` | `UniqueUserHeaders` | `["X-UserID"]` | Headers that identify a unique user | +| `RequiredHeaders` | `RequiredHeaders` | `[]` | Headers that must be present or request is rejected | +| `DisallowedHeaders` | `DisallowedHeaders` | `[]` | Headers that must not be present | +| `StripRequestHeaders` | `StripRequestHeaders` | `[]` | Headers stripped before forwarding to backend | +| `DependancyHeaders` | `DependancyHeaders` | `["Backend-Host","Status",...]` | Headers copied from backend response into event log | +| `ValidateHeaders` | `ValidateHeaders` | `{}` | Header name→expected value validation map | + +### Response + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `AcceptableStatusCodes` | `AcceptableStatusCodes` | `[200,202,400,401,403,404,408,410,412,417]` | Status codes returned to client without retry | +| `StripResponseHeaders` | `StripResponseHeaders` | `[]` | Headers stripped from backend response | + +### User Profiles & Auth Validation + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `UserConfigUrl` | `UserConfigUrl` | `""` | URL for user profile config (file: or http:) | +| `SuspendedUserConfigUrl` | `SuspendedUserConfigUrl` | `""` | URL for suspended user list | +| `UserIDFieldName` | `UserIDFieldName` | `userId` | JSON field used as user identifier | +| `UserProfileHeader` | `UserProfileHeader` | `X-UserProfile` | Header injected with user profile data | +| `UseProfiles` | `UseProfiles` | `false` | Enable user profile enrichment | +| `UserConfigRequired` | `UserConfigRequired` | `false` | Reject requests when user config unavailable | +| `ValidateAppIDEnabled` | `ValidateAuthAppID` | `false` | Enable app ID validation | +| `ValidateAuthAppIDUrl` | `ValidateAuthAppIDUrl` | `""` | URL for app ID allowlist | +| `ValidateAuthAppFieldName` | `ValidateAuthAppFieldName` | `authAppID` | JSON field name for app ID | +| `ValidateAuthAppIDHeader` | `ValidateAuthAppIDHeader` | `X-MS-CLIENT-PRINCIPAL-ID` | Header containing app ID to validate | + +### Sentinel + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `Sentinel` | `Sentinel` | `""` | Update this value to trigger a Warm settings refresh | -### LoadBalancing - Changing strategy mid-flight causes uneven distribution +--- -| Setting | Property Name | Reason | -|---------|---------------|--------| -| Mode | `LoadBalanceMode` | Strategy change mid-flight | -| IterationMode | `IterationMode` | Iterator behavior change | -| UseSharedIterators | `UseSharedIterators` | State inconsistency with active iterators | +## Cold Settings — restart required -### OAuth - Changing auth mid-flight causes 401s on in-flight requests +### Async -| Setting | Property Name | Reason | -|---------|---------------|--------| -| Enabled | `UseOAuth` | Auth change mid-flight | -| UseGov | `UseOAuthGov` | Endpoint change mid-flight | -| Audience | `OAuthAudience` | Token audience change | +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `AsyncModeEnabled` | `AsyncModeEnabled` | `false` | Enable asynchronous request processing | +| `AsyncBlobStorageConfig` | `AsyncBlobStorageConfig` | `uri=...,mi=true` | Blob storage composite config string | +| `AsyncSBConfig` | `AsyncSBConfig` | `cs=...,ns=...,q=requeststatus,mi=false` | Service Bus composite config string | +| `AsyncBlobWorkerCount` | `AsyncBlobWorkerCount` | `2` | Worker threads for blob upload | +| `StorageDbEnabled` | `StorageDbEnabled` | `false` | Enable blob result storage | +| `StorageDbContainerName` | `StorageDbContainerName` | `Requests` | Blob container name for async results | -### Server - Infrastructure changes with active queue +### Security -| Setting | Property Name | Reason | -|---------|---------------|--------| -| Port | `Port` | Listener stop required | -| Workers | `Workers` | Worker count with active queue | -| MaxQueueLength | `MaxQueueLength` | Queue resize with pending requests | +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `IgnoreSSLCert` | `IgnoreSSLCert` | `false` | Skip TLS verification (dev only) | +| `UseOAuth` | `UseOAuth` | `false` | Enable OAuth token validation | +| `OAuthAudience` | `OAuthAudience` | `""` | Expected OAuth audience | -### Storage - Storage changes with pending writes = data loss +### Server -| Setting | Property Name | Reason | -|---------|---------------|--------| -| DbEnabled | `StorageDbEnabled` | Toggling with pending writes | -| DbContainerName | `StorageDbContainerName` | Container change with pending writes | +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `Port` | `Port` | `80` | Proxy listen port | +| `Workers` | `Workers` | `10` | Concurrent worker count | +| `MaxQueueLength` | `MaxQueueLength` | `1000` | Max queued requests before returning 429 | +| `PollInterval` | `PollInterval` | `15000` ms | Backend health poll interval | +| `PollTimeout` | `PollTimeout` | `3000` ms | Backend health probe timeout | +| `SuccessRate` | `SuccessRate` | `80` % | Min success rate to keep circuit closed | +| `TERMINATION_GRACE_PERIOD_SECONDS` | `TerminationGracePeriodSeconds` | `30` s | Drain window on shutdown | +| `GC2InternalSecs` | `GC2InternalSecs` | `300` s | GC2 internal cleanup interval | +| `EVENTHUB_MAX_UNDRAINED_EVENTS` | `MaxUndrainedEvents` | `100` | Max buffered events before blocking | +| `SharedIteratorTTLSeconds` | `SharedIteratorTTLSeconds` | `60` s | TTL for an unused shared iterator | +| `SharedIteratorCleanupIntervalSeconds` | `SharedIteratorCleanupIntervalSeconds` | `30` s | Shared iterator cleanup frequency | + +### Logging + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `APPINSIGHTS_CONNECTIONSTRING` | `AppInsightsConnectionString` | `""` | Application Insights connection string | +| `EVENT_LOGGERS` | `EventLoggers` | `file` | Comma-separated list of event sinks | +| `EVENT_HEADERS` | `EventHeaders` | `SimpleL7Proxy.Events.CommonEventHeaders` | Event data class name | +| `LOGFILE_NAME` | `LogFileName` | `eventslog.json` | Event log file path | +| `LOGDATETIME` | `LogDateTime` | `false` | Prefix log entries with timestamp | +| `ReuseEvents` | `ReuseEvents` | `false` | Reuse event objects across requests | + +### Event Hub + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `EVENTHUB_CONNECTIONSTRING` | `EventHubConnectionString` | `""` | Event Hub connection string | +| `EVENTHUB_NAME` | `EventHubName` | `""` | Event Hub name | +| `EVENTHUB_NAMESPACE` | `EventHubNamespace` | `""` | Event Hub namespace | +| `EVENTHUB_STARTUP_SECONDS` | `EventHubStartupSeconds` | `10` s | Delay before Event Hub starts sending | +| `EVENTHUB_MAX_RECONNECT_ATTEMPTS` | `EventHubMaxReconnectAttempts` | `5` | Max reconnect attempts on failure | + +### User Profiles + +| Env Var / Config Name | Property | Default | Description | +|----------------------|----------|---------|-------------| +| `RefreshIntervalSecs` | `UserConfigRefreshIntervalSecs` | `3600` s (1 h) | How often user config is reloaded | +| `SoftDeleteTTLMinutes` | `UserSoftDeleteTTLMinutes` | `360` min (6 h) | TTL for soft-deleted user records | --- -## [COLD] Settings - Require restart but can use rolling update +## Hidden Settings — not published to App Configuration -### Async +> [!NOTE] +> Hidden settings are set via environment variables only. They are never written to or read from Azure App Configuration. -| Setting | Property Name | -|---------|---------------| -| BlobStorageConfig | `AsyncBlobStorageConfig` | -| SBConfig | `AsyncSBConfig` | -| BlobWorkerCount | `AsyncBlobWorkerCount` | +### Azure App Configuration -### CircuitBreaker - Configured at startup +| Env Var | Property | Default | Description | +|---------|----------|---------|-------------| +| `AZURE_APPCONFIG_ENDPOINT` | `AppConfigEndpoint` | — | App Configuration endpoint (Managed Identity auth) | +| `AZURE_APPCONFIG_CONNECTION_STRING` | `AppConfigConnectionString` | — | App Configuration connection string (dev fallback) | +| `AZURE_APPCONFIG_LABEL` | `AppConfigLabel` | — | Label filter for settings | +| `AZURE_APPCONFIG_REFRESH_INTERVAL_SECONDS` | `AppConfigRefreshIntervalSeconds` | `30` s | Sentinel poll interval | -| Setting | Property Name | -|---------|---------------| -| ErrorThreshold | `CircuitBreakerErrorThreshold` | -| Timeslice | `CircuitBreakerTimeslice` | +### Security -### HealthProbe - Timer and sidecar client created at startup +| Env Var | Property | Default | Description | +|---------|----------|---------|-------------| +| `UseOAuthGov` | `UseOAuthGov` | `false` | Use Azure Government OAuth endpoint | -| Setting | Property Name | -|---------|---------------| -| Sidecar | `HealthProbeSidecar` | -| SidecarEnabled | `HealthProbeSidecarEnabled` | -| SidecarUrl | `HealthProbeSidecarUrl` | +### Async — parsed from `AsyncBlobStorageConfig` -### Hosts +| Property | Default | Description | +|----------|---------|-------------| +| `AsyncBlobStorageConnectionString` | `example-connection-string` | Parsed blob storage connection string | +| `AsyncBlobStorageUseMI` | `true` | Use Managed Identity for blob storage | +| `AsyncBlobStorageAccountUri` | `https://mystorageaccount.blob.core.windows.net` | Blob storage account URI | -| Setting | Property Name | -|---------|---------------| -| HostName | `HostName` | +### Async — parsed from `AsyncSBConfig` -### LoadBalancing.SharedIterator +| Property | Default | Description | +|----------|---------|-------------| +| `AsyncSBConnectionString` | `example-sb-connection-string` | Parsed Service Bus connection string | +| `AsyncSBQueue` | `requeststatus` | Service Bus queue name | +| `AsyncSBUseMI` | `false` | Use Managed Identity for Service Bus | +| `AsyncSBNamespace` | `example-namespace` | Service Bus namespace | -| Setting | Property Name | -|---------|---------------| -| TTLSeconds | `SharedIteratorTTLSeconds` | -| CleanupIntervalSeconds | `SharedIteratorCleanupIntervalSeconds` | +### Logging -### Polling - Poller timer configured at startup +| Env Var | Property | Default | Description | +|---------|----------|---------|-------------| +| `LOG_LEVEL` | `LogLevel` | `Information` | Minimum log level | +| `LOGTOFILE` | `LogToFile` | `false` | Write logs to file | -| Setting | Property Name | -|---------|---------------| -| Interval | `PollInterval` | -| Timeout | `PollTimeout` | -| SuccessRate | `SuccessRate` | +### Transport / Keep-Alive -### Request +| Env Var | Property | Default | Description | +|---------|----------|---------|-------------| +| `KeepAliveInitialDelaySecs` | `KeepAliveInitialDelaySecs` | `60` s | Delay before first keep-alive probe | +| `KeepAlivePingIntervalSecs` | `KeepAlivePingIntervalSecs` | `60` s | Interval between keep-alive pings | +| `KeepAliveIdleTimeoutSecs` | `KeepAliveIdleTimeoutSecs` | `1200` s | Idle connection timeout | +| `EnableMultipleHttp2Connections` | `EnableMultipleHttp2Connections` | `false` | Allow multiple HTTP/2 connections per host | +| `MultiConnLifetimeSecs` | `MultiConnLifetimeSecs` | `3600` s | Max lifetime of a pooled connection | +| `MultiConnIdleTimeoutSecs` | `MultiConnIdleTimeoutSecs` | `300` s | Idle timeout for pooled connections | +| `MultiConnMaxConns` | `MultiConnMaxConns` | `4000` | Max connections in the pool | -| Setting | Property Name | -|---------|---------------| -| Timeout | `Timeout` (HttpClient timeout) | +### Metadata (populated by Azure Container Apps runtime) -### Security +| Env Var | Property | Default | Description | +|---------|----------|---------|-------------| +| `CONTAINER_APP_NAME` | `ContainerApp` | `ContainerAppName` | Container App name injected by ACA | +| `Hostname` | `HostName` | `""` | Host name | +| `RequestIDPrefix` | `IDStr` | `S7P` | Prefix for generated request IDs | +| `CONTAINER_APP_REPLICA_NAME` | `ReplicaName` | `""` | Replica name injected by ACA | +| `CONTAINER_APP_REVISION` | `Revision` | `revisionID` | Revision name injected by ACA | -| Setting | Property Name | -|---------|---------------| -| IgnoreSSLCert | `IgnoreSSLCert` | +--- -### Server +## Runtime-Derived Properties + +These are never set via config — the proxy computes them at startup from other settings. -| Setting | Property Name | -|---------|---------------| -| MaxEvents | `MaxEvents` | -| TerminationGracePeriodSeconds | `TerminationGracePeriodSeconds` | -| TrackWorkers | `TrackWorkers` | - -### Logging - Configured at startup - -| Setting | Property Name | -|---------|---------------| -| Level | `LogLevel` | -| EventLoggers | `EventLoggers` | -| EventHeaders | `EventHeaders` | -| LogFileName | `LogFileName` | -| LogDateTime | `LogDateTime` | - -### EventHub - Configured at startup - -| Setting | Property Name | -|---------|---------------| -| ConnectionString | `EventHubConnectionString` | -| Name | `EventHubName` | -| Namespace | `EventHubNamespace` | -| StartupSeconds | `EventHubStartupSeconds` | -| MaxReconnectAttempts | `EventHubMaxReconnectAttempts` | -| MaxUndrainedEvents | `MaxUndrainedEvents` | +| Property | Description | +|----------|-------------| +| `HealthProbeSidecarEnabled` | Parsed from `HealthProbeSidecar` | +| `HealthProbeSidecarUrl` | Parsed from `HealthProbeSidecar` | +| `Hosts` | Populated from `Host1`…`HostN` environment variables | +| `PriorityWorkers` | Worker allocation map derived from `PriorityValues` | +| `TrackWorkers` | Internal worker tracking flag | +| `UseSharedIterators` | Whether to share iterator state across concurrent requests | --- -## [PARTIAL] Settings - Mixed restart requirements - -### Priority - -| Setting | Property Name | Requirement | -|---------|---------------|-------------| -| Default | `DefaultPriority` | [WARM] | -| KeyHeader | `PriorityKeyHeader` | [WARM] | -| Keys | `PriorityKeys` | [WARM] | -| Values | `PriorityValues` | [WARM] | -| Workers | `PriorityWorkers` | [DRAIN] | - -### User - -| Setting | Property Name | Requirement | -|---------|---------------|-------------| -| IDFieldName | `UserIDFieldName` | [WARM] | -| ProfileHeader | `UserProfileHeader` | [WARM] | -| ConfigUrl | `UserConfigUrl` | [WARM] | -| PriorityThreshold | `UserPriorityThreshold` | [WARM] | -| UniqueHeaders | `UniqueUserHeaders` | [WARM] | -| SuspendedConfigUrl | `SuspendedUserConfigUrl` | [WARM] | -| UseProfiles | `UseProfiles` | [COLD] | -| UserConfigRequired | `UserConfigRequired` | [COLD] | -| UserConfigRefreshIntervalSecs | `UserConfigRefreshIntervalSecs` | [COLD] | -| UserSoftDeleteTTLMinutes | `UserSoftDeleteTTLMinutes` | [COLD] | +## Related Documentation + +- [AZURE_APP_CONFIGURATION.md](AZURE_APP_CONFIGURATION.md) — Setting up hot-reload with App Configuration +- [DEVELOPMENT.md](DEVELOPMENT.md) — Local dev setup and minimal required config +- [TIMEOUTS.md](TIMEOUTS.md) — How TTL, Timeout, and AsyncTimeout interact +- [LOAD_BALANCING.md](LOAD_BALANCING.md) — LoadBalanceMode, IterationMode, and retry settings diff --git a/docs/CONTAINER_DEPLOYMENT.md b/docs/CONTAINER_DEPLOYMENT.md index 25ffcf21..33419d82 100644 --- a/docs/CONTAINER_DEPLOYMENT.md +++ b/docs/CONTAINER_DEPLOYMENT.md @@ -1,420 +1,405 @@ # Container Deployment -This document provides comprehensive instructions for running SimpleL7Proxy as a container, including local Docker deployment and Azure Container Apps deployment. +Build a Docker image from the `src/` directory and run it locally or deploy it to Azure Container Apps — the proxy listens on port **443** (HTTP) and exposes a health probe server on port **9000**. -## Azure Container Apps Deployment with AZD +> **TL;DR** +> - **Build from `src/`** — the Dockerfile requires `Shared/` and `SimpleL7Proxy/` side-by-side; build context must be `src/`. +> - **Probe paths are in the Host connection string** — use `Host1=host=https://api.example.com;probe=/health` (not separate `Probe_path1=` variables). +> - **Fastest path to Azure:** run `.azure/setup.sh` → `azd provision` → `.azure/deploy.sh`. -SimpleL7Proxy can be deployed to Azure Container Apps using the Azure Developer CLI (AZD), providing a streamlined deployment experience with predefined scenarios and environment templates. +--- -### Prerequisites +## Container Ports -- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) -- [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) -- [Docker](https://docs.docker.com/get-docker/) +| Port | Purpose | +|------|---------| +| `443` | Main proxy traffic (HTTP, not HTTPS — TLS is terminated by ACA ingress) | +| `9000` | Health probe server — serves `/liveness`, `/readiness`, `/startup` | -### Deployment Steps +> [!NOTE] +> Port 443 carries plain HTTP inside the container. TLS termination is done by the Azure Container Apps ingress or an upstream load balancer. -#### 1. Run the Setup Script +--- -**Windows** -```powershell -./.azure/setup.ps1 -``` +## Building the Image + +**Rule: Use `src/SimpleL7Proxy/build.sh` — it handles the correct build context, version extraction from `Constants.cs`, ACR login, and push automatically.** -**Linux/macOS** ```bash -chmod +x ./.azure/setup.sh -./.azure/setup.sh +export ACR=myregistry # your ACR name, without .azurecr.io +cd src/SimpleL7Proxy +./build.sh ``` -#### 2. Configure Deployment - -The setup script guides you through the configuration process: +The script: +1. Reads the version from `Constants.cs` (`VERSION = "..."`) and prefixes `v` if needed +2. Logs in to ACR via `az acr login` +3. Runs `docker build` from `src/` (the correct context that includes `Shared/`) +4. Pushes `$ACR.azurecr.io/myproxy:` to ACR +5. Prints the `PROXY_VERSION` export line to paste into `deploy.parameters.sh` -1. **Select a Deployment Scenario**: - - **Local proxy with public APIM**: Run the proxy locally while connecting to a public APIM instance - - **ACA proxy with public APIM**: Deploy the proxy as an Azure Container App connecting to a public APIM instance - - **VNET proxy deployment**: Deploy the proxy within a Virtual Network for enhanced security +> [!NOTE] +> `ACR` can also be set in `deployment/proxy-with-sidecar/deploy.parameters.sh` — the script sources it automatically if that file exists. -2. **Choose an Environment Template** (optional): - - **Standard Production**: Balanced configuration for most production workloads - - **High Performance**: Optimized for maximum throughput and low latency - - **Cost Optimized**: Designed to minimize resource usage and cost - - **High Availability**: Maximized for resilience and uptime - - **Development**: Configured for development and testing with additional debug information - -3. Configure additional Azure resources as prompted. - -#### 3. Provision Infrastructure +### Updating after a code change ```bash -azd provision -``` +# 1. Bump the version in Constants.cs if needed +# VERSION = "2.x.x" -#### 4. Deploy the Application +# 2. Rebuild and push +export ACR=myregistry +cd src/SimpleL7Proxy +./build.sh -**Windows** -```powershell -./.azure/deploy.ps1 -``` - -**Linux/macOS** -```bash -chmod +x ./.azure/deploy.sh -./.azure/deploy.sh +# 3. Update the running Container App to the new image +# (the script prints the exact version — use it below) +az containerapp update \ + --name $ACANAME \ + --resource-group $GROUP \ + --image $ACR.azurecr.io/myproxy: ``` -During deployment, you can apply an environment template if you didn't do so during setup. - -### Environment Templates - -SimpleL7Proxy provides predefined environment variable templates in the `.azure/env-templates/` directory. Each template is optimized for specific operational needs: +> [!TIP] +> If you deployed with AZD, run `.azure/deploy.sh` instead of `az containerapp update` — it reads the version and environment from `azd env get-values` automatically. -- **Standard Production**: Balanced performance and cost with multiple priority levels -- **High Performance**: Maximum concurrency and optimized settings for throughput -- **Cost Optimized**: Reduced worker counts and minimal resource usage -- **High Availability**: Multiple backend hosts with aggressive failover settings -- **Development**: Debug logging and extended timeouts for testing +### Manual build (without the script) -For more details on environment variables, see [ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md). +Only needed if you are not using the sidecar deployment or want a custom image name: -## Running as a Docker Container +```bash +# Must run from src/ — Dockerfile references Shared/ at the same level +cd src +docker build -t simplel7proxy:latest -f SimpleL7Proxy/Dockerfile . +``` -### Prerequisites -- Cloned repository -- Docker installed and running -- Basic understanding of Docker commands +> [!WARNING] +> Running `docker build` from the repository root (not `src/`) will fail — `COPY Shared/` will not resolve. -### Building the Container +--- -```bash -# Navigate to the project directory -cd SimpleL7Proxy +## Running Locally with Docker -# Build the Docker image -docker build -t simplel7proxy -f Dockerfile . -``` +### Minimal run -### Running the Container Locally - -#### Basic Configuration ```bash -# Run with minimal configuration docker run -p 8000:443 \ - -e "Host1=https://localhost:3000" \ - -e "Host2=http://localhost:5000" \ + -e "Host1=host=https://api.example.com;probe=/health" \ -e "Timeout=2000" \ - -e "LogAllRequestHeaders=true" \ - -e "Workers=15" \ - simplel7proxy + -e "Workers=10" \ + simplel7proxy:latest ``` -#### Production Configuration -```bash -# Run with production settings -docker run -p 8000:443 \ - -e "Host1=https://api1.example.com" \ - -e "Host2=https://api2.example.com" \ - -e "Host3=https://api3.example.com" \ - -e "Probe_path1=/health" \ - -e "Probe_path2=/health" \ - -e "Probe_path3=/health" \ - -e "Workers=20" \ - -e "MaxQueueLength=50" \ - -e "PollInterval=10000" \ - -e "Timeout=5000" \ - -e "LogAllRequestHeaders=false" \ - -e "APPINSIGHTS_CONNECTIONSTRING=your-connection-string" \ - simplel7proxy -``` +### Using an environment file -#### With Environment File -Create a `.env` file: +Create `.env`: ```bash -# .env file -Host1=https://api1.example.com -Host2=https://api2.example.com +Host1=host=https://api1.example.com;probe=/health +Host2=host=https://api2.example.com;probe=/health Workers=20 -MaxQueueLength=50 -LogAllRequestHeaders=true +MaxQueueLength=1000 +Timeout=5000 APPINSIGHTS_CONNECTIONSTRING=your-connection-string ``` -Run with environment file: ```bash -docker run -p 8000:443 --env-file .env simplel7proxy +docker run -p 8000:443 --env-file .env simplel7proxy:latest ``` -### Docker Compose Setup - -Create a `docker-compose.yml` file: +### Docker Compose ```yaml -version: '3.8' - services: proxy: - build: . + build: + context: ./src + dockerfile: SimpleL7Proxy/Dockerfile ports: - "8000:443" environment: - - Host1=https://api1.example.com - - Host2=https://api2.example.com - - Probe_path1=/health - - Probe_path2=/health + - Host1=host=https://api1.example.com;probe=/health + - Host2=host=https://api2.example.com;probe=/health - Workers=20 - - MaxQueueLength=50 - - LogAllRequestHeaders=true + - MaxQueueLength=1000 restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:443/health"] + test: ["CMD", "curl", "-f", "http://localhost:9000/liveness"] interval: 30s timeout: 10s retries: 3 - - # Example backend services for testing - backend1: - image: nginx:alpine - ports: - - "3000:80" - - backend2: - image: nginx:alpine - ports: - - "5000:80" ``` -Run with Docker Compose: ```bash -docker-compose up -d +docker compose up -d ``` -## Deploy to Azure Container Apps +> [!TIP] +> **Troubleshooting:** Health check failures using port 443 will always fail — the probe server runs on port **9000**. Use `http://localhost:9000/liveness`. + +--- + +## Deploying to Azure Container Apps with AZD + +This is the recommended path for provisioning all required Azure resources (ACR, Container Apps environment, managed identity) in one step. ### Prerequisites -- Cloned repository -- Docker installed -- Azure CLI installed and logged in -- Azure subscription with appropriate permissions -### Step 1: Set Environment Variables +- [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Azure Developer CLI (azd)](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) +- Docker + +### Step 1 — Setup +**Linux/macOS** ```bash -# Configure your deployment settings -export ACR= # Without .azurecr.io -export GROUP=simplel7proxyg -export ACENV=simplel7proxyenv -export ACANAME=simplel7proxy -export LOCATION=eastus +chmod +x ./.azure/setup.sh +./.azure/setup.sh +``` + +**Windows** +```powershell +.\.azure\setup.ps1 ``` -### Step 2: Build and Push to Azure Container Registry +The setup script asks for: +1. **Deployment scenario** — choose one: + - `local-proxy-public-apim` — proxy runs locally, backends on public APIM + - `aca-proxy-public-apim` — proxy deployed as ACA, backends on public APIM + - `vnet-proxy-deployment` — proxy inside a VNet +2. **Environment template** (optional, see table below) + +### Step 2 — Provision ```bash -# Login to your Azure Container Registry -az acr login --name $ACR.azurecr.io +azd provision +``` + +### Step 3 — Build and Deploy + +**Linux/macOS** +```bash +chmod +x ./.azure/deploy.sh +./.azure/deploy.sh +``` + +**Windows** +```powershell +.\.azure\deploy.ps1 +``` + +The deploy script builds the image, pushes it to the provisioned ACR, and updates the Container App. It reads all variables from `azd env get-values`. + +### Environment Templates + +Templates live in `.azure/env-templates/`. Apply one during setup or when prompted by `deploy.sh`. -# Build and tag the image -docker build -t $ACR.azurecr.io/simplel7proxy:v1 -f Dockerfile . +| Template | Best for | +|----------|----------| +| Standard Production | Balanced performance and cost — good default | +| High Performance | Maximum throughput, higher worker counts | +| Cost Optimized | Minimal resource usage | +| High Availability | Multiple backends, aggressive failover | +| Local Development | `dotnet run` on localhost backends | +| Container Development | Containerized dev/test scenarios | -# Push the image to ACR -docker push $ACR.azurecr.io/simplel7proxy:v1 +--- + +## Deploying to Azure Container Apps Manually (CLI) + +Use this path when you already have an ACR and Container Apps environment. + +### Step 1 — Set variables + +```bash +export ACR= # Without .azurecr.io +export GROUP= +export ACENV= +export ACANAME= +export LOCATION=eastus ``` -### Step 3: Get ACR Credentials +### Step 2 — Build and push ```bash -# Retrieve ACR credentials -ACR_CREDENTIALS=$(az acr credential show --name $ACR) -export ACR_USERNAME=$(echo $ACR_CREDENTIALS | jq -r '.username') -export ACR_PASSWORD=$(echo $ACR_CREDENTIALS | jq -r '.passwords[0].value') +# Build from src/ directory +cd src +docker build -t $ACR.azurecr.io/simple-l7-proxy:latest -f SimpleL7Proxy/Dockerfile . +cd .. + +az acr login --name $ACR +docker push $ACR.azurecr.io/simple-l7-proxy:latest ``` -### Step 4: Create Azure Resources +### Step 3 — Create resources ```bash -# Create resource group az group create --name $GROUP --location $LOCATION -# Create Container Apps environment az containerapp env create \ --name $ACENV \ --resource-group $GROUP \ --location $LOCATION ``` -### Step 5: Deploy Container App +### Step 4 — Deploy using YAML + +A ready-to-use YAML template is at `deployment/containerapp-single.yaml`. Edit the placeholder values, then: -#### Basic Deployment ```bash az containerapp create \ --name $ACANAME \ --resource-group $GROUP \ - --environment $ACENV \ - --image $ACR.azurecr.io/simplel7proxy:v1 \ - --target-port 443 \ - --ingress external \ - --registry-server $ACR.azurecr.io \ - --registry-username $ACR_USERNAME \ - --registry-password $ACR_PASSWORD \ - --env-vars \ - Host1=https://api1.example.com \ - Host2=https://api2.example.com \ - Workers=15 \ - MaxQueueLength=30 \ - LogAllRequestHeaders=true \ - --query properties.configuration.ingress.fqdn + --yaml deployment/containerapp-single.yaml ``` -#### Production Deployment with Scaling +Or deploy inline with minimal settings: + ```bash az containerapp create \ --name $ACANAME \ --resource-group $GROUP \ --environment $ACENV \ - --image $ACR.azurecr.io/simplel7proxy:v1 \ + --image $ACR.azurecr.io/simple-l7-proxy:latest \ --target-port 443 \ --ingress external \ --registry-server $ACR.azurecr.io \ - --registry-username $ACR_USERNAME \ - --registry-password $ACR_PASSWORD \ - --min-replicas 2 \ - --max-replicas 10 \ - --cpu 1.0 \ - --memory 2Gi \ + --registry-identity system \ + --min-replicas 2 --max-replicas 10 \ + --cpu 1.0 --memory 2Gi \ --env-vars \ - Host1=https://api1.example.com \ - Host2=https://api2.example.com \ - Host3=https://api3.example.com \ - Probe_path1=/health \ - Probe_path2=/health \ - Probe_path3=/health \ - Workers=20 \ - MaxQueueLength=50 \ - PollInterval=10000 \ - Timeout=5000 \ - APPINSIGHTS_CONNECTIONSTRING="your-connection-string" \ + "Host1=host=https://api1.example.com;probe=/health" \ + "Host2=host=https://api2.example.com;probe=/health" \ + "Workers=20" \ + "MaxQueueLength=1000" \ + "Timeout=5000" \ + "APPINSIGHTS_CONNECTIONSTRING=your-connection-string" \ --query properties.configuration.ingress.fqdn ``` -### Step 6: Configure Custom Domain (Optional) +> [!NOTE] +> Use `--registry-identity system` (managed identity) instead of `--registry-username`/`--registry-password` to avoid storing credentials. Grant the Container App's system identity `AcrPull` on the registry. -```bash -# Add custom domain -az containerapp hostname add \ - --hostname "proxy.yourdomain.com" \ - --name $ACANAME \ - --resource-group $GROUP +--- -# Bind SSL certificate -az containerapp ssl upload \ - --hostname "proxy.yourdomain.com" \ - --name $ACANAME \ - --resource-group $GROUP \ - --certificate-file "path/to/certificate.pfx" \ - --password "certificate-password" +## Health Probe Configuration + +The container exposes a built-in probe server on **port 9000**. Configure these in your Container App YAML: + +```yaml +probes: + - type: Liveness + httpGet: + path: /liveness + port: 9000 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + - type: Readiness + httpGet: + path: /readiness + port: 9000 + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 3 + - type: Startup + httpGet: + path: /startup + port: 9000 + scheme: HTTP + initialDelaySeconds: 0 + periodSeconds: 5 + failureThreshold: 30 ``` -## Container Configuration Best Practices +--- -### Environment Variables -- Use Azure Key Vault references for sensitive data -- Set appropriate resource limits based on expected load -- Enable health checks for better reliability +## Sidecar Deployment (HealthProbe as separate container) -### Logging -- Configure Application Insights for production monitoring -- Use structured logging for better observability -- Set appropriate log levels to avoid noise +For deployments where the health probe runs as a dedicated sidecar container alongside the proxy in the same Container App revision, see [SIDECAR_DEPLOYMENT.md](SIDECAR_DEPLOYMENT.md). -### Security -- Use managed identity when possible -- Limit container permissions -- Regular security updates for base images +--- -### Performance -- Set appropriate CPU and memory limits -- Configure auto-scaling based on metrics -- Use multiple replicas for high availability +## Updating the Container -## Monitoring and Troubleshooting +### Rolling update -### View Container Logs ```bash -# View real-time logs -az containerapp logs show \ - --name $ACANAME \ - --resource-group $GROUP \ - --follow +cd src +docker build -t $ACR.azurecr.io/simple-l7-proxy:v2 -f SimpleL7Proxy/Dockerfile . +docker push $ACR.azurecr.io/simple-l7-proxy:v2 +cd .. -# View recent logs -az containerapp logs show \ +az containerapp update \ --name $ACANAME \ --resource-group $GROUP \ - --tail 100 + --image $ACR.azurecr.io/simple-l7-proxy:v2 ``` -### Check Container Status +### Blue-green deployment + ```bash -# Get container app details -az containerapp show \ +# Send 0% traffic to the new revision initially +az containerapp revision copy \ --name $ACANAME \ --resource-group $GROUP \ - --query properties.configuration.ingress.fqdn + --image $ACR.azurecr.io/simple-l7-proxy:v2 -# Check revision status -az containerapp revision list \ - --name $ACANAME \ - --resource-group $GROUP -``` +# Gradually shift traffic +az containerapp ingress traffic set \ + --name $ACANAME --resource-group $GROUP \ + --revision-weight latest=50 previous=50 -## Deploy to Container Apps via a GitHub Action +# Complete the switch +az containerapp ingress traffic set \ + --name $ACANAME --resource-group $GROUP \ + --revision-weight latest=100 +``` -You can create a GitHub workflow to deploy this code to an Azure container app. You can follow the step by step instruction from a similar project in the following video: +--- -[![Video Title](https://i.ytimg.com/vi/-KojzBMM2ic/hqdefault.jpg)](https://www.youtube.com/watch?v=-KojzBMM2ic "How to Create a Github Action to Deploy to Azure Container Apps") +## Monitoring and Troubleshooting +### View logs -### Common Issues and Solutions +```bash +# Stream live logs +az containerapp logs show \ + --name $ACANAME --resource-group $GROUP --follow -1. **Container fails to start**: Check environment variables and image availability -2. **503 Service Unavailable**: Verify backend hosts are accessible from container -3. **SSL/TLS issues**: Ensure proper certificate configuration -4. **High memory usage**: Adjust worker count and queue length settings +# Recent logs +az containerapp logs show \ + --name $ACANAME --resource-group $GROUP --tail 100 +``` -## Updating the Container +### Check revision status -### Rolling Update ```bash -# Build new version -docker build -t $ACR.azurecr.io/simplel7proxy:v2 -f Dockerfile . -docker push $ACR.azurecr.io/simplel7proxy:v2 - -# Update container app -az containerapp update \ - --name $ACANAME \ - --resource-group $GROUP \ - --image $ACR.azurecr.io/simplel7proxy:v2 +az containerapp revision list \ + --name $ACANAME --resource-group $GROUP -o table ``` -### Blue-Green Deployment -```bash -# Create new revision without traffic -az containerapp revision copy \ - --name $ACANAME \ - --resource-group $GROUP \ - --from-revision latest \ - --image $ACR.azurecr.io/simplel7proxy:v2 +### Common issues -# Gradually shift traffic -az containerapp ingress traffic set \ - --name $ACANAME \ - --resource-group $GROUP \ - --revision-weight latest=50 previous=50 +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Container fails to start | Missing required env var or bad image | Check `az containerapp logs show` | +| `503 Service Unavailable` | All circuit breakers OPEN | Verify backend hosts are reachable from inside ACA | +| Probe failures, container cycling | Wrong probe port | Ensure probes target port **9000**, not 443 | +| `docker build` fails with `COPY Shared/` error | Built from wrong directory | Run `docker build` from `src/`, not repo root | -# Complete the switch -az containerapp ingress traffic set \ - --name $ACANAME \ - --resource-group $GROUP \ - --revision-weight latest=100 -``` +--- + +## Deploy via GitHub Actions + +[![Deploy to Azure Container Apps](https://i.ytimg.com/vi/-KojzBMM2ic/hqdefault.jpg)](https://www.youtube.com/watch?v=-KojzBMM2ic "How to Create a Github Action to Deploy to Azure Container Apps") + +--- + +## Related Documentation + +- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) — Host connection string format including probe paths +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — All environment variables +- [HEALTH_CHECKING.md](HEALTH_CHECKING.md) — Health probe internals +- [OBSERVABILITY.md](OBSERVABILITY.md) — Application Insights setup diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fa53f529..98998412 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,268 +1,179 @@ # Development and Testing -This document provides guidance for setting up SimpleL7Proxy for development and testing purposes. +Get SimpleL7Proxy running locally in under five minutes using the automated setup script, or configure it manually with the steps below. -## Local Development Setup +> **TL;DR** +> - **Fastest path:** run `.azure/local-setup.sh` — it generates your environment file interactively. +> - **Minimum config:** set `Host1`, `Port`, and `Workers`; everything else has a working default. +> - **Debugging:** add `LogAllRequestHeaders=true` and `LogAllResponseHeaders=true` to see all headers in the log. -### Automated Setup (Recommended) +--- -SimpleL7Proxy includes a setup script that configures the proxy to run on your local machine using an interactive wizard. +## Reference — Key Development Settings -```bash -# Navigate to the .azure directory and run the setup script -cd .azure -./local-setup.sh -``` - -The script will guide you through connecting to backends (Mock or Real) and generating a local environment file. +| Variable | Default | Description | +|----------|---------|-------------| +| `Port` | `80` | Proxy listen port | +| `Host1` / `Host2` | — | Backend URLs (at least one required) | +| `Workers` | `10` | Concurrent worker count | +| `Timeout` | `1200000` ms | Per-host request timeout | +| `IgnoreSSLCert` | `false` | Skip TLS verification (dev only) | +| `LogAllRequestHeaders` | `false` | Log every inbound header | +| `LogAllResponseHeaders` | `false` | Log every outbound header | +| `LOGFILE_NAME` | `eventslog.json` | Path for event log output | +| `MaxQueueLength` | `1000` | Max queued requests before 429 | +| `AZURE_APPCONFIG_ENDPOINT` | — | App Configuration endpoint URL | +| `AZURE_APPCONFIG_LABEL` | *(none)* | Label filter (use `dev` for local work) | +| `AZURE_APPCONFIG_REFRESH_SECONDS` | `30` | Sentinel poll interval in seconds | -### Manual Setup +--- -If you prefer to configure the environment manually: +## Setting Up Locally -### Prerequisites -- .NET SDK 9.0 or later -- Git (for cloning the repository) -- Optional: Docker (for containerized testing) +**Rule: Use the automated script for the fastest, error-free setup; fall back to manual steps only if the script cannot reach your backends.** -### Quick Start for Development - -1. **Clone the repository:** ```bash -git clone https://github.com/your-org/SimpleL7Proxy.git -cd SimpleL7Proxy +cd .azure +./local-setup.sh # interactive wizard → generates .env file ``` -2. **Set up environment variables:** +> [!NOTE] +> **Prerequisites:** .NET SDK 10.0+, Git. Docker is optional (for containerized testing). + +> [!TIP] +> **Troubleshooting:** If the script fails with a permission error, run `chmod +x .azure/local-setup.sh` first. + +### Manual setup + ```bash -# Essential development configuration export Port=8080 export Host1=http://localhost:3000 -export Host2=http://localhost:5000 -export Timeout=5000 -export LogAllRequestHeaders=true -export LogAllResponseHeaders=true -export LOGFILE=dev-events.log -export Workers=5 -export IgnoreSSLCert=true -``` - -3. **Run the proxy:** -```bash dotnet run ``` -### Testing with Mock Backends +### Using Azure App Configuration in dev mode -For testing without real backend services, you can use simple HTTP servers: +**All settings (Warm and Cold) are loaded from App Configuration at startup. Warm settings are then re-applied every `AZURE_APPCONFIG_REFRESH_SECONDS` seconds via the sentinel key — no restart needed for those changes.** -#### Using Node.js (if available) ```bash -# Terminal 1 - Mock backend on port 3000 -npx http-server -p 3000 -c-1 - -# Terminal 2 - Mock backend on port 5000 -npx http-server -p 5000 -c-1 - -# Terminal 3 - Run the proxy +export AZURE_APPCONFIG_ENDPOINT=https://nvm2-tc26-appcfg.azconfig.io +export AZURE_APPCONFIG_LABEL=dev +export AZURE_APPCONFIG_REFRESH_SECONDS=30 dotnet run ``` -#### Using Python (if available) -```bash -# Terminal 1 - Mock backend on port 3000 -python -m http.server 3000 - -# Terminal 2 - Mock backend on port 5000 -python -m http.server 5000 +Before running, assign both roles to your developer account on the App Configuration resource: -# Terminal 3 - Run the proxy -dotnet run -``` - -### Development Configuration +```bash +APPCONFIG_ID=$(az appconfig show --name nvm2-tc26-appcfg --query id -o tsv) +USER_ID=$(az ad signed-in-user show --query id -o tsv) -Create a local `appsettings.Development.json` file: +az role assignment create --role "App Configuration Data Reader" \ + --assignee $USER_ID --scope $APPCONFIG_ID -```json -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "ProxyOptions": { - "Port": 8080, - "Host1": "http://localhost:3000", - "Host2": "http://localhost:5000", - "Workers": 5, - "MaxQueueLength": 10, - "LogAllRequestHeaders": true, - "LogAllResponseHeaders": true, - "LogProbes": true, - "IgnoreSSLCert": true, - "PollInterval": 5000, - "Timeout": 3000 - } -} +az role assignment create --role "App Configuration Data Owner" \ + --assignee $USER_ID --scope $APPCONFIG_ID ``` -## Testing Scenarios +> [!NOTE] +> **Data Reader** is sufficient if you only read settings. **Data Owner** is required if you also update keys or bump the sentinel from the CLI during development. -### Load Testing +> [!TIP] +> **Troubleshooting:** If the proxy fails to connect, run `az login` to refresh your developer credentials — the SDK uses the default Azure credential chain. Role assignments can take a few minutes to propagate. -Use tools like `wrk` or `curl` to test the proxy: - -```bash -# Simple load test with curl -for i in {1..100}; do - curl -H "X-Test-Request: $i" http://localhost:8080/test & -done -wait - -# Using wrk (if installed) -wrk -t4 -c100 -d30s http://localhost:8080/ -``` +--- -### Priority Testing +## Running with Mock Backends -Test priority-based routing: +**Rule: Use the included null server for the fastest mock backend; it requires only Python and no extra dependencies.** ```bash -# High priority request -curl -H "S7PPriorityKey: 12345" http://localhost:8080/high-priority +# Terminal 1 — start the included mock backend +cd test/nullserver/Python +python streamserver.py -# Normal priority request -curl http://localhost:8080/normal-priority - -# With custom TTL -curl -H "S7PTTL: 60" http://localhost:8080/with-ttl +# Terminal 2 — start the proxy pointing at it +export Port=8080 +export Host1=http://localhost:3000 +dotnet run ``` -### Async Mode Testing +> [!NOTE] +> `Host1` must be reachable before the proxy starts or the initial health check will mark it as OPEN. -When testing async functionality: +> [!TIP] +> **Troubleshooting:** Run `curl http://localhost:3000/` to confirm the mock backend is up before starting the proxy. -```bash -# Set up async environment variables -export AsyncModeEnabled=true -export AsyncBlobStorageConnectionString="your-blob-connection-string" -export AsyncSBConnectionString="your-servicebus-connection-string" - -# Test async request -curl -H "AsyncMode: true" -H "X-UserID: test-user" http://localhost:8080/async-test -``` +--- -## Debugging +## Testing Scenarios -### Enable Debug Logging +**Rule: Use targeted `curl` commands to exercise priority, TTL, and async paths individually before running load tests.** ```bash -export LogAllRequestHeaders=true -export LogAllResponseHeaders=true -export LogProbes=true -export LOGFILE=debug.log -``` +# Priority + TTL override +curl -H "S7PPriorityKey: 12345" -H "S7PTTL: 60" http://localhost:8080/test -### Request-Level Debugging - -Add the debug header to individual requests: +# Async mode +curl -H "AsyncMode: true" -H "X-UserID: user1" http://localhost:8080/async-test +``` ```bash -curl -H "S7PDEBUG: true" http://localhost:8080/debug-request +# Load test (curl loop) +for i in {1..100}; do curl -s http://localhost:8080/test & done; wait ``` -### Health Check Testing +> [!NOTE] +> Async mode also requires `AsyncBlobStorageConnectionString` and `AsyncSBConnectionString` to be set. -Test backend health monitoring: +> [!WARNING] +> **Error:** A `429` response during load testing means `MaxQueueLength` was reached — increase it or reduce concurrency. -```bash -# Check if proxy is detecting backend health -curl http://localhost:8080/health - -# Test failover by stopping one backend -# Stop backend on port 3000, then test -curl http://localhost:8080/test-failover -``` +--- ## Container Development -### Build and Test Locally +**Rule: Build the image locally and inject environment variables at `docker run` time; do not bake secrets into the image.** ```bash -# Build the container docker build -t proxy-dev -f Dockerfile . -# Run with development configuration docker run -p 8080:443 \ - -e "Host1=http://host.docker.internal:3000" \ - -e "Host2=http://host.docker.internal:5000" \ - -e "LogAllRequestHeaders=true" \ - -e "Workers=5" \ - -e "LOGFILE=/tmp/events.log" \ + -e Host1=http://host.docker.internal:3000 \ + -e Host2=http://host.docker.internal:5000 \ + -e LogAllRequestHeaders=true \ + -e Workers=5 \ proxy-dev ``` -### Docker Compose for Development - -Create a `docker-compose.dev.yml`: - -```yaml -version: '3.8' -services: - proxy: - build: . - ports: - - "8080:443" - environment: - - Host1=http://backend1:3000 - - Host2=http://backend2:5000 - - LogAllRequestHeaders=true - - Workers=5 - depends_on: - - backend1 - - backend2 - - backend1: - image: nginx:alpine - ports: - - "3000:80" - - backend2: - image: nginx:alpine - ports: - - "5000:80" -``` +> [!NOTE] +> Use `host.docker.internal` to reach mock backends running on the host from inside the container. -Run with: `docker-compose -f docker-compose.dev.yml up` +> [!TIP] +> **Troubleshooting:** If the container exits immediately, check logs with `docker logs ` — a missing `Host1` value is the most common cause. -## Common Development Issues +--- -### Port Conflicts -If port 8080 is in use, change the port: -```bash -export Port=8081 -``` +## Worked Example — Full Local Stack -### SSL Certificate Issues -For development with self-signed certificates: -```bash -export IgnoreSSLCert=true -``` +> **Goal:** Proxy on port 8080 with two nginx mock backends, header logging enabled, 10-worker pool. -### Backend Connection Issues -Ensure backend services are running and accessible: -```bash -curl http://localhost:3000/health -curl http://localhost:5000/health -``` +| Step | Command | Expected result | +|------|---------|----------------| +| Start backend 1 | `python -m http.server 3000` | Listening on :3000 | +| Start backend 2 | `python -m http.server 5000` | Listening on :5000 | +| Export config | `export Port=8080 Host1=http://localhost:3000 Host2=http://localhost:5000 Workers=10 LogAllRequestHeaders=true` | — | +| Start proxy | `dotnet run` | `Listening on port 8080` | +| Smoke test | `curl -v http://localhost:8080/` | `200 OK` from backend 1 or 2 | +| Check failover | Stop backend 1; `curl http://localhost:8080/` | `200 OK` routed to backend 2 | -## IDE Configuration +**Stopping backend 1 while the proxy is running triggers circuit-breaker logic — subsequent requests route automatically to backend 2.** + +--- -### Visual Studio Code +## IDE Configuration -Create `.vscode/launch.json`: +Add `.vscode/launch.json` to start the proxy from VS Code with F5: ```json { @@ -288,3 +199,12 @@ Create `.vscode/launch.json`: ] } ``` + +--- + +## Related Documentation + +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — All environment variables and config keys +- [LOAD_BALANCING.md](LOAD_BALANCING.md) — Backend selection and retry settings +- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) — Health check and failover configuration +- [OBSERVABILITY.md](OBSERVABILITY.md) — Logging, metrics, and tracing diff --git a/docs/LOAD_BALANCING.md b/docs/LOAD_BALANCING.md index 7fd68b55..9fc619cb 100644 --- a/docs/LOAD_BALANCING.md +++ b/docs/LOAD_BALANCING.md @@ -1,243 +1,164 @@ # Load Balancing & Backend Selection -SimpleL7Proxy uses a sophisticated multi-stage algorithm to select the optimal backend for each request. This document explains how backends are chosen, filtered, and iterated. +The proxy selects backends through a three-stage pipeline on every request: filter by path → order by load-balance mode → gate by circuit breaker. -## Algorithm Overview - -``` -REQUEST ARRIVES - │ - ▼ -┌──────────────────────────┐ -│ 1. Filter hosts by path │ → Specific path hosts OR catch-all hosts -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 2. Create Iterator │ → RoundRobin / Latency / Random -│ (LoadBalanceMode) │ -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ 3. FOR EACH HOST: │ -│ ├─ Circuit breaker OK?│ → Skip if OPEN -│ ├─ TTL not expired? │ → 412 if expired -│ ├─ Send request │ -│ └─ Success? → RETURN │ -└──────────────────────────┘ - │ - ▼ (all hosts failed) -┌──────────────────────────┐ -│ 429s collected? → Requeue│ -│ Else → 503 Service │ -│ Unavailable │ -└──────────────────────────┘ -``` +> **TL;DR** +> - **Specific-path hosts always win** — if any configured host matches the request path, catch-all hosts are never tried. +> - **`LoadBalanceMode`** controls host order (round-robin / latency / random); **`IterationMode`** controls how many attempts are made. +> - **A `429` with `S7PREQUEUE`** requeues the request; any other non-2xx advances to the next host; TTL expiry stops iteration with `412`. --- -## Stage 1: Path-Based Host Filtering +## Reference — All Settings -Before load balancing, hosts are filtered based on the request path. The proxy maintains two categories of hosts: - -| Category | Description | Example Path | -|----------|-------------|--------------| -| **Specific Path Hosts** | Hosts with explicit path prefixes | `/api/v1/*`, `/chat/*`, `/embeddings` | -| **Catch-All Hosts** | Hosts that handle any path | `/` or `/*` | - -### Matching Rules +| Variable | Default | Description | +|----------|---------|-------------| +| `LoadBalanceMode` | `random` | Host ordering: `roundrobin`, `latency`, or `random` | +| `IterationMode` | `SinglePass` | Retry strategy: `SinglePass` or `MultiPass` | +| `MaxAttempts` | `30` | Max total attempts (MultiPass only) | +| `UseSharedIterators` | `false` | Share iterator state across concurrent requests to the same path | +| `SharedIteratorTTLSeconds` | `300` | Seconds before an unused shared iterator is discarded | +| `SharedIteratorCleanupIntervalSeconds` | `60` | How often expired shared iterators are cleaned up | -1. **Specific paths take precedence**: If any host's path matches the request, only those hosts are used. -2. **Path prefix is stripped by default**: When forwarding to a matched host, the matching prefix is removed from the request path. This can be disabled per-host with `stripprefix=false` (see [BACKEND_HOSTS.md](BACKEND_HOSTS.md#controlling-path-prefix-stripping)). -3. **Catch-all fallback**: If no specific path matches, catch-all hosts are used with the original path. +--- -### Example +## Request Flow ``` -Configured Hosts: - Host1: path=/api/v1 → https://api-v1.internal - Host2: path=/api/v2 → https://api-v2.internal - Host3: path=/ → https://default.internal - -Request: GET /api/v1/users/123 - -Result (stripprefix=true, default): - - Matches Host1 (specific path /api/v1) - - Forwarded as: GET /users/123 to https://api-v1.internal - - Host2 and Host3 are NOT considered - -Result (if Host1 had stripprefix=false): - - Matches Host1 (specific path /api/v1) - - Forwarded as: GET /api/v1/users/123 to https://api-v1.internal - - Original path is preserved +Request arrives + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ 1. PATH FILTER │ +│ Specific-path hosts match? ──Yes──► use them │ +│ ──No───► use catch-all│ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ 2. ITERATOR (LoadBalanceMode) │ +│ roundrobin → global counter order │ +│ latency → sorted lowest avg latency first │ +│ random → shuffled each request │ +└─────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ 3. FOR EACH HOST (IterationMode / MaxAttempts) │ +│ circuit OPEN? ──Yes──► skip, next host │ +│ TTL expired? ──Yes──► 412, stop │ +│ send request │ +│ 2xx? ──Yes──► return to client ✓ │ +│ 429+S7PREQUEUE ──────► collect, try next host │ +│ other failure ──────► try next host │ +└─────────────────────────────────────────────────────┘ + │ + ▼ (all hosts exhausted) + All 429+S7PREQUEUE? → requeue with shortest retry-after + Else → 503 Service Unavailable ``` ---- - -## Stage 2: Load Balance Mode +**The circuit-breaker gate means an OPEN host is never attempted, so `MaxAttempts` counts only hosts actually tried.** -Once hosts are filtered, an iterator is created based on the configured `LoadBalanceMode`. - -### Available Modes +--- -| Mode | Environment Variable | Behavior | -|------|---------------------|----------| -| **Round Robin** | `LoadBalanceMode=roundrobin` | Uses a **global counter** shared across all workers. Each request gets the "next" host, ensuring fair distribution. | -| **Latency** | `LoadBalanceMode=latency` | Hosts are **sorted by average latency** (lowest first). Fastest hosts are tried first. | -| **Random** | `LoadBalanceMode=random` | Hosts are **shuffled randomly** for each request. All hosts are tried but in unpredictable order. | +## Selecting a Backend -### Configuration +**Rule: The path filter runs first; within the matched set, `LoadBalanceMode` determines which host is tried first.** ```bash -# Default is random -LoadBalanceMode=latency +LoadBalanceMode=latency # try fastest host first +# Hosts sorted by average response time, lowest first ``` -### When to Use Each Mode - -| Scenario | Recommended Mode | -|----------|------------------| -| All backends have equal capacity | `roundrobin` | -| Backends have different response times | `latency` | -| Want to avoid predictable patterns | `random` | -| Testing/debugging specific hosts | `roundrobin` with single host | +| Mode | Best for | +|------|----------| +| `roundrobin` | Homogeneous backends; fair distribution | +| `latency` | Backends with measurably different response times | +| `random` | Avoiding predictable traffic patterns | ---- +> [!NOTE] +> **Default:** `LoadBalanceMode=random`. Path prefix is stripped before forwarding unless `stripprefix=false` is set on the host (see [BACKEND_HOSTS.md](BACKEND_HOSTS.md#controlling-path-prefix-stripping)). -## Stage 3: Iteration Mode +> [!TIP] +> **Troubleshooting:** If a specific host is never reached, verify its configured path prefix matches the inbound request path; a mismatch silently excludes it from the candidate set. -The iteration mode controls how many times the proxy attempts to reach backends before giving up. +--- -| Mode | Environment Variable | Behavior | -|------|---------------------|----------| -| **SinglePass** | `IterationMode=SinglePass` | Try each matching host **once**. If all fail → error. | -| **MultiPass** | `IterationMode=MultiPass` | Retry across all hosts up to `MaxAttempts` total. Will cycle through hosts multiple times. | +## Retrying Across Backends -### Configuration +**Rule: `SinglePass` tries each host once; `MultiPass` cycles through all hosts up to `MaxAttempts` total.** ```bash -IterationMode=SinglePass -MaxAttempts=30 # Only used in MultiPass mode -``` - -### Example: MultiPass with 3 Hosts - -``` -Hosts: [A, B, C] -MaxAttempts: 7 - -Attempt 1: Host A → 503 (fail) -Attempt 2: Host B → 503 (fail) -Attempt 3: Host C → 503 (fail) -Attempt 4: Host A → 503 (fail) # Second pass begins -Attempt 5: Host B → 503 (fail) -Attempt 6: Host C → 503 (fail) -Attempt 7: Host A → 200 (success!) ✓ +IterationMode=MultiPass +MaxAttempts=7 +# 3 hosts → up to 2 full passes + 1 extra attempt ``` ---- - -## Stage 4: Shared vs Per-Request Iterators - -Control whether concurrent requests share iterator state or each get their own. +> [!NOTE] +> **Default:** `IterationMode=SinglePass`. `MaxAttempts` is ignored in SinglePass mode. -| Setting | Behavior | -|---------|----------| -| `UseSharedIterators=false` (default) | Each request gets its **own iterator**. Simple but may cause uneven distribution under high concurrency. | -| `UseSharedIterators=true` | Requests to the **same path** share an iterator. Ensures fair distribution across concurrent requests. | +> [!TIP] +> **Troubleshooting:** Seeing more failures than expected? A low `MaxAttempts` combined with many OPEN circuits can exhaust the attempt budget before a healthy host is reached — check circuit-breaker state with `LogHeaders=true`. -### When to Use Shared Iterators +### Shared Iterators -- **High concurrency**: Many simultaneous requests to the same path -- **Fair distribution required**: Need to ensure all backends get equal traffic -- **Round-robin mode**: Most beneficial when combined with `roundrobin` - -### Configuration - -```bash -UseSharedIterators=true -SharedIteratorTTLSeconds=300 # How long to keep unused iterators -SharedIteratorCleanupIntervalSeconds=60 # Cleanup frequency -``` +Set `UseSharedIterators=true` when many concurrent requests target the same path and you need strict round-robin fairness across them. Each path then maintains a single shared counter instead of per-request counters. --- -## Stage 5: Per-Host Circuit Breaker Check - -Before sending a request to each host, the circuit breaker status is checked. +## Handling Responses -``` -FOR EACH HOST in iterator: - └─ CheckFailedStatus() ──[OPEN]──► SKIP (continue to next host) - └─[CLOSED]──► Proceed with request -``` +**Rule: Only `2xx` returns to the client; everything else either retries, requeues, or stops.** -- **OPEN circuit**: Host is skipped immediately, no request sent -- **CLOSED circuit**: Request is attempted -- **All circuits OPEN**: Returns `503 Service Unavailable` +| Response | Action | +|----------|--------| +| `2xx` | Return to client | +| `3xx`, `404`, `5xx` | Try next host | +| `429` + `S7PREQUEUE` header | Collect; try next host. If **all** hosts return this, requeue with shortest `retry-after`. | +| `412` Precondition Failed | TTL expired — stop, no further retries | +| All hosts exhausted (non-429) | `503 Service Unavailable` | -See [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) for detailed circuit breaker configuration. +> [!WARNING] +> **Error:** `412` means the request's TTL expired during iteration. Increase `DefaultTTLSecs` or reduce backend latency — adding more `MaxAttempts` will not help once TTL is gone. --- -## Response Handling - -After sending a request, the response determines the next action: +## Worked Example -| Response | Action | -|----------|--------| -| `2xx` (Success) | Return response to client ✓ | -| `3xx`, `404`, `5xx` | Try next host | -| `429` with `S7PREQUEUE` header | Collect for potential requeue, try next host | -| `412` (Precondition Failed) | Request TTL expired, stop iteration | +> **Setup:** 3 hosts (`A avg 200 ms`, `B avg 80 ms`, `C avg 150 ms`), `LoadBalanceMode=latency`, `IterationMode=MultiPass`, `MaxAttempts=5`. -### Requeue Behavior +| Attempt | Host tried (latency order) | Response | Action | +|---------|---------------------------|----------|--------| +| 1 | B (80 ms — fastest) | 503 | try next | +| 2 | C (150 ms) | circuit OPEN | skip (no attempt counted) | +| 3 | A (200 ms) | 503 | try next | +| 4 | B (second pass) | 200 | **return to client** | -If all hosts return `429` with the `S7PREQUEUE` header, the request is requeued with a delay based on the shortest `retry-after` value. +**Attempts used: 4 of 5. Host C's open circuit was skipped without spending an attempt budget entry.** --- ## Monitoring & Diagnostics -### Logging - -Enable debug logging to see backend selection: +Enable header logging to trace backend selection: ```bash LogHeaders=true ``` -Log output includes: -- Which hosts matched the path -- Which host was selected -- Circuit breaker status for skipped hosts -- Attempt count and duration - -### Metrics - -Key metrics to monitor: -- `BackendAttempts`: Number of hosts tried per request -- `Backend-Host`: Which host ultimately served the request -- `Total-Latency`: End-to-end request duration - ---- - -## Configuration Summary +Key response headers to inspect: -| Variable | Default | Description | -|----------|---------|-------------| -| `LoadBalanceMode` | `random` | Algorithm: `roundrobin`, `latency`, or `random` | -| `IterationMode` | `SinglePass` | Retry strategy: `SinglePass` or `MultiPass` | -| `MaxAttempts` | `30` | Max total attempts (MultiPass only) | -| `UseSharedIterators` | `false` | Share iterators across concurrent requests | -| `SharedIteratorTTLSeconds` | `300` | TTL for unused shared iterators | -| `SharedIteratorCleanupIntervalSeconds` | `60` | Cleanup interval for expired iterators | +| Header | Meaning | +|--------|---------| +| `Backend-Host` | Host that ultimately served the request | +| `BackendAttempts` | Number of hosts tried | +| `Total-Latency` | End-to-end request duration | --- ## Related Documentation -- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) - Host configuration and connection strings -- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) - Circuit breaker configuration -- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) - All configuration options +- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) — Host configuration and path prefixes +- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) — Circuit breaker configuration +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — All configuration options diff --git a/docs/REQUEST_VALIDATION.md b/docs/REQUEST_VALIDATION.md index bd639695..53efe82b 100644 --- a/docs/REQUEST_VALIDATION.md +++ b/docs/REQUEST_VALIDATION.md @@ -1,9 +1,251 @@ # Request Validation -SimpleL7Proxy can validate incoming requests against user profiles, allowing you to: -- **Control access** to proxy features on a per-user basis -- **Apply custom priority levels** based on user identity -- **Enable async processing** only for authorized users -- **Add user-specific headers** automatically +Reject or sanitize incoming requests before they enter the queue — return **417** if a required header is missing or fails a value rule, **403** if an App ID is not in the allowlist. -See the **[User Profiles Guide](USER_PROFILES.md)** for complete configuration details and examples. +**TL;DR** +- Set `RequiredHeaders` to demand specific headers on every request; missing → 417. +- Set `ValidateHeaders` to enforce per-user value allowlists; mismatch → 417. +- Set `ValidateAuthAppID=true` + `ValidateAuthAppIDUrl` to block unknown Entra app IDs; unknown → 403. + +## Reference Table + +All settings are **Warm** — changes apply instantly without a container restart. + +| Setting | Type | Default | Description | +|---|---|---|---| +| `RequiredHeaders` | `List` | `[]` | Headers that must be non-empty; first missing header → 417. | +| `DisallowedHeaders` | `List` | `[]` | Headers stripped from the request before forwarding to the backend. | +| `ValidateHeaders` | `Dictionary` | `{}` | Rules: `SourceHeader=AllowlistHeader`. Value of `SourceHeader` must appear in the comma-separated list in `AllowlistHeader`. Supports `*` suffix for prefix matching. | +| `ValidateAuthAppID` | `bool` | `false` | Enable App ID allowlisting (runs before all other checks). | +| `ValidateAuthAppIDUrl` | `string` | `""` | URL or `file:auth.json` for the App ID allowlist. Requires `UseProfiles=true`. | +| `ValidateAuthAppIDHeader` | `string` | `X-MS-CLIENT-PRINCIPAL-ID` | Request header containing the caller's Entra App ID. | +| `ValidateAuthAppFieldName` | `string` | `authAppID` | JSON field name in the allowlist file that holds the App ID value. | + +> [!NOTE] +> **Auto-population side effect:** Setting `ValidateHeaders=SourceHeader=AllowlistHeader` automatically adds both headers to `RequiredHeaders` **and** adds `AllowlistHeader` to `DisallowedHeaders`. The allowlist header is injected by the user profile service and must not reach the backend. + +## Validation Execution Order + +Validation runs on every non-probe request before it is enqueued. The order is fixed: + +``` +Incoming request + │ + ▼ +[1] ValidateAuthAppID check ──── fail ──► 403 Forbidden (DisallowedAppID) + │ pass + ▼ +[2] Strip DisallowedHeaders (silent — no error returned) + │ + ▼ +[3] User profile lookup ─────── unknown ──► 403 Forbidden (UnknownProfile) + │ found → inject profile headers into request + ▼ +[4] RequiredHeaders check ───── first missing ──► 417 Expectation Failed (IncompleteHeaders) + │ all present + ▼ +[5] ValidateHeaders rules ───── first mismatch ──► 417 Expectation Failed (InvalidHeader) + │ all pass + ▼ + Enqueue → workers → backend +``` + +--- + +## Scenario 1: Require Specific Headers on Every Request + +**The simplest gate: reject any request that doesn't carry a mandatory header.** + +Use `RequiredHeaders` to list header names that must be non-empty. The proxy returns 417 on the first missing one. + +```env +RequiredHeaders=Authorization,X-Correlation-ID +``` + +A request without `Authorization` receives: +``` +HTTP/1.1 417 Expectation Failed +X-S7P-Error: Required header is missing: Authorization +``` + +> [!TIP] +> Use `RequiredHeaders` to reject unauthenticated requests before they consume queue capacity or reach any backend. + +--- + +## Scenario 2: Strip Internal Headers Before Forwarding + +**Prevent internal routing or control headers from leaking to the backend.** + +Use `DisallowedHeaders` to list headers the proxy removes silently before forwarding. The caller sees no error. + +```env +DisallowedHeaders=X-Internal-RouteKey,X-Admin-Override +``` + +Common use: remove allowlist headers that the user profile service injects (see Scenario 3) so the backend never receives them. + +> [!WARNING] +> When you set `ValidateHeaders`, the allowlist header is **automatically** added to `DisallowedHeaders`. Only add headers here manually for headers that have no corresponding `ValidateHeaders` rule. + +--- + +## Scenario 3: Validate Header Values Against a Per-User Allowlist + +**The most powerful pattern: each user has their own allowlist, injected at runtime by the user profile service.** + +`ValidateHeaders` maps a *source header* (sent by the client) to an *allowlist header* (populated from the user's stored profile). The proxy checks that the source header value appears in the comma-separated allowlist. Prefix matching is supported with a trailing `*`. + +### How it works + +1. Client sends `X-Requested-Model: gpt-4o`. +2. User profile service injects `S7PAllowedModels: gpt-4o-mini,gpt-4o` into the request headers (from the user's profile JSON). +3. Proxy checks: is `gpt-4o` in `gpt-4o-mini,gpt-4o`? Yes → pass. +4. Before forwarding, `S7PAllowedModels` is stripped (it is auto-added to `DisallowedHeaders`). + +### Configuration + +```env +ValidateHeaders=X-Requested-Model=S7PAllowedModels + +# This single line automatically adds: +# RequiredHeaders += X-Requested-Model, S7PAllowedModels (auto) +# DisallowedHeaders += S7PAllowedModels (auto) +``` + +**User profile JSON** (served from `UserConfigUrl`): +```json +[ + { + "userId": "alice@contoso.com", + "S7PAllowedModels": "gpt-4o-mini,gpt-4o" + }, + { + "userId": "bob@contoso.com", + "S7PAllowedModels": "gpt-4o-mini" + }, + { + "userId": "admin@contoso.com", + "S7PAllowedModels": "gpt-4*" + } +] +``` + +The `admin` entry uses a wildcard: `gpt-4o`, `gpt-4o-mini`, and `gpt-4-turbo` all match; `gpt-3.5-turbo` does not. + +### Worked example + +| User | Request header | Profile `S7PAllowedModels` | Result | +|---|---|---|---| +| alice@contoso.com | `X-Requested-Model: gpt-4o` | `gpt-4o-mini,gpt-4o` | ✅ 200 — forwarded | +| bob@contoso.com | `X-Requested-Model: gpt-4o` | `gpt-4o-mini` | ❌ 417 — `gpt-4o` not in allowlist | +| carol@contoso.com (no profile) | `X-Requested-Model: gpt-4o` | — | ❌ 403 — unknown profile | +| dave@contoso.com | *(header absent)* | `gpt-4o-mini,gpt-4o` | ❌ 417 — required header missing | +| admin@contoso.com | `X-Requested-Model: gpt-4-turbo` | `gpt-4*` | ✅ 200 — prefix match | + +> [!NOTE] +> Error messages strip an `S7` prefix from the source header name. A rule `S7PModel=S7PAllowedModels` reports `Validation check failed for PModel: `. + +--- + +## Scenario 4: Block Unknown Entra App IDs + +**Allowlist calling applications by their Entra App/Client ID — requests from any unlisted application receive 403.** + +This check executes *first*, before DisallowedHeaders stripping, user profile lookup, and header validation. + +### Configuration + +```env +UseProfiles=true +ValidateAuthAppID=true +ValidateAuthAppIDUrl=file:auth.json +# ValidateAuthAppIDHeader=X-MS-CLIENT-PRINCIPAL-ID ← default (injected by Azure EasyAuth) +# ValidateAuthAppFieldName=authAppID ← default +``` + +**auth.json**: +```json +[ + { "authAppID": "a1b2c3d4-0000-0000-0000-000000000001" }, + { "authAppID": "a1b2c3d4-0000-0000-0000-000000000002" }, + { "authAppID": "a1b2c3d4-0000-0000-0000-000000000003" } +] +``` + +> [!NOTE] +> `ValidateAuthAppIDUrl` is only active when `UseProfiles=true`. The allowlist is loaded on the same background timer as user profiles (`UserConfigRefreshIntervalSecs`, default 300 s). + +### How it works + +Azure Container Apps EasyAuth automatically injects `X-MS-CLIENT-PRINCIPAL-ID` with the caller's Entra App/Client GUID. The proxy performs a case-insensitive dictionary lookup. If the GUID is absent from the list — or the header itself is missing — the request is rejected: + +``` +Request: X-MS-CLIENT-PRINCIPAL-ID: a1b2c3d4-0000-0000-0000-000000000001 + ├── in auth.json → pass → continue + +Request: X-MS-CLIENT-PRINCIPAL-ID: ffffffff-0000-0000-0000-ffffffffffff + └── not in auth.json → 403 Forbidden + X-S7P-Error: Invalid AuthAppID: ffffffff-... +``` + +### Revoking access + +Remove the entry from `auth.json`. The proxy picks up the change on the next refresh cycle without a restart. For zero-downtime revocation, use the soft-delete pattern: add `__DeletedAt` and `__ExpiresAt` timestamps to keep the entry present during the grace period before all in-flight requests drain. + +### Using an HTTP endpoint + +```env +ValidateAuthAppIDUrl=https://config-service.internal/api/allowlisted-appids +``` + +The endpoint must return a JSON array in the same format as `auth.json`. The proxy fetches and atomically swaps the in-memory dictionary on each refresh. + +> [!WARNING] +> If `ValidateAuthAppIDUrl` is temporarily unreachable, the proxy continues using the **last successfully loaded** allowlist. After `UserSoftDeleteTTLMinutes` of continuous failure, the service transitions to a degraded readiness state and Container Apps will eventually stop routing traffic to it. + +--- + +## Combining Multiple Rules + +All mechanisms compose independently. Enable any subset: + +```env +# Gate 1: Only known Entra apps may call the proxy +ValidateAuthAppID=true +ValidateAuthAppIDUrl=file:auth.json + +# Gate 2: Every request must carry a correlation ID +RequiredHeaders=X-Correlation-ID + +# Gate 3: Per-user model allowlist (auto-adds to RequiredHeaders and DisallowedHeaders) +ValidateHeaders=X-Requested-Model=S7PAllowedModels + +# Gate 4: Strip an APIM internal routing header +DisallowedHeaders=X-APIM-Internal-Key +``` + +Combined flow for a fully valid request: + +``` +X-MS-CLIENT-PRINCIPAL-ID → auth.json lookup → pass +X-APIM-Internal-Key → stripped (DisallowedHeaders) +S7PAllowedModels → injected from user profile +X-Correlation-ID → RequiredHeaders check → present → pass +X-Requested-Model → checked against S7PAllowedModels → match → pass +S7PAllowedModels → stripped (auto DisallowedHeaders) + → enqueue → backend +``` + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| All requests get 403 immediately after enabling `ValidateAuthAppID` | Allowlist not yet loaded at startup | Check `[PROFILE] Auth: DEGRADED` in logs; wait for first successful load | +| 417 on a header you know the client sends | Header was stripped by `DisallowedHeaders` before the check | Review auto-added entries from your `ValidateHeaders` rule | +| 403 "User profile not found" even though profile exists | `UserProfileHeader` value doesn't match `UserIDFieldName` in the JSON | Confirm the header name and JSON field name are consistent | +| 417 "required header missing" for the allowlist header (`S7PAllowedModels`) | User profile not found or profile JSON lacks the key | Verify `UserConfigUrl` is reachable and the user ID matches `UserIDFieldName` | +| Allowlist header (`S7PAllowedModels`) appears in backend request | `ValidateHeaders` rule not set; strip is auto-applied only when the rule is active | Set the `ValidateHeaders` rule or add the header to `DisallowedHeaders` manually | +| App ID validation blocks a valid caller after Entra cert rotation | The App/Client GUID changed | Update `auth.json` with the new GUID | diff --git a/docs/RESPONSE_CODES.md b/docs/RESPONSE_CODES.md index 2afee189..d9e25256 100644 --- a/docs/RESPONSE_CODES.md +++ b/docs/RESPONSE_CODES.md @@ -1,25 +1,127 @@ -# Proxy Response Codes and Headers - -## Response Codes - -| Code | Description | -|----- |----------------------------------------------------------------------------------------------| -| 200 | Success | -| 400 | Bad Request (Issue with the HTTP request format) | -| 408 | Request Timed Out (The request to the backend host timed out) | -| 412 | Request Expired (S7PTTL indicates the request is too old to process) | -| 429 | The queue is full or circuit breaker has tripped and the service not accepting requests currently | -| 500 | Internal Server Error (Check Application Insights for more details) | -| 502 | Bad Gateway (Could not complete the request, possibly from overloaded backend hosts) | - -## Headers Used - -| Header | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------| -| **S7PDEBUG** | Set to `true` to enable tracing at the request level. | -| **S7PPriorityKey** | If this header matches a defined key in *PriorityKeys*, the request uses the associated priority from *PriorityValues*. | -| **S7PREQUEUE** | If a remote host returns `429` and sets this header to `true`, the request will be requeued using its original enqueue time. | -| **S7PTTL** | Time-to-live for a message. Once expired, the proxy returns `410` for that request. | -| **x-Request-Queue-Duration** | Shows how long the message spent in the queue before being processed. | -| **x-Request-Process-Duration**| Indicates the processing duration of the request. | -| **x-Request-Worker** | Identifies which worker ID handled the request. | +# Response Codes and Headers + +The proxy returns its own status codes for queue and validation errors, and passes backend codes through for successful responses. Understanding which codes originate from the proxy vs. the backend helps diagnose issues quickly. + +> **TL;DR** +> - **`429`** — the proxy itself is at capacity (queue full, all circuits open, no active hosts). +> - **`412`** — the request waited too long in the queue and its TTL expired before being sent. +> - **`503`** — all backends were tried and all failed (no 429s to requeue). + +--- + +## Proxy-Originated Response Codes + +These codes are generated by the proxy itself, never passed from a backend. + +| Code | Name | Proxy cause | +|------|------|-------------| +| `400` | Bad Request | Malformed `S7PTTL` header value (`InvalidTTL`) | +| `403` | Forbidden | `ValidateAuthAppID` check failed (`DisallowedAppID`); user not found in profile or suspended (`UnknownProfile`) | +| `408` | Request Timeout | IO exception or task cancellation while communicating with backend | +| `412` | Precondition Failed | Request TTL expired while waiting in queue (`TTLExpired`) — `ExpiresAt` passed before dispatch | +| `417` | Expectation Failed | Required header missing (`IncompleteHeaders`); header validation rule failed (`InvalidHeader`) | +| `429` | Too Many Requests | Any of: queue full (`MaxQueueLength` exceeded); all circuit breakers OPEN; no active backend hosts; max undrained events exceeded | +| `500` | Internal Server Error | Unhandled exception; request body too large to buffer (OOM → `ContentTooLarge`) | +| `503` | Service Unavailable | All backends tried and exhausted with no 429s to requeue; exception during initial enqueue | + +> [!NOTE] +> The old documentation listed `502 Bad Gateway` — this code is **not returned** by the proxy. The correct code when all backends fail is `503`. + +--- + +## Backend Pass-Through Codes + +Any status code the proxy receives from a backend that is in `AcceptableStatusCodes` is forwarded directly to the client without triggering a retry. + +**Default `AcceptableStatusCodes`:** `200, 202, 400, 401, 403, 404, 408, 410, 412, 417` + +Codes **not** in this list (e.g. `500`, `502`, `503`) cause the proxy to skip to the next backend. Codes in `3xx` and `404` also trigger a skip, with `404` not counting as a circuit-breaker failure. + +> [!TIP] +> To pass `503` through from a backend instead of retrying, add it to `AcceptableStatusCodes`. + +--- + +## Backend 429 and Requeue + +When a backend returns `429`: + +| `S7PREQUEUE` header on response | Proxy behaviour | +|---------------------------------|----------------| +| `true` | Request is requeued with a delay equal to the backend's `retry-after`; worker moves on immediately | +| absent / not `true` | Proxy tries the next backend host | + +If all backends returned `429` with `S7PREQUEUE: true`, the proxy throws `S7PRequeueException` and requeues using the **shortest** retry-after delay. If some returned 429 without requeue, those hosts are skipped. + +--- + +## Request Headers (proxy reads these) + +| Header | Description | +|--------|-------------| +| `S7PDEBUG` | Set to `true` to enable per-request debug tracing in logs | +| `S7PPriorityKey` | Looked up in `PriorityKeys`; matching entry sets the request priority from `PriorityValues` | +| `S7PTTL` | Time-to-live for the request (seconds). Expired requests return `412`. Default TTL is `DefaultTTLSecs` (300 s) | +| `S7PTimeout` | Per-request timeout override (ms). Header name is configurable via `TimeoutHeader` | +| `S7PREQUEUE` | Set by a **backend** on a `429` response to trigger requeue with retry-after logic | + +--- + +## Response Headers (proxy adds these) + +These headers are injected by the proxy on every successful proxied response. + +| Header | Description | +|--------|-------------| +| `x-Request-Queue-Duration` | Milliseconds the request spent in the priority queue | +| `x-Request-Process-Duration` | Milliseconds spent in the proxy worker (dequeue → response write) | +| `x-Request-Worker` | Worker ID that handled the request | +| `BackendHost` | Hostname of the backend that served the response | +| `Total-Latency` | Total milliseconds from enqueue to response (queue + process) | + +> [!NOTE] +> The headers `Request-Queue-Duration`, `Request-Process-Duration`, and `Backend-Host` (without `x-` prefix) are also added and configured via `DependancyHeaders`. These are the names forwarded in Application Insights dependency telemetry. + +--- + +## Health Probe Endpoints (port 9000) + +| Path | Returns | +|------|---------| +| `/liveness` | `200` when process is running; `503` if unhealthy | +| `/readiness` | `200` when all workers are active and at least one backend is healthy; `503` otherwise | +| `/startup` | `200` once workers have completed startup; `503` during startup | + +--- + +## Diagnostic Flow + +``` +Client request arrives + │ + ├── TTL already expired? ──► 412 Precondition Failed + ├── Validation failed? ──► 403 Forbidden / 417 Expectation Failed + ├── Queue full / CB open / no hosts? ──► 429 Too Many Requests + │ + └── Enqueued → Worker picks up + │ + ├── TTL expired in queue? ──► 412 + │ + └── Send to backend (try each active host) + │ + ├── Backend: 2xx ──────────────────────────────► pass-through to client + ├── Backend: AcceptableStatusCode ────────────► pass-through to client + ├── Backend: 3xx / 404 / 5xx ─────────────────► skip, try next host + ├── Backend: 429 + S7PREQUEUE=true ───────────► requeue with retry-after + ├── Backend: 429 (no requeue) ────────────────► skip, try next host + └── All hosts exhausted ──────────────────────► 503 Service Unavailable +``` + +--- + +## Related Documentation + +- [CIRCUIT_BREAKER.md](CIRCUIT_BREAKER.md) — When and why circuit breakers trip (429) +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — `AcceptableStatusCodes`, `MaxQueueLength`, `DefaultTTLSecs` +- [HEALTH_CHECKING.md](HEALTH_CHECKING.md) — Probe endpoint internals +- [REQUEST_VALIDATION.md](REQUEST_VALIDATION.md) — Header validation rules (403 / 417) diff --git a/docs/SIDECAR_DEPLOYMENT.md b/docs/SIDECAR_DEPLOYMENT.md new file mode 100644 index 00000000..4c94f6f8 --- /dev/null +++ b/docs/SIDECAR_DEPLOYMENT.md @@ -0,0 +1,223 @@ +# Sidecar Deployment (Proxy + HealthProbe) + +Deploy SimpleL7Proxy as a two-container Azure Container App: the **proxy** container handles traffic on port 8000, and a separate **HealthProbe** sidecar handles liveness/readiness/startup probes on port 9000. + +> **TL;DR** +> - **Two images, one Container App** — `myproxy:` and `healthprobe:` run in the same revision; they share `localhost` networking. +> - **One parameters file drives everything** — set values once in `deployment/proxy-with-sidecar/deploy.parameters.sh`; all build and deploy scripts read from it. +> - **Normal update cycle:** bump version in `Constants.cs` → run both `build.sh` scripts → run `deploy.sh`. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Azure Container App revision │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ proxy │ │ health (sidecar) │ │ +│ │ image: myproxy │ │ image: healthprobe │ │ +│ │ port: 8000 (ACA) │ │ port: 9000 │ │ +│ │ CPU: 0.5 / Mem: 1Gi │ │ CPU: 0.25 / Mem: │ │ +│ │ │ │ 0.5Gi │ │ +│ │ HealthProbeSidecar= │ │ probes: /liveness │ │ +│ │ enabled=true; │◄─┤ /readiness │ │ +│ │ url=localhost:9000 │ │ /startup │ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ ▲ │ +│ ACA ingress (external, port 8000) │ +└─────────────────────────────────────────────────────┘ +``` + +The proxy is configured with `HealthProbeSidecar=enabled=true;url=http://localhost:9000` so it delegates its own health state to the sidecar. ACA probes hit the `health` container; traffic ingress hits the `proxy` container. + +--- + +## Reference — deploy.parameters.sh + +All scripts read from `deployment/proxy-with-sidecar/deploy.parameters.sh`. Set these values once. + +| Variable | Default | Description | +|----------|---------|-------------| +| `ACR` | _(required)_ | ACR name without `.azurecr.io` | +| `REGISTRY_SERVER` | `${ACR}.azurecr.io` | Derived automatically | +| `RESOURCE_GROUP` | _(required)_ | Azure resource group | +| `LOCATION` | `eastus` | Azure region | +| `CONTAINER_APP_NAME` | _(required)_ | Container App name | +| `ENVIRONMENT_NAME` | _(required)_ | Container Apps environment name | +| `HOST1` | _(required)_ | Backend host connection string | +| `WEB_CPU` / `WEB_MEMORY` | `0.5` / `1.0` Gi | Proxy container resources | +| `HEALTH_CPU` / `HEALTH_MEMORY` | `0.25` / `0.5` Gi | Sidecar resources | +| `WEB_PORT` | `8000` | Proxy ingress port | +| `HEALTH_PORT` | `9000` | Sidecar probe port | +| `INGRESS_TYPE` | `external` | `external` or `internal` | +| `REVISION_MODE` | `single` | `single` (recommended) or `multiple` | + +> [!NOTE] +> `PROXY_VERSION` and `HEALTHPROBE_VERSION` are **auto-extracted** from `src/SimpleL7Proxy/Constants.cs` and `src/HealthProbe/Constants.cs` — you do not need to set them manually. + +--- + +## First-time Setup + +### Step 1 — Configure parameters + +```bash +cd deployment/proxy-with-sidecar +# deploy.parameters.sh is already present; edit it with your values +nano deploy.parameters.sh +``` + +Minimum required values: + +```bash +export ACR="myregistry" +export RESOURCE_GROUP="my-resource-group" +export CONTAINER_APP_NAME="my-proxy-app" +export ENVIRONMENT_NAME="my-aca-environment" +export HOST1="host=https://my-api.azure-api.net;mode=apim;path=/;probe=/status-0123456789abcdef" +``` + +> [!WARNING] +> Do not commit `deploy.parameters.sh` to source control — it contains environment-specific values. It is listed in `deployment/.gitignore`. + +### Step 2 — Build both images + +Both build scripts read `ACR` from `deploy.parameters.sh` automatically. + +```bash +# Build the proxy image +cd src/SimpleL7Proxy +./build.sh + +# Build the health probe sidecar image +cd ../HealthProbe +./build.sh +``` + +Each script: +1. Extracts the version from its own `Constants.cs` +2. Logs in to ACR via `az acr login` +3. Builds from `src/` (includes `Shared/`) +4. Pushes `$ACR.azurecr.io/myproxy:` and `$ACR.azurecr.io/healthprobe:` respectively + +### Step 3 — Create the Container App and assign RBAC + +Run `setup.sh` **once** — it creates the Container App with a placeholder image, enables system-assigned managed identity, and grants `AcrPull` on the ACR. It waits 60 seconds for the role to propagate. + +```bash +cd deployment/proxy-with-sidecar +chmod +x setup.sh deploy.sh +./setup.sh +``` + +> [!NOTE] +> If the Container App already exists, `setup.sh` enables managed identity on it and assigns `AcrPull` without recreating it. + +### Step 4 — Deploy + +```bash +./deploy.sh +``` + +`deploy.sh` invokes `script.bicep` which creates/updates the Container App with both containers, probes, scaling rules, and registry configuration in a single ARM deployment. + +--- + +## Updating After a Code Change + +```bash +# 1. Bump version in the relevant Constants.cs (if releasing a new version) +# src/SimpleL7Proxy/Constants.cs → VERSION = "2.x.x" +# src/HealthProbe/Constants.cs → VERSION = "2.x.x" + +# 2. Rebuild whichever image changed +cd src/SimpleL7Proxy && ./build.sh # proxy only +cd ../HealthProbe && ./build.sh # sidecar only (if changed) + +# 3. Redeploy +cd ../../deployment/proxy-with-sidecar +./deploy.sh +``` + +`deploy.sh` reads the current versions from `Constants.cs` on each run, so the new image tags are picked up automatically. + +> [!TIP] +> If only the proxy changed, you still only need to re-run `deploy.sh` — the sidecar image tag is unchanged and Bicep will not restart it unnecessarily unless the revision suffix changes. + +--- + +## Worked Example + +> **Setup:** ACR=`myacr`, proxy version `v2.1.0`, sidecar version `v1.3.0`. + +| Step | Command | Result | +|------|---------|--------| +| Edit params | `nano deploy.parameters.sh` | `ACR=myacr`, `CONTAINER_APP_NAME=myproxy` set | +| Build proxy | `cd src/SimpleL7Proxy && ./build.sh` | Pushes `myacr.azurecr.io/myproxy:v2.1.0` | +| Build sidecar | `cd src/HealthProbe && ./build.sh` | Pushes `myacr.azurecr.io/healthprobe:v1.3.0` | +| First-time setup | `./setup.sh` | Container App created, `AcrPull` assigned | +| Deploy | `./deploy.sh` | Bicep deploys both containers in one revision | +| Verify | `az containerapp revision list ...` | New revision active, probes passing | + +--- + +## Monitoring and Troubleshooting + +### View logs per container + +```bash +# Proxy container +az containerapp logs show \ + --name $CONTAINER_APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --container proxy --follow + +# Health sidecar +az containerapp logs show \ + --name $CONTAINER_APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --container health --follow +``` + +### Check revision and replica status + +```bash +az containerapp revision list \ + --name $CONTAINER_APP_NAME \ + --resource-group $RESOURCE_GROUP \ + -o table + +az containerapp replica list \ + --name $CONTAINER_APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --revision -o table +``` + +### Inspect probe configuration + +```bash +az containerapp show \ + --name $CONTAINER_APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --query "properties.template.containers[].probes" +``` + +### Common issues + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| `deploy.sh` fails — `WEB_IMAGE` not set | `deploy.parameters.sh` not sourced | Ensure file exists and `ACR` is set; re-run `build.sh` first | +| Container App cycling — probes failing | Sidecar not ready yet | `failureThreshold: 30` gives 5 minutes at 10 s intervals; check `health` container logs | +| `AcrPull` permission denied on first deploy | RBAC not propagated yet | Re-run `deploy.sh` after waiting ~60 s, or run `setup.sh` again | +| Proxy returns 503 on `/health` path | `HealthProbeSidecar` env var missing | Ensure `HealthProbeSidecar=enabled=true;url=http://localhost:9000` is set on proxy container | + +--- + +## Related Documentation + +- [CONTAINER_DEPLOYMENT.md](CONTAINER_DEPLOYMENT.md) — Single-container deployment (built-in probe server) +- [HEALTH_CHECKING.md](HEALTH_CHECKING.md) — Health probe internals and endpoints +- [BACKEND_HOSTS.md](BACKEND_HOSTS.md) — `HOST1` connection string format +- [CONFIGURATION_SETTINGS.md](CONFIGURATION_SETTINGS.md) — All proxy environment variables diff --git a/docs/SyncTimeouts.png b/docs/SyncTimeouts.png new file mode 100644 index 0000000000000000000000000000000000000000..0cf3fe71af1933f07714ea5ad5b6dac26af77d0a GIT binary patch literal 43228 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 **TL;DR** +> - **Earliest expiration wins** — when TTL and Timeout both apply, whichever deadline arrives first is enforced. +> - **TTL** (seconds) is the hard wall-clock budget for the entire request life: queue wait + all retry attempts. +> - **Timeout** (milliseconds) is the per-host-attempt window; it resets on every retry. +> - **AsyncTimeout** (milliseconds) replaces Timeout once a request switches to async mode. + +--- + +> **Units used in this doc:** TTL values are in **seconds**; all Timeout values are in **milliseconds**. + +## Reference — All Settings + +| Setting | Default | Unit | Override Header | Config Key | Reload | +|---|---|---|---|---|---| +| **DefaultTTLSecs** | 300 (5 min) | s | `S7PTTL` | `Priority:DefaultTTLSecs` | WARM | +| **Timeout** | 1,200,000 (20 min) | ms | `S7PTimeout` | `Server:Timeout` | COLD | +| **AsyncTriggerTimeout** | 10,000 (10 s) | ms | — | `Async:TriggerTimeout` | WARM | +| **AsyncTimeout** | 1,800,000 (30 min) | ms | — | `Async:Timeout` | WARM | +| **AsyncTTLSecs** | 86,400 (24 h) | s | — | `Async:TTLSecs` | WARM | + +--- + +## Request Flow + +The diagram below covers both synchronous and async paths. All clocks start at **enqueue time**. + +``` +Client + │ + ▼ enqueue ◄─── TTL clock starts (DefaultTTLSecs or S7PTTL) + │ + ├── AsyncTriggerTimeout elapsed? ──Yes──► Return async response (blob URIs) to client + │ Continue in background under AsyncTimeout + │ Result retained for AsyncTTLSecs + No (synchronous path) + │ + ▼ + ┌──────────────┐ fail / timeout ┌──────────────┐ fail / timeout ┌──────────────┐ + │ Host 1 │ ─────────────────►│ Host 2 │ ─────────────────►│ Host n │ + │ [Timeout ms] │ │ [Timeout ms] │ │ [Timeout ms] │ + └──────────────┘ └──────────────┘ └──────────────┘ + ▲ │ + └───────── TTL expired anywhere along this chain → 503, no retry ───────┘ + │ + ▼ +Response to client +``` + +**On every host attempt, the effective deadline = min(remaining TTL, Timeout).** + +--- + +## Synchronous Requests + +**Rule: Each host attempt gets a fresh Timeout window, but the total request life is capped by TTL.** + +![Synchronous timeout flow: TTL caps the total request life; each host attempt gets a fresh Timeout window.](SyncTimeouts.png) + +``` +DefaultTTLSecs: 60 → ExpiresAt = enqueue + 60 s +Timeout: 45000 → per-host window = 45 s +First attempt: min(60 s, 45 s) = 45 s effective +``` + +> [!NOTE] +> **Default:** `DefaultTTLSecs = 300 s`, `Timeout = 1,200,000 ms`. Both are used when no override headers are present. + +> [!TIP] +> **Troubleshooting:** If requests expire faster than expected, verify that the client is not sending a short `S7PTTL` header — it silently overrides `DefaultTTLSecs`. + +--- + +## Async Requests + +**Rule: After `AsyncTriggerTimeout` elapses the client is unblocked immediately; the proxy finishes processing under `AsyncTimeout`.** + +![Async timeout flow: client is released after AsyncTriggerTimeout; backend continues under AsyncTimeout; result retained for AsyncTTLSecs.](AsyncTimeouts.png) + +``` +AsyncTriggerTimeout: 10000 → client receives blob URIs after 10 s +AsyncTimeout: 1800000 → backend has up to 30 min to complete +AsyncTTLSecs: 86400 → result blob retained for 24 h +``` + +> [!NOTE] +> **No header overrides exist for async settings.** Configure them via environment variables only. + +> [!TIP] +> **Troubleshooting:** If async results disappear sooner than expected, `AsyncTTLSecs` may be set too low. + +--- + +## Per-Request Overrides + +**Rule: Send `S7PTTL` (seconds) or `S7PTimeout` (milliseconds) headers to replace the global defaults for one request.** + +```http +S7PTimeout: 60000 # per-host timeout → 60 s for this request +S7PTTL: 120 # TTL → 120 s for this request +``` + +> [!NOTE] +> **Defaults:** If a header is absent or unparseable, the corresponding global config value is used with no error. + +> [!WARNING] +> **Error:** An unparseable `S7PTTL` value returns **400 Bad Request** with error code `InvalidTTL`. + +Supported `S7PTTL` formats: + +| Format | Example | Meaning | +|---|---|---| +| Relative integer | `300` | Expires 300 s from enqueue | +| Relative decimal | `2.5` | Expires 2,500 ms from enqueue | +| Absolute Unix timestamp | `+1735689600` | Expires at the given epoch second | +| ISO 8601 datetime | `2024-12-31T23:59:59Z` | Expires at the given UTC time | + +--- + +## Worked Example + +> **Scenario:** `DefaultTTLSecs = 60`, `Timeout = 45000`. No override headers. Request queues for 5 s, then needs two host attempts. + +| Event | Wall clock | TTL remaining | Host window | Effective deadline | Outcome | +|---|---|---|---|---|---| +| Enqueue | 0 s | 60 s | — | — | TTL clock starts | +| Dequeue | 5 s | 55 s | 45 s | **min(55 s, 45 s) = 45 s** | Attempt Host 1 | +| Host 1 timeout | 50 s | 10 s | 45 s elapsed | — | Retry | +| Host 2 attempt | 50 s | 10 s | 45 s window | **min(10 s, 45 s) = 10 s** | Attempt Host 2 | +| TTL expires | 60 s | 0 s | — | — | 503 — no more retries | + +**The TTL (not the per-host Timeout) determined the final deadline on the second attempt.** diff --git a/docs/timeout-image.png b/docs/timeout-image.png new file mode 100644 index 0000000000000000000000000000000000000000..deef4bdfefef562e2292719f2d9ffa87af9f0c43 GIT binary patch literal 102968 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

I>lXv;DOaY==RcV-2FhcWM?C^^84(g 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 K(X^WyFaH-dsMkRN literal 0 HcmV?d00001 From 5b3d8e4164655715d883fe47a2155a5f7e34770b Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 12:23:10 -0400 Subject: [PATCH 10/11] improve docs --- README.md | 117 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 90bc0882..dfc23387 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,121 @@ # SimpleL7Proxy +> Self-hosted Layer-7 AI gateway for Azure — priority queuing, async orchestration, and per-user governance inside your own VNET. + [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-10-purple)](https://dotnet.microsoft.com) +[![.NET 10](https://img.shields.io/badge/.NET-10-purple)](https://dotnet.microsoft.com) [![Platform](https://img.shields.io/badge/platform-Azure%20Container%20Apps-0078D4)](https://learn.microsoft.com/en-us/azure/container-apps/overview) +[![Build](https://img.shields.io/badge/build-passing-brightgreen)](docs/DEVELOPMENT.md) + +**TL;DR** +- **Run locally:** `git clone … && dotnet run --project src/SimpleL7Proxy` +- **Deploy to ACA:** `./.azure/setup.sh && azd provision && ./.azure/deploy.sh` +- **Use async mode** for long LLM calls (>60 s); see [AsyncOperation.md](docs/AsyncOperation.md) + +--- + +![SimpleL7Proxy routes client requests through a priority queue to multiple Azure OpenAI backends, with health checking and circuit breaking on each backend.](docs/arch.png) + +*Incoming requests are priority-queued and dispatched to healthy backends; degraded backends are isolated automatically.* + +--- + +## Key Capabilities -An open-source, self-hosted **AI Gateway** for Azure. SimpleL7Proxy is a high-performance Layer 7 router purpose-built for LLM workloads — providing priority queuing, fair-share governance, circuit breaking, async orchestration, and streaming token telemetry, all running inside your own VNET. +- **Priority queuing** — routes high-priority users ahead of batch traffic. +- **Per-user validation** — blocks callers whose model or header values aren't in their allowlist. +- **Entra App ID gating** — unknown app IDs rejected at the gate; no backend hit. +- **Circuit breaker** — progressive back-off; auto-recovery when backends respond. +- **Async orchestration** — blob + Service Bus hand-off for calls that exceed the sync timeout. +- **Hot-reload config** — allowlists, routing rules, and profiles update without restart. -→ **[Full overview, architecture, and use-case analysis](docs/OVERVIEW.md)** +→ **[Full architecture and use-case analysis](docs/OVERVIEW.md)** -![Architecture Diagram](docs/arch.png) +--- ## Prerequisites - [.NET 10 SDK](https://dotnet.microsoft.com/download) -- [Docker](https://docs.docker.com/get-docker/) (for container builds) -- [Azure Developer CLI (azd)](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) (for cloud deployment) -- An Azure subscription with Container Apps and (optionally) AI Foundry / APIM +- [Docker](https://docs.docker.com/get-docker/) (container builds) +- [Azure Developer CLI (azd)](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd) (cloud deployment) +- Azure subscription with Container Apps; optionally AI Foundry / APIM ## Quick Start -**Local development** (interactive setup wizard): +**Local (2 commands):** ```bash git clone https://github.com/your-org/SimpleL7Proxy.git -cd SimpleL7Proxy dotnet run --project src/SimpleL7Proxy ``` -**Deploy to Azure Container Apps:** +**Azure Container Apps — Windows:** +```powershell +.\.azure\setup.ps1 +azd provision +.\.azure\deploy.ps1 +``` + +**Azure Container Apps — Linux / macOS:** ```bash -# Windows -./.azure/setup.ps1 +chmod +x .azure/setup.sh .azure/deploy.sh +./.azure/setup.sh && azd provision && ./.azure/deploy.sh +``` -# Linux / macOS -chmod +x ./.azure/setup.sh && ./.azure/setup.sh +> See [Development & Testing](docs/DEVELOPMENT.md) for local mock backends. +> See [Container Deployment](docs/CONTAINER_DEPLOYMENT.md) for VNET and high-performance variants. -azd provision -./.azure/deploy.ps1 # or deploy.sh on Linux/macOS -``` +--- + +## Key Defaults -See [Development and Testing](docs/DEVELOPMENT.md) for local mock backends and manual configuration. -See [Container Deployment](docs/CONTAINER_DEPLOYMENT.md) for all deployment scenarios (standard, high-performance, VNET). +| Setting | Default | Unit | Config key | Reload | +|---|---|---|---|---| +| Port | 80 | — | `Server:Port` | Cold | +| Workers | 10 | threads | `Server:Workers` | Cold | +| Max queue depth | 1 000 | requests | `Server:MaxQueueLength` | Cold | +| Default priority | 2 | — | `server:DefaultPriority` | Warm | +| Sync timeout | 20 min | ms | `server:DefaultTimeout` | Warm | +| Request TTL | 300 | s | `server:DefaultTTLSecs` | Warm | +| Async result TTL | 24 h | s | `Async:TTLSecs` | Warm | +| Async trigger timeout | 10 s | ms | `Async:TriggerTimeout` | Warm | + +**Units used:** timeout values are in **milliseconds** in config; TTL values are in **seconds**. + +--- + +## Worked Example — Timeout vs TTL + +A request arrives at `t = 0` with no override headers. + +| Step | Value | Source | +|---|---|---| +| `DefaultTTLSecs` | 300 s | proxy config | +| Request expires at | `t + 300 s` | set on enqueue | +| `Timeout` | 20 min (1 200 000 ms) | proxy config | +| Backend call deadline | `t + 20 min` | set on dequeue | +| Effective deadline | **t + 5 min** | TTL wins (shorter) | + +A client can override both per-request via `S7PTimeout` (ms) and `S7PTTL` (s) headers. **If the TTL expires before the request is dequeued, the proxy returns 412.** + +--- + +## Common Errors + +| Code | Meaning | Fix | +|---|---|---| +| 400 | `InvalidTTL` — `S7PTTL` header value is not a valid integer | Send a numeric TTL, e.g. `S7PTTL: 120` | +| 403 | Unknown App ID or missing user profile | Add the Entra GUID to `auth.json`; verify `ValidateAuthAppIDUrl` is reachable | +| 412 | Request TTL expired before dequeue | Increase `DefaultTTLSecs` or reduce queue depth | +| 417 | Required header missing or value not in allowlist | Check `RequiredHeaders` and per-user `ValidateHeaders` rules | +| 429 | Queue full, circuit breaker open, or no active backends | Scale workers, check circuit breaker, or add backends | +| 503 | All backends failed | Check backend health; review circuit breaker timeslice | + +--- ## Documentation +**New here?** Start with [Quick Start](#quick-start) → [Overview](docs/OVERVIEW.md) → [Advanced Configuration](docs/ADVANCED_CONFIGURATION.md). + | Topic | Document | |-------|----------| | Overview & Architecture | [docs/OVERVIEW.md](docs/OVERVIEW.md) | @@ -56,18 +129,20 @@ See [Container Deployment](docs/CONTAINER_DEPLOYMENT.md) for all deployment scen | Request Validation | [docs/REQUEST_VALIDATION.md](docs/REQUEST_VALIDATION.md) | | Observability & Telemetry | [docs/OBSERVABILITY.md](docs/OBSERVABILITY.md) | | Security | [docs/SECURITY.md](docs/SECURITY.md) | -| Environment Variables | [docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md) | | Configuration Settings | [docs/CONFIGURATION_SETTINGS.md](docs/CONFIGURATION_SETTINGS.md) | | Azure App Configuration | [docs/AZURE_APP_CONFIGURATION.md](docs/AZURE_APP_CONFIGURATION.md) | +| Environment Variables | [docs/ENVIRONMENT_VARIABLES.md](docs/ENVIRONMENT_VARIABLES.md) | | AI Foundry Integration | [docs/AI_FOUNDRY_INTEGRATION.md](docs/AI_FOUNDRY_INTEGRATION.md) | | APIM Policy | [APIM-Policy/readme.md](APIM-Policy/readme.md) | | Container Deployment | [docs/CONTAINER_DEPLOYMENT.md](docs/CONTAINER_DEPLOYMENT.md) | | Development & Testing | [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | | Response Codes | [docs/RESPONSE_CODES.md](docs/RESPONSE_CODES.md) | +--- + ## Contributing -Issues and pull requests are welcome. Please open an issue first to discuss significant changes. +Issues and pull requests are welcome. **Open an issue first** to discuss significant changes before submitting a PR. ## License From 62f0d3c4ae4120ef3a44b87f01dcbca4a6c52edf Mon Sep 17 00:00:00 2001 From: Nagendra Mishr Date: Wed, 15 Apr 2026 12:29:38 -0400 Subject: [PATCH 11/11] improve docs --- README.md | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/README.md b/README.md index dfc23387..5b15f21b 100644 --- a/README.md +++ b/README.md @@ -66,52 +66,6 @@ chmod +x .azure/setup.sh .azure/deploy.sh --- -## Key Defaults - -| Setting | Default | Unit | Config key | Reload | -|---|---|---|---|---| -| Port | 80 | — | `Server:Port` | Cold | -| Workers | 10 | threads | `Server:Workers` | Cold | -| Max queue depth | 1 000 | requests | `Server:MaxQueueLength` | Cold | -| Default priority | 2 | — | `server:DefaultPriority` | Warm | -| Sync timeout | 20 min | ms | `server:DefaultTimeout` | Warm | -| Request TTL | 300 | s | `server:DefaultTTLSecs` | Warm | -| Async result TTL | 24 h | s | `Async:TTLSecs` | Warm | -| Async trigger timeout | 10 s | ms | `Async:TriggerTimeout` | Warm | - -**Units used:** timeout values are in **milliseconds** in config; TTL values are in **seconds**. - ---- - -## Worked Example — Timeout vs TTL - -A request arrives at `t = 0` with no override headers. - -| Step | Value | Source | -|---|---|---| -| `DefaultTTLSecs` | 300 s | proxy config | -| Request expires at | `t + 300 s` | set on enqueue | -| `Timeout` | 20 min (1 200 000 ms) | proxy config | -| Backend call deadline | `t + 20 min` | set on dequeue | -| Effective deadline | **t + 5 min** | TTL wins (shorter) | - -A client can override both per-request via `S7PTimeout` (ms) and `S7PTTL` (s) headers. **If the TTL expires before the request is dequeued, the proxy returns 412.** - ---- - -## Common Errors - -| Code | Meaning | Fix | -|---|---|---| -| 400 | `InvalidTTL` — `S7PTTL` header value is not a valid integer | Send a numeric TTL, e.g. `S7PTTL: 120` | -| 403 | Unknown App ID or missing user profile | Add the Entra GUID to `auth.json`; verify `ValidateAuthAppIDUrl` is reachable | -| 412 | Request TTL expired before dequeue | Increase `DefaultTTLSecs` or reduce queue depth | -| 417 | Required header missing or value not in allowlist | Check `RequiredHeaders` and per-user `ValidateHeaders` rules | -| 429 | Queue full, circuit breaker open, or no active backends | Scale workers, check circuit breaker, or add backends | -| 503 | All backends failed | Check backend health; review circuit breaker timeslice | - ---- - ## Documentation **New here?** Start with [Quick Start](#quick-start) → [Overview](docs/OVERVIEW.md) → [Advanced Configuration](docs/ADVANCED_CONFIGURATION.md).