Skip to content

Commit d316c51

Browse files
Deskriclaude
andcommitted
feat: add dedicated polling HttpClient with infinite timeout for long polling
Separate HttpClient with Timeout.InfiniteTimeSpan prevents TaskCanceledException during long polling when server holds connection up to 90 seconds. - Add internal IBotClientInternal interface with PollingSendRequest (explicit impl) - Add PollingProcessApi/GetUpdatesFromPolling parallel to existing ProcessApi/GetUpdates - Lazy-create polling HttpClient on first use - Implement IDisposable with _ownsHttpClient tracking for safe cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0d63ece commit d316c51

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

src/Max.BotClient/Max.BotClient.ApiExtensions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,18 @@ public static async Task ProcessApi<TParams>(
7272
ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body);
7373
await botClient.SendRequest<object>(method, path, body, cancellationToken);
7474
}
75+
76+
internal static async Task<TResult> PollingProcessApi<TParams, TDto, TResult>(
77+
this IBotClientInternal botClient,
78+
HttpMethod method,
79+
string basePath,
80+
Func<TParams> createParams,
81+
CancellationToken cancellationToken = default
82+
) where TParams : class
83+
{
84+
ApiRequestBinder.Bind(createParams(), basePath, out var path, out var body);
85+
var dto = await botClient.PollingSendRequest<TDto>(method, path, body, cancellationToken);
86+
return dto.ToResult<TDto, TResult>();
87+
}
7588
}
7689
}

src/Max.BotClient/Max.BotClient.ApiMethods.Subscriptions.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,25 @@ public static async Task<SubscribeResult> Unsubscribe(
9090

9191
return (response.Updates, response.Marker);
9292
}
93+
94+
private static async Task<(Update[], long?)> GetUpdatesFromPolling(
95+
this IBotClientInternal botClient,
96+
int? limit = null,
97+
int? timeout = null,
98+
long? marker = null,
99+
Types.UpdateType[]? types = null,
100+
CancellationToken cancellationToken = default
101+
)
102+
{
103+
var response = await botClient.PollingProcessApi<GetUpdatesParams, DTOs.GetUpdatesResponse, Types.GetUpdatesResponse>(
104+
HttpMethod.Get,
105+
"/updates",
106+
() => new GetUpdatesParams
107+
{ Limit = limit, Timeout = timeout, Marker = marker, UpdateTypes = types },
108+
cancellationToken
109+
);
110+
111+
return (response.Updates, response.Marker);
112+
}
93113
}
94-
}
114+
}

src/Max.BotClient/Max.BotClient.Polling.cs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ public static void StartReceiving(
4848
Func<IBotClient, Exception, CancellationToken, Task>? errorHandler = null,
4949
ReceiverOptions? options = null,
5050
CancellationToken cancellationToken = default
51-
) => Task.Run(() =>
51+
) => Task.Run(() =>
5252
botClient.ReceiveAsync(
53-
updateHandler,
54-
errorHandler,
55-
options,
53+
updateHandler,
54+
errorHandler,
55+
options,
5656
cancellationToken
57-
),
57+
),
5858
cancellationToken
5959
);
6060

@@ -83,7 +83,7 @@ public static async Task ReceiveAsync(
8383
{
8484
try
8585
{
86-
var (_, newMarker) = await botClient.GetUpdates(
86+
var (_, newMarker) = await botClient.Update(
8787
limit: 1,
8888
timeout: 0,
8989
marker: null,
@@ -109,7 +109,7 @@ public static async Task ReceiveAsync(
109109

110110
try
111111
{
112-
var result = await botClient.GetUpdates(
112+
var result = await botClient.Update(
113113
limit: options.Limit,
114114
timeout: options.Timeout,
115115
marker: marker,
@@ -150,5 +150,32 @@ public static async Task ReceiveAsync(
150150
}
151151
}
152152
}
153+
154+
private static Task<(Update[], long?)> Update(
155+
this IBotClient botClient,
156+
int? limit = null,
157+
int? timeout = null,
158+
long? marker = null,
159+
Types.UpdateType[]? types = null,
160+
CancellationToken cancellationToken = default
161+
)
162+
{
163+
if (botClient is IBotClientInternal internalBotClient)
164+
return internalBotClient.GetUpdatesFromPolling(
165+
limit,
166+
timeout,
167+
marker,
168+
types,
169+
cancellationToken
170+
);
171+
172+
return botClient.GetUpdates(
173+
limit,
174+
timeout,
175+
marker,
176+
types,
177+
cancellationToken
178+
);
179+
}
153180
}
154-
}
181+
}

src/Max.BotClient/Max.BotClient.cs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,23 @@ Task<TResponse> SendRequest<TResponse>(
1919
);
2020
}
2121

22-
public partial class BotClient : IBotClient
22+
internal interface IBotClientInternal : IBotClient
23+
{
24+
Task<TResponse> PollingSendRequest<TResponse>(
25+
HttpMethod method,
26+
string path,
27+
object? body = null,
28+
CancellationToken cancellationToken = default
29+
);
30+
}
31+
32+
public partial class BotClient : IBotClientInternal, IDisposable
2333
{
2434
private readonly BotClientOptions _options;
2535
private readonly HttpClient _httpClient;
36+
private readonly bool _ownsHttpClient;
37+
private HttpClient? _pollingHttpClient;
38+
private bool _disposed;
2639

2740
public string Token => _options.Token;
2841
public CancellationToken GlobalCancelToken { get; }
@@ -36,6 +49,7 @@ public BotClient(
3649
_options = options ?? throw new ArgumentNullException(nameof(options));
3750
GlobalCancelToken = cancellationToken;
3851

52+
_ownsHttpClient = httpClient == null;
3953
_httpClient = httpClient ?? new HttpClient();
4054
_httpClient.BaseAddress = new Uri(_options.ApiUrl);
4155
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token);
@@ -54,6 +68,34 @@ public async Task<TResponse> SendRequest<TResponse>(
5468
string path,
5569
object? body = null,
5670
CancellationToken cancellationToken = default
71+
) => await SendRequestCore<TResponse>(_httpClient, method, path, body, cancellationToken);
72+
73+
async Task<TResponse> IBotClientInternal.PollingSendRequest<TResponse>(
74+
HttpMethod method,
75+
string path,
76+
object? body,
77+
CancellationToken cancellationToken
78+
)
79+
{
80+
if (_pollingHttpClient == null)
81+
{
82+
_pollingHttpClient = new HttpClient
83+
{
84+
BaseAddress = new Uri(_options.ApiUrl),
85+
Timeout = Timeout.InfiniteTimeSpan
86+
};
87+
_pollingHttpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", _options.Token);
88+
}
89+
90+
return await SendRequestCore<TResponse>(_pollingHttpClient, method, path, body, cancellationToken);
91+
}
92+
93+
private async Task<TResponse> SendRequestCore<TResponse>(
94+
HttpClient httpClient,
95+
HttpMethod method,
96+
string path,
97+
object? body,
98+
CancellationToken cancellationToken
5799
)
58100
{
59101
using var cts = CancellationTokenSource.CreateLinkedTokenSource(GlobalCancelToken, cancellationToken);
@@ -78,7 +120,7 @@ public async Task<TResponse> SendRequest<TResponse>(
78120
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
79121
}
80122

81-
using var response = await _httpClient.SendAsync(request, token);
123+
using var response = await httpClient.SendAsync(request, token);
82124
var responseBody = await response.Content.ReadAsStringAsync();
83125

84126
if (response.IsSuccessStatusCode)
@@ -98,5 +140,14 @@ public async Task<TResponse> SendRequest<TResponse>(
98140

99141
throw lastException!;
100142
}
143+
144+
public void Dispose()
145+
{
146+
if (_disposed) return;
147+
_disposed = true;
148+
149+
_pollingHttpClient?.Dispose();
150+
if (_ownsHttpClient) _httpClient.Dispose();
151+
}
101152
}
102153
}

0 commit comments

Comments
 (0)