Files
Clint Branham a1d9550d83 fix: surface HttpClient timeouts as PveApiException(RequestTimeout)
When HttpClient.Timeout elapses, .NET throws TaskCanceledException — not
HttpRequestException — so the existing catch in PveHttpClient.SendAsync
missed it and callers got a raw stack trace. With -TimeoutSeconds now
configurable and documented, this gap became user-visible.

In .NET 5+ HttpClient surfaces transport timeouts as TaskCanceledException
with a TimeoutException inner; user-driven token cancellation does not.
Catch by that inner-type signature and rethrow as PveApiException with
HttpStatusCode.RequestTimeout, the resource path, and a message that
reports the configured timeout.

Adds SendAsync_TimeoutFires_ThrowsPveApiExceptionWithRequestTimeout which
swaps in a delaying HttpMessageHandler with a 50ms timeout to exercise
the path deterministically. Drops the redundant
DefaultSessionTimeoutIs100Seconds test (covered by
PveSessionTests.Timeout_DefaultIs100Seconds and the existing flow-through
test).

Addresses PR #61 review feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:17:31 -05:00

102 lines
3.6 KiB
C#

using System;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Exceptions;
using Xunit;
namespace PSProxmoxVE.Core.Tests.Client
{
public class PveHttpClientTimeoutTests
{
private static HttpClient GetInnerHttpClient(PveHttpClient client)
{
var field = typeof(PveHttpClient).GetField("_httpClient",
BindingFlags.Instance | BindingFlags.NonPublic)!;
return (HttpClient)field.GetValue(client)!;
}
private static void SetInnerHttpClient(PveHttpClient client, HttpClient newInner)
{
var field = typeof(PveHttpClient).GetField("_httpClient",
BindingFlags.Instance | BindingFlags.NonPublic)!;
((HttpClient)field.GetValue(client)!).Dispose();
field.SetValue(client, newInner);
}
private static PveSession NewSession()
{
return new PveSession("pve.example.com", 8006, false,
"root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
}
[Fact]
public void Constructor_UsesSessionTimeoutByDefault()
{
var session = NewSession();
session.Timeout = TimeSpan.FromSeconds(42);
using var client = new PveHttpClient(session);
Assert.Equal(TimeSpan.FromSeconds(42), GetInnerHttpClient(client).Timeout);
}
[Fact]
public void Constructor_OverrideTakesPrecedenceOverSessionTimeout()
{
var session = NewSession();
session.Timeout = TimeSpan.FromSeconds(42);
using var client = new PveHttpClient(session, TimeSpan.FromMinutes(30));
Assert.Equal(TimeSpan.FromMinutes(30), GetInnerHttpClient(client).Timeout);
}
[Fact]
public void Constructor_InfiniteTimeSpanIsAccepted()
{
var session = NewSession();
using var client = new PveHttpClient(session, Timeout.InfiniteTimeSpan);
Assert.Equal(Timeout.InfiniteTimeSpan, GetInnerHttpClient(client).Timeout);
}
[Fact]
public async Task SendAsync_TimeoutFires_ThrowsPveApiExceptionWithRequestTimeout()
{
var session = NewSession();
using var client = new PveHttpClient(session);
// Swap in an HttpClient with a delaying handler and a 50ms timeout so
// HttpClient.Timeout fires reliably without any real network.
var delayingClient = new HttpClient(new DelayingHandler(TimeSpan.FromSeconds(30)))
{
Timeout = TimeSpan.FromMilliseconds(50)
};
SetInnerHttpClient(client, delayingClient);
var ex = await Assert.ThrowsAsync<PveApiException>(() => client.GetAsync("version"));
Assert.Equal(HttpStatusCode.RequestTimeout, ex.StatusCode);
Assert.Contains("timed out", ex.Message, StringComparison.OrdinalIgnoreCase);
}
private sealed class DelayingHandler : HttpMessageHandler
{
private readonly TimeSpan _delay;
public DelayingHandler(TimeSpan delay) { _delay = delay; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
}
}