fix: improve reliability across agent, backend, and web layers

- BackendApiClient: dispose HttpRequestMessage/HttpResponseMessage, cap Retry-After delay
- EmergencyReporter: add lock for thread-safe anti-flood state access
- GeoLocationService: use static HttpClient to prevent socket exhaustion
- EnrollmentTracker: wrap fire-and-forget Task.Run in try-catch
- NtpTimeCheckService: validate NTP response length and leap indicator
- GraphTokenService: cap Retry-After delay at 120s
- NdjsonParser: gracefully skip malformed JSON event lines
- authenticatedFetch: add 30s default timeout via AbortSignal
- SignalRContext: clear retry timeout on unmount to prevent memory leak
- MCP client: add 30s request timeout

https://claude.ai/code/session_01EWzmYXFmXUaPpeksi6WGKZ
This commit is contained in:
Claude
2026-03-30 10:23:13 +00:00
parent 0af770599f
commit 2ced2e18bb
10 changed files with 254 additions and 148 deletions
@@ -137,43 +137,47 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
_logger?.Debug($"IngestEventsAsync: POST {url} — {request.Events.Count} events, {compressedContent.Length} bytes compressed");
_logger?.Verbose($"IngestEventsAsync: NDJSON {ndjsonLength} bytes → gzip {compressedContent.Length} bytes ({(ndjsonLength > 0 ? (100 - compressedContent.Length * 100 / ndjsonLength) : 0)}% reduction)");
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(compressedContent)
};
httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson");
httpRequest.Content.Headers.Add("Content-Encoding", "gzip");
// Add TenantId header so the backend can run security checks before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", request.TenantId);
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
})
{
var httpResponse = await _httpClient.SendAsync(httpRequest);
ThrowOnAuthFailure(httpResponse);
httpResponse.EnsureSuccessStatusCode();
httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson");
httpRequest.Content.Headers.Add("Content-Encoding", "gzip");
var responseJson = await httpResponse.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
// Add TenantId header so the backend can run security checks before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", request.TenantId);
return response;
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed);
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
using (var httpResponse = await _httpClient.SendAsync(httpRequest))
{
ThrowOnAuthFailure(httpResponse);
httpResponse.EnsureSuccessStatusCode();
var responseJson = await httpResponse.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
return response;
}
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
@@ -189,54 +193,58 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
// Retry on 503 (backend signals transient device validation failure)
const int maxAttempts = 3;
const int maxRetryAfterSeconds = 120; // Cap Retry-After to prevent indefinite blocking
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddSecurityHeaders(httpRequest);
_logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})");
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, url))
{
var response = await _httpClient.SendAsync(httpRequest);
AddSecurityHeaders(httpRequest);
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts)
_logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})");
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
// Backend says "try again" — parse Retry-After or default to 30s
var retryAfterSeconds = 30;
if (response.Headers.TryGetValues("Retry-After", out var retryValues) &&
int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry))
using (var response = await _httpClient.SendAsync(httpRequest))
{
retryAfterSeconds = parsedRetry;
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts)
{
// Backend says "try again" — parse Retry-After or default to 30s
var retryAfterSeconds = 30;
if (response.Headers.TryGetValues("Retry-After", out var retryValues) &&
int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry))
{
retryAfterSeconds = Math.Min(parsedRetry, maxRetryAfterSeconds);
}
_logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s...");
sw.Stop();
_networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true);
await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000);
continue;
}
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<AgentConfigResponse>(responseJson);
return result;
}
_logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s...");
sw.Stop();
_networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true);
await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000);
continue;
}
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<AgentConfigResponse>(responseJson);
return result;
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed);
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
@@ -278,20 +286,23 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
var url = $"{_baseUrl}{errorEndpoint}";
var json = JsonConvert.SerializeObject(report);
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
})
{
// Security validation on the backend requires tenant ID before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", report.TenantId);
// Security validation on the backend requires tenant ID before parsing the body
httpRequest.Headers.Add("X-Tenant-Id", report.TenantId);
// Hardware headers are required for full security validation
AddSecurityHeaders(httpRequest);
// Hardware headers are required for full security validation
AddSecurityHeaders(httpRequest);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _httpClient.SendAsync(httpRequest, cts.Token);
// Response status is deliberately ignored — endpoint always returns 200
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
using (var response = await _httpClient.SendAsync(httpRequest, cts.Token))
{
// Response status is deliberately ignored — endpoint always returns 200
}
}
}
catch (Exception ex)
{
@@ -305,38 +316,41 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
var json = JsonConvert.SerializeObject(data);
var bytesUp = Encoding.UTF8.GetByteCount(json);
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
})
{
var response = await _httpClient.SendAsync(httpRequest);
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
AddSecurityHeaders(httpRequest);
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
var sw = Stopwatch.StartNew();
var failed = false;
long bytesDown = 0;
try
{
using (var response = await _httpClient.SendAsync(httpRequest))
{
ThrowOnAuthFailure(response);
response.EnsureSuccessStatusCode();
return result;
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed);
var responseJson = await response.Content.ReadAsStringAsync();
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
return result;
}
}
catch
{
failed = true;
throw;
}
finally
{
sw.Stop();
_networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed);
}
}
}
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
private readonly AgentLogger _logger;
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
private readonly object _antiFloodLock = new object();
private DateTime? _lastReportTime;
private int _reportCount;
@@ -64,34 +65,41 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
int? httpStatusCode = null,
long? sequenceNumber = null)
{
// Deduplicate by error type + status code: same failure category sent only once per session
// Deduplicate by error type + status code: same failure category sent only once per session.
// All anti-flood checks are protected by a lock to prevent race conditions when
// TrySendAsync is called concurrently (fire-and-forget from multiple threads).
var key = $"{errorType}:{httpStatusCode}";
int currentReportCount;
if (_reportCount >= MaxReportsPerSession)
lock (_antiFloodLock)
{
_logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
return;
}
if (_reportCount >= MaxReportsPerSession)
{
_logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
return;
}
if (_reportedKeys.Contains(key))
{
_logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}");
return;
}
if (_reportedKeys.Contains(key))
{
_logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}");
return;
}
if (_lastReportTime != null &&
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
return;
}
if (_lastReportTime != null &&
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
{
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
return;
}
_reportedKeys.Add(key);
_reportCount++;
_lastReportTime = DateTime.UtcNow;
_reportedKeys.Add(key);
_reportCount++;
_lastReportTime = DateTime.UtcNow;
currentReportCount = _reportCount;
}
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
_logger?.Warning($"[EmergencyChannel] Sending report {_reportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
_logger?.Warning($"[EmergencyChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
var report = new AgentErrorReport
{
@@ -105,7 +113,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
Timestamp = DateTime.UtcNow
};
await _apiClient.ReportAgentErrorAsync(report);
try
{
await _apiClient.ReportAgentErrorAsync(report);
}
catch (Exception ex)
{
// Swallow — emergency channel must never cascade failures into the caller
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
}
}
}
}
@@ -44,6 +44,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
// HttpClient is designed to be reused across requests.
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
private static HttpClient CreateSharedHttpClient()
{
var client = new HttpClient { Timeout = RequestTimeout };
client.DefaultRequestHeaders.Add("Accept", "application/json");
return client;
}
public static async Task<GeoLocationAttemptResult> GetLocationAsync(AgentLogger logger)
{
var attempt = new GeoLocationAttemptResult();
@@ -88,10 +99,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
{
logger?.Info("GeoLocation: Querying ipinfo.io...");
using (var client = new HttpClient { Timeout = RequestTimeout })
using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
var httpResponse = await client.GetAsync("https://ipinfo.io/json");
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
@@ -136,10 +145,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
{
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
using (var client = new HttpClient { Timeout = RequestTimeout })
using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
var httpResponse = await client.GetAsync("https://ifconfig.co/json");
if (!httpResponse.IsSuccessStatusCode)
{
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
@@ -47,9 +47,27 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
using (var socket = new Socket(addresses[0].AddressFamily, SocketType.Dgram, ProtocolType.Udp))
{
socket.ReceiveTimeout = ReceiveTimeoutMs;
socket.SendTimeout = ReceiveTimeoutMs;
socket.Connect(ipEndPoint);
socket.Send(ntpData);
socket.Receive(ntpData);
var received = socket.Receive(ntpData);
// Validate NTP response: must be at least 48 bytes
if (received < 48)
{
result.Error = $"Invalid NTP response: received {received} bytes (expected 48)";
logger.Warning($"NTP time check failed: {result.Error}");
return result;
}
// Check Leap Indicator (bits 6-7 of first byte): value 3 means unsynchronized
var leapIndicator = (ntpData[0] >> 6) & 3;
if (leapIndicator == 3)
{
result.Error = "NTP server is unsynchronized (Leap Indicator = 3)";
logger.Warning($"NTP time check failed: {result.Error}");
return result;
}
}
// Parse transmit timestamp from bytes 40-47 (NTP epoch: 1900-01-01)
@@ -270,7 +270,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_imeLogTracker.Start();
// Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads)
Task.Run(CollectDeviceInfo);
Task.Run(() =>
{
try
{
CollectDeviceInfo();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}");
}
});
// TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit
// laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde
@@ -288,9 +298,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
public void Stop()
{
_logger.Info("EnrollmentTracker: stopping");
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_imeLogTracker?.Stop();
_logger.Info("EnrollmentTracker: stopped");
}
@@ -61,11 +61,19 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
var events = new List<EnrollmentEvent>();
for (int i = 1; i < lines.Length; i++)
{
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(lines[i]);
if (evt != null)
try
{
NormalizeEventData(evt);
events.Add(evt);
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(lines[i]);
if (evt != null)
{
NormalizeEventData(evt);
events.Add(evt);
}
}
catch (JsonException)
{
// Skip malformed event lines rather than failing the entire batch.
// A single corrupted event should not cause data loss for valid events.
}
}
@@ -149,10 +149,13 @@ namespace AutopilotMonitor.Functions.Security
var delay = retryDelays[attempt];
// Respect Retry-After header from Azure AD / Graph throttling
// Respect Retry-After header from Azure AD / Graph throttling, but cap
// at 120s to prevent a misbehaving server from blocking indefinitely
if (response.Headers.RetryAfter?.Delta is TimeSpan retryAfterDelta && retryAfterDelta > delay)
{
delay = retryAfterDelta;
delay = retryAfterDelta > TimeSpan.FromSeconds(120)
? TimeSpan.FromSeconds(120)
: retryAfterDelta;
}
_logger.LogInformation(
@@ -1,9 +1,16 @@
const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net';
/** Default timeout for backend API requests (30 seconds) */
const API_TIMEOUT_MS = 30_000;
/**
* Per-request token store. The MCP request handler sets the current user's
* Bearer token before tool execution, and apiFetch reads it to pass through
* to the backend API.
*
* NOTE: This is safe in the MCP server context because each MCP request is
* handled sequentially within a session — the token is set before tool
* execution and read during it, with no concurrent overlap.
*/
let _currentToken: string | undefined;
@@ -28,7 +35,11 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise<unknow
'X-Client-Source': 'mcp',
...((options.headers as Record<string, string>) ?? {}),
};
const res = await fetch(url, { ...options, headers });
// Apply timeout to prevent hanging on unresponsive backend
const signal = options.signal ?? AbortSignal.timeout(API_TIMEOUT_MS);
const res = await fetch(url, { ...options, headers, signal });
if (!res.ok) {
const text = await res.text();
throw new Error(`API error ${res.status}: ${text}`);
@@ -30,6 +30,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
const joinedGroupsRef = useRef<Set<string>>(new Set());
const [joinedGroups, setJoinedGroups] = useState<string[]>([]);
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const maxRetries = 3;
const syncJoinedGroups = useCallback(() => {
@@ -135,7 +136,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
if (retryCountRef.current < maxRetries) {
retryCountRef.current++;
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30000);
setTimeout(startConnection, delay);
retryTimeoutRef.current = setTimeout(startConnection, delay);
} else {
console.error('[SignalR] Max retries reached. Connection failed.');
trackEvent("signalr_connection_failed");
@@ -147,6 +148,11 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
// Cleanup only when provider unmounts (app closes)
return () => {
// Clear any pending retry timeout to prevent reconnection after unmount
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (connectionRef.current) {
connectionRef.current.stop();
connectionRef.current = null;
@@ -5,8 +5,15 @@
* request once. If the retry also fails with 401, a `TokenExpiredError` is
* thrown so callers can show a clear "session expired" message instead of a
* generic backend error.
*
* All requests are subject to a default timeout (30s) to prevent hanging
* indefinitely on unresponsive backends. Callers can override the timeout
* by passing their own AbortSignal via init.signal.
*/
/** Default request timeout in milliseconds */
const DEFAULT_TIMEOUT_MS = 30_000;
export class TokenExpiredError extends Error {
constructor() {
super('Your session has expired. Please reload the page to sign in again.');
@@ -29,7 +36,10 @@ export async function authenticatedFetch(
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${token}`);
const response = await fetch(url, { ...init, headers });
// Apply a default timeout unless the caller already provided an AbortSignal
const signal = init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
const response = await fetch(url, { ...init, headers, signal });
if (response.status === 401) {
// Force MSAL to bypass its cache and obtain a fresh token.
@@ -41,7 +51,8 @@ export async function authenticatedFetch(
const retryHeaders = new Headers(init?.headers);
retryHeaders.set('Authorization', `Bearer ${freshToken}`);
const retryResponse = await fetch(url, { ...init, headers: retryHeaders });
const retrySignal = init?.signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
const retryResponse = await fetch(url, { ...init, headers: retryHeaders, signal: retrySignal });
if (retryResponse.status === 401) {
throw new TokenExpiredError();
}