Improved HTTP timeout configuration

This commit is contained in:
Thorsten Sommer 2026-05-21 16:21:39 +02:00
parent 5c4778d4c0
commit 6c72749658
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
12 changed files with 123 additions and 97 deletions

View File

@ -89,8 +89,10 @@ public static class IImageSourceExtensions
case ContentImageSource.URL:
{
using var httpClient = new HttpClient();
using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, token);
using var httpClient = ExternalHttpClientTimeout.CreateHttpClient();
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
using var response = await httpClient.GetAsync(image.Source, HttpCompletionOption.ResponseHeadersRead, timeoutToken);
if(response.IsSuccessStatusCode)
{
// Read the length of the content:
@ -101,7 +103,7 @@ public static class IImageSourceExtensions
return (success: false, string.Empty);
}
var bytes = await response.Content.ReadAsByteArrayAsync(token);
var bytes = await response.Content.ReadAsByteArrayAsync(timeoutToken);
return (success: true, Convert.ToBase64String(bytes));
}

View File

@ -260,9 +260,9 @@ CONFIG["SETTINGS"] = {}
-- Examples are: "CmdOrControl+Shift+D", "Alt+F9", "F8"
-- CONFIG["SETTINGS"]["DataApp.ShortcutVoiceRecording"] = "CmdOrControl+1"
-- Configure the HTTP timeout for requests to LLM providers, in seconds.
-- Configure the HTTP timeout for external requests, in seconds.
-- The default is 3600 (1 hour).
-- CONFIG["SETTINGS"]["DataApp.ProviderHttpTimeoutSeconds"] = 3600
-- CONFIG["SETTINGS"]["DataApp.HttpClientTimeoutSeconds"] = 3600
-- Example chat templates for this configuration:
CONFIG["CHAT_TEMPLATES"] = {}

View File

@ -24,15 +24,12 @@ namespace AIStudio.Provider;
/// </summary>
public abstract class BaseProvider : IProvider, ISecretId
{
private const int DEFAULT_HTTP_TIMEOUT_SECONDS = 3600;
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(BaseProvider).Namespace, nameof(BaseProvider));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
/// <summary>
/// The HTTP client to use it for all requests.
/// </summary>
protected readonly HttpClient HttpClient = new();
protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient();
/// <summary>
/// The logger to use.
@ -77,7 +74,6 @@ public abstract class BaseProvider : IProvider, ISecretId
// Set the base URL:
this.HttpClient.BaseAddress = new(url);
this.HttpClient.Timeout = GetProviderHttpTimeout();
}
#region Handling of IProvider, which all providers must implement
@ -145,13 +141,7 @@ public abstract class BaseProvider : IProvider, ISecretId
if (token.IsCancellationRequested)
return false;
if (exception is TimeoutException)
return true;
if (exception is OperationCanceledException)
return true;
return exception.InnerException is not null && this.IsTimeoutException(exception.InnerException, token);
return ExternalHttpClientTimeout.IsTimeoutException(exception, token);
}
protected Task SendTimeoutError(string action) => MessageBus.INSTANCE.SendError(new(
@ -160,38 +150,9 @@ public abstract class BaseProvider : IProvider, ISecretId
TB("The request to the LLM provider '{0}' (type={1}) timed out after {2} while {3}. Please try again or check whether the provider is still responding."),
this.InstanceName,
this.Provider,
GetProviderHttpTimeoutDescription(),
ExternalHttpClientTimeout.GetTimeoutDescription(),
action)));
private static TimeSpan GetProviderHttpTimeout()
{
var seconds = SETTINGS_MANAGER.ConfigurationData.App.ProviderHttpTimeoutSeconds;
if (seconds <= 0)
seconds = DEFAULT_HTTP_TIMEOUT_SECONDS;
return TimeSpan.FromSeconds(seconds);
}
private static string GetProviderHttpTimeoutDescription()
{
var timeout = GetProviderHttpTimeout();
if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0)
{
var hours = (int)timeout.TotalHours;
return hours == 1 ? "1 hour" : $"{hours} hours";
}
if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0)
{
var minutes = (int)timeout.TotalMinutes;
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = (int)timeout.TotalSeconds;
return seconds == 1 ? "1 second" : $"{seconds} seconds";
}
protected async Task<string?> GetModelLoadingSecretKey(SecretStoreType storeType, string? apiKeyProvisional = null, bool isTryingSecret = false) => apiKeyProvisional switch
{
not null => apiKeyProvisional,
@ -266,12 +227,14 @@ public abstract class BaseProvider : IProvider, ISecretId
/// Sends a request and handles rate limiting by exponential backoff.
/// </summary>
/// <param name="requestBuilder">A function that builds the request.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="userCancellationToken">The user cancellation token.</param>
/// <param name="requestCancellationToken">The token to use for the HTTP request.</param>
/// <returns>The status object of the request.</returns>
private async Task<HttpRateLimitedStreamResult> SendRequest(Func<Task<HttpRequestMessage>> requestBuilder, CancellationToken token = default)
private async Task<HttpRateLimitedStreamResult> SendRequest(Func<Task<HttpRequestMessage>> requestBuilder, CancellationToken userCancellationToken = default, CancellationToken requestCancellationToken = default)
{
const int MAX_RETRIES = 6;
const double RETRY_DELAY_SECONDS = 4;
var effectiveCancellationToken = requestCancellationToken.CanBeCanceled ? requestCancellationToken : userCancellationToken;
var retry = 0;
var response = default(HttpResponseMessage);
@ -291,9 +254,9 @@ public abstract class BaseProvider : IProvider, ISecretId
HttpResponseMessage nextResponse;
try
{
nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
nextResponse = await this.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, effectiveCancellationToken);
}
catch (Exception e) when (this.IsTimeoutException(e, token))
catch (Exception e) when (this.IsTimeoutException(e, userCancellationToken))
{
await this.SendTimeoutError("waiting for the chat response");
this.logger.LogError(e, "Timed out while sending a streaming request to provider '{ProviderInstanceName}' (provider={ProviderType}).", this.InstanceName, this.Provider);
@ -306,7 +269,7 @@ public abstract class BaseProvider : IProvider, ISecretId
break;
}
var errorBody = await nextResponse.Content.ReadAsStringAsync(token);
var errorBody = await nextResponse.Content.ReadAsStringAsync(effectiveCancellationToken);
if (nextResponse.StatusCode is HttpStatusCode.Forbidden)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Block, string.Format(TB("We tried to communicate with the LLM provider '{0}' (type={1}). You might not be able to use this provider from your location. The provider message is: '{2}'"), this.InstanceName, this.Provider, nextResponse.ReasonPhrase)));
@ -372,7 +335,7 @@ public abstract class BaseProvider : IProvider, ISecretId
timeSeconds = 90;
this.logger.LogDebug("Failed request with status code {ResponseStatusCode} (message = '{ErrorMessage}'). Retrying in {TimeSeconds:0.00} seconds.", nextResponse.StatusCode, errorMessage, timeSeconds);
await Task.Delay(TimeSpan.FromSeconds(timeSeconds), token);
await Task.Delay(TimeSpan.FromSeconds(timeSeconds), effectiveCancellationToken);
}
if(retry >= MAX_RETRIES || !string.IsNullOrWhiteSpace(errorMessage))
@ -399,10 +362,12 @@ public abstract class BaseProvider : IProvider, ISecretId
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
try
{
// Send the request using exponential backoff:
var responseData = await this.SendRequest(requestBuilder, token);
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"The {providerName} chat completion failed: {responseData.ErrorMessage}");
@ -410,7 +375,7 @@ public abstract class BaseProvider : IProvider, ISecretId
}
// Open the response stream:
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(providerStream);
@ -441,18 +406,6 @@ public abstract class BaseProvider : IProvider, ISecretId
//
while (true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if (token.IsCancellationRequested)
{
@ -467,7 +420,7 @@ public abstract class BaseProvider : IProvider, ISecretId
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
line = await streamReader.ReadLineAsync(timeoutToken);
}
catch (Exception e)
{
@ -489,6 +442,9 @@ public abstract class BaseProvider : IProvider, ISecretId
break;
}
if (line is null)
break;
// Skip empty lines:
if (string.IsNullOrWhiteSpace(line))
continue;
@ -588,10 +544,12 @@ public abstract class BaseProvider : IProvider, ISecretId
var annotationSupported = typeof(TAnnotation) != typeof(NoResponsesAnnotationStreamLine) && typeof(TAnnotation) != typeof(NoChatCompletionAnnotationStreamLine);
StreamReader? streamReader = null;
using var timeoutTokenSource = ExternalHttpClientTimeout.CreateTimeoutTokenSource(token);
var timeoutToken = timeoutTokenSource.Token;
try
{
// Send the request using exponential backoff:
var responseData = await this.SendRequest(requestBuilder, token);
var responseData = await this.SendRequest(requestBuilder, token, timeoutToken);
if(responseData.IsFailedAfterAllRetries)
{
this.logger.LogError($"The {providerName} responses call failed: {responseData.ErrorMessage}");
@ -599,7 +557,7 @@ public abstract class BaseProvider : IProvider, ISecretId
}
// Open the response stream:
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(token);
var providerStream = await responseData.Response!.Content.ReadAsStreamAsync(timeoutToken);
// Add a stream reader to read the stream, line by line:
streamReader = new StreamReader(providerStream);
@ -630,18 +588,6 @@ public abstract class BaseProvider : IProvider, ISecretId
//
while (true)
{
try
{
if(streamReader.EndOfStream)
break;
}
catch (Exception e)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("Tried to stream the LLM provider '{0}' answer. There were some problems with the stream. The message is: '{1}'"), this.InstanceName, e.Message)));
this.logger.LogWarning($"Failed to read the end-of-stream state from {providerName} '{this.InstanceName}': {e.Message}");
break;
}
// Check if the token is canceled:
if (token.IsCancellationRequested)
{
@ -656,7 +602,7 @@ public abstract class BaseProvider : IProvider, ISecretId
string? line;
try
{
line = await streamReader.ReadLineAsync(token);
line = await streamReader.ReadLineAsync(timeoutToken);
}
catch (Exception e)
{
@ -678,6 +624,9 @@ public abstract class BaseProvider : IProvider, ISecretId
break;
}
if (line is null)
break;
// Skip empty lines:
if (string.IsNullOrWhiteSpace(line))
continue;

View File

@ -161,4 +161,4 @@ public sealed class ProviderHelmholtz() : BaseProvider(LLMProviders.HELMHOLTZ, "
return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message);
}
}
}
}

View File

@ -196,4 +196,4 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
return FailedModelLoadResult(ModelLoadFailureReason.PROVIDER_UNAVAILABLE, e.Message);
}
}
}
}

View File

@ -95,9 +95,9 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
/// <summary>
/// The HTTP timeout in seconds for requests to LLM providers.
/// The HTTP timeout in seconds for external HTTP clients.
/// </summary>
public int ProviderHttpTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ProviderHttpTimeoutSeconds, 3600);
public int HttpClientTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HttpClientTimeoutSeconds, ExternalHttpClientTimeout.DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS);
/// <summary>
/// Should the user be allowed to add providers?

View File

@ -23,10 +23,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
}
};
protected readonly HttpClient HttpClient = new()
{
BaseAddress = new Uri($"{dataSource.Hostname}:{dataSource.Port}"),
};
protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}"));
protected string SecurityToken = string.Empty;

View File

@ -0,0 +1,77 @@
using AIStudio.Settings;
namespace AIStudio.Tools;
/// <summary>
/// Provides utility methods to standardize the management of HTTP client timeouts
/// across various components in the application.
/// </summary>
public static class ExternalHttpClientTimeout
{
public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600;
public static HttpClient CreateHttpClient(Uri? baseAddress = null)
{
var httpClient = new HttpClient();
Configure(httpClient, baseAddress);
return httpClient;
}
public static string GetTimeoutDescription()
{
var timeout = GetTimeout();
if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0)
{
var hours = (int)timeout.TotalHours;
return hours == 1 ? "1 hour" : $"{hours} hours";
}
if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0)
{
var minutes = (int)timeout.TotalMinutes;
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = (int)timeout.TotalSeconds;
return seconds == 1 ? "1 second" : $"{seconds} seconds";
}
public static CancellationTokenSource CreateTimeoutTokenSource(CancellationToken cancellationToken)
{
var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutTokenSource.CancelAfter(GetTimeout());
return timeoutTokenSource;
}
public static bool IsTimeoutException(Exception exception, CancellationToken userCancellationToken = default)
{
if (userCancellationToken.IsCancellationRequested)
return false;
if (exception is TimeoutException)
return true;
if (exception is OperationCanceledException)
return true;
return exception.InnerException is not null && IsTimeoutException(exception.InnerException, userCancellationToken);
}
private static TimeSpan GetTimeout()
{
var settingsManager = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
var seconds = settingsManager.ConfigurationData.App.HttpClientTimeoutSeconds;
if (seconds <= 0)
seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS;
return TimeSpan.FromSeconds(seconds);
}
private static void Configure(HttpClient httpClient, Uri? baseAddress = null)
{
httpClient.Timeout = GetTimeout();
if (baseAddress is not null)
httpClient.BaseAddress = baseAddress;
}
}

View File

@ -172,8 +172,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// Config: timeout for HTTP requests to providers
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ProviderHttpTimeoutSeconds, this.Id, settingsTable, dryRun);
// Config: timeout for external HTTP requests
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun);
// Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);

View File

@ -15,7 +15,7 @@ public static partial class PluginFactory
var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl;
var downloadUrl = $"{serverUrl}/{configPlugId}.zip";
using var http = new HttpClient();
using var http = ExternalHttpClientTimeout.CreateHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
@ -52,7 +52,7 @@ public static partial class PluginFactory
try
{
await LockHotReloadAsync();
using var httpClient = new HttpClient();
using var httpClient = ExternalHttpClientTimeout.CreateHttpClient();
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{

View File

@ -245,8 +245,8 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the provider HTTP timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ProviderHttpTimeoutSeconds, AVAILABLE_PLUGINS))
// Check for the external HTTP client timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if audit is required before it can be activated

View File

@ -2,6 +2,7 @@
- Released the voice recording and transcription for all users. You no longer need to enable a preview feature to configure transcription providers, select a transcription provider, or use dictation.
- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users.
- Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually.
- Added an option to configure the timeout setting for all requests. This is useful when you have a slow network connection, or you have to work with slow AI servers. It is also possible to configure this timeout for an entire organization using configuration plugins.
- Added the username to the information page to make organization support easier when users share their screen.
- Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base.
- Improved the Pandoc management and detection process to make it more reliable.