612 lines
24 KiB
C#
612 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public enum BridgeCommandStage
|
|
{
|
|
Plan,
|
|
Execute
|
|
}
|
|
|
|
public sealed class BridgePolicyDecision
|
|
{
|
|
public static BridgePolicyDecision Allow()
|
|
{
|
|
return new BridgePolicyDecision { Allowed = true };
|
|
}
|
|
|
|
public static BridgePolicyDecision Deny(string code, string message)
|
|
{
|
|
return Deny(code, message, false);
|
|
}
|
|
|
|
public static BridgePolicyDecision Deny(
|
|
string code,
|
|
string message,
|
|
bool auditRecommended)
|
|
{
|
|
return new BridgePolicyDecision
|
|
{
|
|
Allowed = false,
|
|
Code = code,
|
|
Message = message,
|
|
AuditRecommended = auditRecommended
|
|
};
|
|
}
|
|
|
|
public bool Allowed { get; set; }
|
|
public string Code { get; set; }
|
|
public string Message { get; set; }
|
|
public bool AuditRecommended { get; set; }
|
|
}
|
|
|
|
public sealed class BridgeOperationalSnapshot
|
|
{
|
|
public int RequestsPerMinute { get; set; }
|
|
public int MaximumTrackedSessions { get; set; }
|
|
public int TrackedSessionCount { get; set; }
|
|
public int DisabledCommandCount { get; set; }
|
|
public int OpenCircuitCount { get; set; }
|
|
public int CircuitFailureThreshold { get; set; }
|
|
public int CircuitOpenSeconds { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Process-local operational guard for the ERP command bridge. Database
|
|
/// idempotency remains the business safety boundary; this policy protects
|
|
/// the legacy UI process from request floods and repeated infrastructure
|
|
/// failures, and supplies a deployment-time command kill switch.
|
|
/// </summary>
|
|
public sealed class BridgeOperationalPolicy
|
|
{
|
|
public const int DefaultRequestsPerMinute = 120;
|
|
public const int DefaultMaximumTrackedSessions = 256;
|
|
public const int DefaultCircuitFailureThreshold = 5;
|
|
public const int DefaultCircuitFailureWindowSeconds = 60;
|
|
public const int DefaultCircuitOpenSeconds = 30;
|
|
private const int MaximumDisabledCommands = 64;
|
|
private const int MaximumCircuitStates = 512;
|
|
private const int MaximumRejectionAuditStates = 512;
|
|
|
|
private static readonly Regex SafeCommandName = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeOutcomeCode = new Regex(
|
|
"^[a-z0-9_.-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly ISet<string> OperationalFailureCodes =
|
|
new HashSet<string>(new[]
|
|
{
|
|
"internal_error",
|
|
"workflow_database_error",
|
|
"audit_log_unavailable",
|
|
"audit_log_capacity_exceeded",
|
|
"audit_log_corrupt",
|
|
"idempotency_store_error",
|
|
"plan_store_capacity_exceeded",
|
|
"adapter_protocol_error",
|
|
"adapter_result_missing",
|
|
"adapter_commit_evidence_missing",
|
|
"adapter_not_ready",
|
|
"adapter_readiness_failed"
|
|
}, StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly object _syncRoot = new object();
|
|
private readonly ISystemClock _clock;
|
|
private readonly ISet<string> _disabledCommands;
|
|
private readonly int _requestsPerMinute;
|
|
private readonly int _maximumTrackedSessions;
|
|
private readonly int _circuitFailureThreshold;
|
|
private readonly TimeSpan _circuitFailureWindow;
|
|
private readonly TimeSpan _circuitOpenDuration;
|
|
private readonly TimeSpan _sessionRetention = TimeSpan.FromMinutes(5);
|
|
private readonly IDictionary<string, RateState> _rateStates =
|
|
new Dictionary<string, RateState>(StringComparer.Ordinal);
|
|
private readonly IDictionary<string, CircuitState> _circuitStates =
|
|
new Dictionary<string, CircuitState>(StringComparer.Ordinal);
|
|
private readonly IDictionary<string, DateTime> _rejectionAudits =
|
|
new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
|
private DateTime _capacityAuditWindowStartedAtUtc;
|
|
|
|
public BridgeOperationalPolicy(
|
|
ISystemClock clock,
|
|
IEnumerable<string> disabledCommands,
|
|
int requestsPerMinute,
|
|
int maximumTrackedSessions,
|
|
int circuitFailureThreshold,
|
|
TimeSpan circuitFailureWindow,
|
|
TimeSpan circuitOpenDuration)
|
|
{
|
|
if (clock == null) throw new ArgumentNullException("clock");
|
|
if (requestsPerMinute < 1 || requestsPerMinute > 6000)
|
|
throw Invalid("桥每分钟请求上限必须在 1-6000 之间。");
|
|
if (maximumTrackedSessions < 1 || maximumTrackedSessions > 4096)
|
|
throw Invalid("桥限流会话容量必须在 1-4096 之间。");
|
|
if (circuitFailureThreshold < 2 || circuitFailureThreshold > 100)
|
|
throw Invalid("桥熔断故障阈值必须在 2-100 之间。");
|
|
if (circuitFailureWindow < TimeSpan.FromSeconds(1)
|
|
|| circuitFailureWindow > TimeSpan.FromMinutes(30))
|
|
throw Invalid("桥熔断统计窗口必须在 1-1800 秒之间。");
|
|
if (circuitOpenDuration < TimeSpan.FromSeconds(1)
|
|
|| circuitOpenDuration > TimeSpan.FromMinutes(30))
|
|
throw Invalid("桥熔断时长必须在 1-1800 秒之间。");
|
|
|
|
HashSet<string> disabled = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (string item in disabledCommands ?? new string[0])
|
|
{
|
|
string command = (item ?? string.Empty).Trim();
|
|
if (!SafeCommandName.IsMatch(command)
|
|
|| !disabled.Add(command)
|
|
|| disabled.Count > MaximumDisabledCommands)
|
|
throw Invalid("停用命令列表包含空值、重复项、非法名称或超过 64 项。");
|
|
}
|
|
|
|
_clock = clock;
|
|
_disabledCommands = disabled;
|
|
_requestsPerMinute = requestsPerMinute;
|
|
_maximumTrackedSessions = maximumTrackedSessions;
|
|
_circuitFailureThreshold = circuitFailureThreshold;
|
|
_circuitFailureWindow = circuitFailureWindow;
|
|
_circuitOpenDuration = circuitOpenDuration;
|
|
}
|
|
|
|
public static BridgeOperationalPolicy CreateDefault(ISystemClock clock)
|
|
{
|
|
return new BridgeOperationalPolicy(
|
|
clock,
|
|
new string[0],
|
|
DefaultRequestsPerMinute,
|
|
DefaultMaximumTrackedSessions,
|
|
DefaultCircuitFailureThreshold,
|
|
TimeSpan.FromSeconds(DefaultCircuitFailureWindowSeconds),
|
|
TimeSpan.FromSeconds(DefaultCircuitOpenSeconds));
|
|
}
|
|
|
|
public static BridgeOperationalPolicy FromEnvironment(ISystemClock clock)
|
|
{
|
|
return new BridgeOperationalPolicy(
|
|
clock,
|
|
ParseDisabledCommands(
|
|
Environment.GetEnvironmentVariable(
|
|
"LSERP_AGENT_DISABLED_COMMANDS")),
|
|
ParseBoundedInteger(
|
|
"LSERP_AGENT_RATE_LIMIT_PER_MINUTE",
|
|
DefaultRequestsPerMinute,
|
|
1,
|
|
6000),
|
|
ParseBoundedInteger(
|
|
"LSERP_AGENT_RATE_LIMIT_SESSIONS",
|
|
DefaultMaximumTrackedSessions,
|
|
1,
|
|
4096),
|
|
ParseBoundedInteger(
|
|
"LSERP_AGENT_CIRCUIT_FAILURE_THRESHOLD",
|
|
DefaultCircuitFailureThreshold,
|
|
2,
|
|
100),
|
|
TimeSpan.FromSeconds(ParseBoundedInteger(
|
|
"LSERP_AGENT_CIRCUIT_WINDOW_SECONDS",
|
|
DefaultCircuitFailureWindowSeconds,
|
|
1,
|
|
1800)),
|
|
TimeSpan.FromSeconds(ParseBoundedInteger(
|
|
"LSERP_AGENT_CIRCUIT_OPEN_SECONDS",
|
|
DefaultCircuitOpenSeconds,
|
|
1,
|
|
1800)));
|
|
}
|
|
|
|
public void ValidateRegisteredCommands(
|
|
IEnumerable<CommandDescriptor> descriptors)
|
|
{
|
|
if (descriptors == null)
|
|
throw Invalid("ERP 命令注册表不能为空。");
|
|
HashSet<string> registered = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (CommandDescriptor descriptor in descriptors)
|
|
{
|
|
string name = descriptor == null
|
|
? string.Empty
|
|
: (descriptor.Name ?? string.Empty).Trim();
|
|
if (!SafeCommandName.IsMatch(name) || !registered.Add(name))
|
|
throw Invalid("ERP 命令注册表包含空值、重复项或非法名称。");
|
|
}
|
|
lock (_syncRoot)
|
|
{
|
|
foreach (string disabled in _disabledCommands)
|
|
{
|
|
if (!registered.Contains(disabled))
|
|
throw Invalid("停用列表包含当前未注册的 ERP 命令。");
|
|
}
|
|
}
|
|
}
|
|
|
|
public BridgePolicyDecision Admit(string clientSessionId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(clientSessionId))
|
|
return BridgePolicyDecision.Deny(
|
|
"invalid_request",
|
|
"桌宠客户端会话不能为空。");
|
|
DateTime now = _clock.UtcNow;
|
|
lock (_syncRoot)
|
|
{
|
|
PurgeRateStates(now);
|
|
RateState state;
|
|
if (!_rateStates.TryGetValue(clientSessionId, out state))
|
|
{
|
|
if (_rateStates.Count >= _maximumTrackedSessions)
|
|
{
|
|
return BridgePolicyDecision.Deny(
|
|
"bridge_rate_state_capacity_exceeded",
|
|
"ERP 命令桥限流会话容量已满,请关闭无效桌宠会话后重试。",
|
|
ShouldAuditCapacityRejection(now));
|
|
}
|
|
state = new RateState
|
|
{
|
|
WindowStartedAtUtc = now,
|
|
LastSeenAtUtc = now
|
|
};
|
|
_rateStates.Add(clientSessionId, state);
|
|
}
|
|
if (now >= state.WindowStartedAtUtc
|
|
&& now - state.WindowStartedAtUtc >= TimeSpan.FromMinutes(1))
|
|
{
|
|
state.WindowStartedAtUtc = now;
|
|
state.RequestCount = 0;
|
|
state.LimitAuditRecorded = false;
|
|
}
|
|
if (now > state.LastSeenAtUtc) state.LastSeenAtUtc = now;
|
|
if (state.RequestCount >= _requestsPerMinute)
|
|
{
|
|
bool auditRecommended = !state.LimitAuditRecorded;
|
|
state.LimitAuditRecorded = true;
|
|
return BridgePolicyDecision.Deny(
|
|
"bridge_rate_limit_exceeded",
|
|
"ERP 命令桥请求过于频繁,请稍后重试。",
|
|
auditRecommended);
|
|
}
|
|
state.RequestCount += 1;
|
|
return BridgePolicyDecision.Allow();
|
|
}
|
|
}
|
|
|
|
public bool IsCommandVisible(string commandName, bool requiresExecution)
|
|
{
|
|
if (!CheckCommandAvailability(
|
|
commandName,
|
|
BridgeCommandStage.Plan).Allowed)
|
|
return false;
|
|
return !requiresExecution || CheckCommandAvailability(
|
|
commandName,
|
|
BridgeCommandStage.Execute).Allowed;
|
|
}
|
|
|
|
public BridgePolicyDecision CheckCommandAvailability(
|
|
string commandName,
|
|
BridgeCommandStage stage)
|
|
{
|
|
if (!SafeCommandName.IsMatch(commandName ?? string.Empty))
|
|
return BridgePolicyDecision.Deny(
|
|
"command_policy_invalid",
|
|
"ERP 命令运维策略无法识别该命令。");
|
|
DateTime now = _clock.UtcNow;
|
|
lock (_syncRoot)
|
|
{
|
|
if (_disabledCommands.Contains(commandName))
|
|
{
|
|
return BridgePolicyDecision.Deny(
|
|
"command_disabled",
|
|
"该 ERP 命令已被运维策略停用。");
|
|
}
|
|
return IsCircuitBlocking(commandName, stage, now)
|
|
? BridgePolicyDecision.Deny(
|
|
"command_circuit_open",
|
|
"该 ERP 命令因连续运行故障暂时停用,请稍后重试。")
|
|
: BridgePolicyDecision.Allow();
|
|
}
|
|
}
|
|
|
|
public BridgePolicyDecision TryEnterCommand(
|
|
string commandName,
|
|
BridgeCommandStage stage)
|
|
{
|
|
if (!SafeCommandName.IsMatch(commandName ?? string.Empty))
|
|
return BridgePolicyDecision.Deny(
|
|
"command_policy_invalid",
|
|
"ERP 命令运维策略无法识别该命令。");
|
|
DateTime now = _clock.UtcNow;
|
|
lock (_syncRoot)
|
|
{
|
|
if (_disabledCommands.Contains(commandName))
|
|
{
|
|
return BridgePolicyDecision.Deny(
|
|
"command_disabled",
|
|
"该 ERP 命令已被运维策略停用。");
|
|
}
|
|
string key = CircuitKey(commandName, stage);
|
|
CircuitState state;
|
|
if (!_circuitStates.TryGetValue(key, out state))
|
|
return BridgePolicyDecision.Allow();
|
|
if (state.OpenUntilUtc > now || state.HalfOpenProbe)
|
|
{
|
|
return BridgePolicyDecision.Deny(
|
|
"command_circuit_open",
|
|
"该 ERP 命令因连续运行故障暂时停用,请稍后重试。");
|
|
}
|
|
if (state.OpenUntilUtc != DateTime.MinValue)
|
|
state.HalfOpenProbe = true;
|
|
return BridgePolicyDecision.Allow();
|
|
}
|
|
}
|
|
|
|
public bool ShouldAuditCommandRejection(
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
string outcomeCode)
|
|
{
|
|
if (!SafeCommandName.IsMatch(commandName ?? string.Empty)
|
|
|| !SafeOutcomeCode.IsMatch(outcomeCode ?? string.Empty))
|
|
return false;
|
|
DateTime now = _clock.UtcNow;
|
|
string key = CircuitKey(commandName, stage)
|
|
+ "|" + outcomeCode.ToLowerInvariant();
|
|
lock (_syncRoot)
|
|
{
|
|
PurgeRejectionAudits(now);
|
|
DateTime previous;
|
|
if (_rejectionAudits.TryGetValue(key, out previous))
|
|
{
|
|
if (now < previous
|
|
|| now - previous < TimeSpan.FromMinutes(1))
|
|
return false;
|
|
_rejectionAudits[key] = now;
|
|
return true;
|
|
}
|
|
if (_rejectionAudits.Count >= MaximumRejectionAuditStates)
|
|
return false;
|
|
_rejectionAudits.Add(key, now);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public bool RecordCommandSuccess(
|
|
string commandName,
|
|
BridgeCommandStage stage)
|
|
{
|
|
lock (_syncRoot)
|
|
{
|
|
string key = CircuitKey(commandName, stage);
|
|
CircuitState state;
|
|
bool recovered = _circuitStates.TryGetValue(key, out state)
|
|
&& (state.OpenUntilUtc != DateTime.MinValue
|
|
|| state.HalfOpenProbe);
|
|
_circuitStates.Remove(key);
|
|
return recovered;
|
|
}
|
|
}
|
|
|
|
public bool RecordCommandFailure(
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
string errorCode)
|
|
{
|
|
string key = CircuitKey(commandName, stage);
|
|
DateTime now = _clock.UtcNow;
|
|
lock (_syncRoot)
|
|
{
|
|
CircuitState existing;
|
|
if (!OperationalFailureCodes.Contains(errorCode ?? string.Empty))
|
|
{
|
|
// Validation, permission and user-cancel outcomes must not
|
|
// heal an infrastructure circuit. They only release a
|
|
// half-open probe so a later valid request can test it.
|
|
if (_circuitStates.TryGetValue(key, out existing))
|
|
existing.HalfOpenProbe = false;
|
|
return false;
|
|
}
|
|
if (!_circuitStates.TryGetValue(key, out existing))
|
|
{
|
|
PurgeCircuitStates(now);
|
|
if (_circuitStates.Count >= MaximumCircuitStates)
|
|
return false;
|
|
existing = new CircuitState
|
|
{
|
|
FailureWindowStartedAtUtc = now
|
|
};
|
|
_circuitStates.Add(key, existing);
|
|
}
|
|
|
|
if (existing.OpenUntilUtc > now
|
|
&& !existing.HalfOpenProbe)
|
|
return false;
|
|
if (existing.HalfOpenProbe
|
|
|| existing.OpenUntilUtc != DateTime.MinValue)
|
|
{
|
|
existing.FailureCount = _circuitFailureThreshold;
|
|
existing.FailureWindowStartedAtUtc = now;
|
|
existing.OpenUntilUtc = now.Add(_circuitOpenDuration);
|
|
existing.HalfOpenProbe = false;
|
|
return true;
|
|
}
|
|
|
|
bool sameWindow = now < existing.FailureWindowStartedAtUtc
|
|
|| now - existing.FailureWindowStartedAtUtc
|
|
<= _circuitFailureWindow;
|
|
if (!sameWindow)
|
|
{
|
|
existing.FailureWindowStartedAtUtc = now;
|
|
existing.FailureCount = 0;
|
|
}
|
|
existing.FailureCount += 1;
|
|
if (existing.FailureCount >= _circuitFailureThreshold)
|
|
{
|
|
existing.OpenUntilUtc = now.Add(_circuitOpenDuration);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public BridgeOperationalSnapshot Snapshot()
|
|
{
|
|
DateTime now = _clock.UtcNow;
|
|
lock (_syncRoot)
|
|
{
|
|
PurgeRateStates(now);
|
|
PurgeCircuitStates(now);
|
|
int open = 0;
|
|
foreach (CircuitState state in _circuitStates.Values)
|
|
{
|
|
if (state.OpenUntilUtc > now || state.HalfOpenProbe)
|
|
open += 1;
|
|
}
|
|
return new BridgeOperationalSnapshot
|
|
{
|
|
RequestsPerMinute = _requestsPerMinute,
|
|
MaximumTrackedSessions = _maximumTrackedSessions,
|
|
TrackedSessionCount = _rateStates.Count,
|
|
DisabledCommandCount = _disabledCommands.Count,
|
|
OpenCircuitCount = open,
|
|
CircuitFailureThreshold = _circuitFailureThreshold,
|
|
CircuitOpenSeconds = (int)_circuitOpenDuration.TotalSeconds
|
|
};
|
|
}
|
|
}
|
|
|
|
private bool IsCircuitBlocking(
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
DateTime now)
|
|
{
|
|
CircuitState state;
|
|
return _circuitStates.TryGetValue(
|
|
CircuitKey(commandName, stage),
|
|
out state)
|
|
&& (state.OpenUntilUtc > now || state.HalfOpenProbe);
|
|
}
|
|
|
|
private void PurgeRateStates(DateTime now)
|
|
{
|
|
List<string> expired = new List<string>();
|
|
foreach (KeyValuePair<string, RateState> item in _rateStates)
|
|
{
|
|
if (now >= item.Value.LastSeenAtUtc
|
|
&& now - item.Value.LastSeenAtUtc >= _sessionRetention)
|
|
expired.Add(item.Key);
|
|
}
|
|
foreach (string key in expired) _rateStates.Remove(key);
|
|
}
|
|
|
|
private void PurgeCircuitStates(DateTime now)
|
|
{
|
|
List<string> expired = new List<string>();
|
|
foreach (KeyValuePair<string, CircuitState> item in _circuitStates)
|
|
{
|
|
CircuitState state = item.Value;
|
|
if (state.HalfOpenProbe) continue;
|
|
if (state.OpenUntilUtc != DateTime.MinValue)
|
|
{
|
|
if (now >= state.OpenUntilUtc
|
|
&& now - state.OpenUntilUtc > _circuitFailureWindow)
|
|
expired.Add(item.Key);
|
|
}
|
|
else if (now >= state.FailureWindowStartedAtUtc
|
|
&& now - state.FailureWindowStartedAtUtc
|
|
> _circuitFailureWindow)
|
|
{
|
|
expired.Add(item.Key);
|
|
}
|
|
}
|
|
foreach (string key in expired) _circuitStates.Remove(key);
|
|
}
|
|
|
|
private void PurgeRejectionAudits(DateTime now)
|
|
{
|
|
List<string> expired = new List<string>();
|
|
foreach (KeyValuePair<string, DateTime> item in _rejectionAudits)
|
|
{
|
|
if (now >= item.Value
|
|
&& now - item.Value >= TimeSpan.FromMinutes(5))
|
|
expired.Add(item.Key);
|
|
}
|
|
foreach (string key in expired) _rejectionAudits.Remove(key);
|
|
}
|
|
|
|
private bool ShouldAuditCapacityRejection(DateTime now)
|
|
{
|
|
if (_capacityAuditWindowStartedAtUtc == DateTime.MinValue
|
|
|| (now >= _capacityAuditWindowStartedAtUtc
|
|
&& now - _capacityAuditWindowStartedAtUtc
|
|
>= TimeSpan.FromMinutes(1)))
|
|
{
|
|
_capacityAuditWindowStartedAtUtc = now;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static string CircuitKey(
|
|
string commandName,
|
|
BridgeCommandStage stage)
|
|
{
|
|
return (commandName ?? string.Empty).Trim().ToLowerInvariant()
|
|
+ "|" + (stage == BridgeCommandStage.Execute ? "execute" : "plan");
|
|
}
|
|
|
|
private static IEnumerable<string> ParseDisabledCommands(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return new string[0];
|
|
return value.Split(new[] { ',' }, StringSplitOptions.None);
|
|
}
|
|
|
|
private static int ParseBoundedInteger(
|
|
string name,
|
|
int defaultValue,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
string raw = Environment.GetEnvironmentVariable(name);
|
|
if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
|
|
int value;
|
|
if (!int.TryParse(
|
|
raw.Trim(),
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out value)
|
|
|| value < minimum
|
|
|| value > maximum)
|
|
throw Invalid("ERP 命令桥运维参数格式或范围无效:" + name);
|
|
return value;
|
|
}
|
|
|
|
private static CommandKernelException Invalid(string message)
|
|
{
|
|
return new CommandKernelException(
|
|
"bridge_operational_policy_invalid",
|
|
message,
|
|
6);
|
|
}
|
|
|
|
private sealed class RateState
|
|
{
|
|
public DateTime WindowStartedAtUtc { get; set; }
|
|
public DateTime LastSeenAtUtc { get; set; }
|
|
public int RequestCount { get; set; }
|
|
public bool LimitAuditRecorded { get; set; }
|
|
}
|
|
|
|
private sealed class CircuitState
|
|
{
|
|
public DateTime FailureWindowStartedAtUtc { get; set; }
|
|
public int FailureCount { get; set; }
|
|
public DateTime OpenUntilUtc { get; set; }
|
|
public bool HalfOpenProbe { get; set; }
|
|
}
|
|
}
|
|
}
|