Improve HTTP Client Manager / Improved Debug Error Messaging

- Fixed issues with how HttpClient was being created and managed, which could lead to socket leaks and connection problems. Now the app reuses a single shared HttpClient instead of creating new ones repeatedly, and properly manages the underlying connection handler.
- Improved connection handling with better timeouts, safer pooling settings, and better handling of network errors and DNS changes.
- Improved Debug Error messaging for failed SteamCMD querys
This commit is contained in:
Frog
2026-06-02 01:13:11 -07:00
parent 668463f687
commit 0dbd35ed0c
4 changed files with 144 additions and 42 deletions
+2 -2
View File
@@ -247,7 +247,7 @@ internal sealed partial class SelectForm : CustomForm
string dlcName = null;
string dlcIcon = null;
bool onSteamStore = false;
StoreAppData dlcStoreAppData = await SteamStore.QueryStoreAPI(dlcAppId, true);
StoreAppData dlcStoreAppData = await SteamStore.QueryStoreAPI(dlcAppId, true, 0, name, appId);
if (dlcStoreAppData is not null)
{
dlcName = dlcStoreAppData.Name;
@@ -277,7 +277,7 @@ internal sealed partial class SelectForm : CustomForm
string fullGameIcon = null;
bool fullGameOnSteamStore = false;
StoreAppData fullGameStoreAppData =
await SteamStore.QueryStoreAPI(fullGameAppId, true);
await SteamStore.QueryStoreAPI(fullGameAppId, true, 0, null, null);
if (fullGameStoreAppData is not null)
{
fullGameName = fullGameStoreAppData.Name;
+2 -3
View File
@@ -81,9 +81,8 @@ internal sealed partial class TestGameForm : CustomForm
string name = await Task.Run(async () =>
{
// Use a dedicated client with a neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
// Use an isolated client with neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = HttpClientManager.CreateIsolatedClient();
string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic";
try
{
+44 -24
View File
@@ -18,6 +18,25 @@ internal static class SteamStore
private const int CooldownGame = 600;
private const int CooldownDlc = 1200;
#if DEBUG
private static string FormatErrorLog(int attempts, string appId, string gameName, bool isDlc, string reason,
string parentGameName = null, string parentGameAppId = null)
{
if (isDlc && parentGameName != null && parentGameAppId != null)
{
return $"[SteamQuery][Attempt {attempts}][FAILED]\n" +
$"BaseGame: \"{parentGameName}\" ({parentGameAppId})\n" +
$"DLC: \"{gameName}\" ({appId})\n" +
$"Type: DLC\n" +
$"Reason: {reason}\n" +
"-------";
}
string type = isDlc ? "DLC" : "Game";
return $"[SteamQuery][Attempt {attempts}][FAILED] AppId: {appId} | Name: \"{gameName}\" | Type: {type} | Reason: {reason}";
}
#endif
internal static async Task<HashSet<string>> ParseDlcAppIds(StoreAppData storeAppData)
=> await Task.Run(() =>
{
@@ -31,8 +50,9 @@ internal static class SteamStore
return dlcIds;
});
internal static async Task<StoreAppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0)
internal static async Task<StoreAppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0, string parentGameName = null, string parentGameAppId = null)
{
string gameName = "Unknown";
while (!Program.Canceled)
{
attempts++;
@@ -55,13 +75,14 @@ internal static class SteamStore
if (storeAppDetails is not null)
{
StoreAppData data = storeAppDetails.Data;
if (data?.Name is not null)
gameName = data.Name;
if (!storeAppDetails.Success)
{
#if DEBUG
DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Query unsuccessful (" + app.Value.ToString(Formatting.None) + ")",
FormatErrorLog(attempts, appId, gameName, isDlc, "Query unsuccessful", parentGameName, parentGameAppId),
LogTextBox.Warning);
#endif
if (data is null)
@@ -78,9 +99,8 @@ internal static class SteamStore
#if DEBUG
(Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts +
" for " + appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful serialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
@@ -90,27 +110,24 @@ internal static class SteamStore
return data;
}
#if DEBUG
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Response data null (" +
app.Value.ToString(Formatting.None) + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response data null", parentGameName, parentGameAppId));
#endif
}
#if DEBUG
else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Response details null (" +
app.Value.ToString(Formatting.None) + ")");
{
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response details null", parentGameName, parentGameAppId));
}
#endif
}
catch
#if DEBUG
(Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful deserialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
@@ -119,17 +136,19 @@ internal static class SteamStore
#endif
#if DEBUG
else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Response deserialization null");
{
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response deserialization null", parentGameName, parentGameAppId));
}
#endif
}
#if DEBUG
else
{
DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") +
": Response null",
FormatErrorLog(attempts, appId, gameName, isDlc, "Null or empty response", parentGameName, parentGameAppId),
LogTextBox.Warning);
}
#endif
}
@@ -148,7 +167,8 @@ internal static class SteamStore
if (attempts > 10)
{
#if DEBUG
DebugForm.Current.Log("Failed to query Steam store after 10 tries: " + appId);
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Maximum retry attempts exceeded (10)", parentGameName, parentGameAppId));
#endif
break;
}
+96 -13
View File
@@ -13,23 +13,59 @@ namespace CreamInstaller.Utility;
internal static class HttpClientManager
{
internal static HttpClient HttpClient;
private static readonly object _lock = new();
private static HttpClient _httpClient;
private static SocketsHttpHandler _handler;
internal static HttpClient HttpClient
{
get
{
lock (_lock)
{
return _httpClient;
}
}
}
private static readonly ConcurrentDictionary<string, string> HttpContentCache = new();
internal static void Setup()
{
HttpClient = new();
if (CreamInstaller.Platforms.Epic.EpicStore.EpicBool)
lock (_lock)
{
HttpClient.DefaultRequestHeaders.UserAgent.Add(new("EpicGamesLauncher", "18.9.0-45233261+++Portal+Release-Live"));
CreamInstaller.Platforms.Epic.EpicStore.EpicBool = false;
// If already set up, don't recreate to avoid socket exhaustion
if (_httpClient != null)
return;
// Create a SocketsHttpHandler with proper pooling and lifecycle settings
_handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(10), // Rotate connections every 10 minutes to respect DNS changes
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), // Close idle connections after 2 minutes
MaxConnectionsPerServer = 10, // Reasonable concurrent connection limit
EnableMultipleHttp2Connections = true
};
// Create HttpClient with the handler
_httpClient = new HttpClient(_handler, disposeHandler: false)
{
Timeout = TimeSpan.FromSeconds(30) // 30 second timeout for all requests
};
// Set user agent based on context
if (CreamInstaller.Platforms.Epic.EpicStore.EpicBool)
{
_httpClient.DefaultRequestHeaders.UserAgent.Add(new("EpicGamesLauncher", "18.9.0-45233261+++Portal+Release-Live"));
CreamInstaller.Platforms.Epic.EpicStore.EpicBool = false;
}
else
{
_httpClient.DefaultRequestHeaders.UserAgent.Add(new(Program.Name, Program.Version));
}
_httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new(CultureInfo.CurrentCulture.ToString()));
}
else
{
HttpClient.DefaultRequestHeaders.UserAgent.Add(new(Program.Name, Program.Version));
}
HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new(CultureInfo.CurrentCulture.ToString()));
}
internal static async Task<string> EnsureGet(string url)
@@ -52,16 +88,31 @@ internal static class HttpClientManager
if (e.StatusCode != HttpStatusCode.TooManyRequests)
{
#if DEBUG
DebugForm.Current.Log("Get request failed to " + url + ": " + e, LogTextBox.Warning);
string statusInfo = e.StatusCode.HasValue ? $" (HTTP {(int)e.StatusCode.Value})" : "";
DebugForm.Current.Log($"Get request failed to {url}{statusInfo}: {e}", LogTextBox.Warning);
#endif
return null;
}
#if DEBUG
DebugForm.Current.Log("Too many requests to " + url, LogTextBox.Error);
DebugForm.Current.Log($"Too many requests to {url} (HTTP 429 - Rate Limited)", LogTextBox.Error);
#endif
// do something special?
return null;
}
catch (TaskCanceledException)
{
#if DEBUG
DebugForm.Current.Log("Get request timed out for " + url, LogTextBox.Warning);
#endif
return null;
}
catch (OperationCanceledException)
{
#if DEBUG
DebugForm.Current.Log("Get request was cancelled for " + url, LogTextBox.Warning);
#endif
return null;
}
#if DEBUG
catch (Exception e)
{
@@ -88,5 +139,37 @@ internal static class HttpClientManager
}
}
internal static void Dispose() => HttpClient?.Dispose();
/// <summary>
/// Creates a new HttpClient for isolated/one-off use cases.
/// The caller is responsible for disposing the returned client.
/// </summary>
internal static HttpClient CreateIsolatedClient(TimeSpan? timeout = null)
{
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
MaxConnectionsPerServer = 5
};
var client = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
return client;
}
internal static void Dispose()
{
lock (_lock)
{
_httpClient?.Dispose();
_httpClient = null;
_handler?.Dispose();
_handler = null;
}
}
}