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 dlcName = null;
string dlcIcon = null; string dlcIcon = null;
bool onSteamStore = false; 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) if (dlcStoreAppData is not null)
{ {
dlcName = dlcStoreAppData.Name; dlcName = dlcStoreAppData.Name;
@@ -277,7 +277,7 @@ internal sealed partial class SelectForm : CustomForm
string fullGameIcon = null; string fullGameIcon = null;
bool fullGameOnSteamStore = false; bool fullGameOnSteamStore = false;
StoreAppData fullGameStoreAppData = StoreAppData fullGameStoreAppData =
await SteamStore.QueryStoreAPI(fullGameAppId, true); await SteamStore.QueryStoreAPI(fullGameAppId, true, 0, null, null);
if (fullGameStoreAppData is not null) if (fullGameStoreAppData is not null)
{ {
fullGameName = fullGameStoreAppData.Name; fullGameName = fullGameStoreAppData.Name;
+2 -3
View File
@@ -81,9 +81,8 @@ internal sealed partial class TestGameForm : CustomForm
string name = await Task.Run(async () => string name = await Task.Run(async () =>
{ {
// Use a dedicated client with a neutral UA so Steam's store API doesn't reject the request. // Use an isolated client with neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = new(); using System.Net.Http.HttpClient client = HttpClientManager.CreateIsolatedClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic"; string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic";
try try
{ {
+44 -24
View File
@@ -18,6 +18,25 @@ internal static class SteamStore
private const int CooldownGame = 600; private const int CooldownGame = 600;
private const int CooldownDlc = 1200; 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) internal static async Task<HashSet<string>> ParseDlcAppIds(StoreAppData storeAppData)
=> await Task.Run(() => => await Task.Run(() =>
{ {
@@ -31,8 +50,9 @@ internal static class SteamStore
return dlcIds; 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) while (!Program.Canceled)
{ {
attempts++; attempts++;
@@ -55,13 +75,14 @@ internal static class SteamStore
if (storeAppDetails is not null) if (storeAppDetails is not null)
{ {
StoreAppData data = storeAppDetails.Data; StoreAppData data = storeAppDetails.Data;
if (data?.Name is not null)
gameName = data.Name;
if (!storeAppDetails.Success) if (!storeAppDetails.Success)
{ {
#if DEBUG #if DEBUG
DebugForm.Current.Log( DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + FormatErrorLog(attempts, appId, gameName, isDlc, "Query unsuccessful", parentGameName, parentGameAppId),
(isDlc ? " (DLC)" : "")
+ ": Query unsuccessful (" + app.Value.ToString(Formatting.None) + ")",
LogTextBox.Warning); LogTextBox.Warning);
#endif #endif
if (data is null) if (data is null)
@@ -78,9 +99,8 @@ internal static class SteamStore
#if DEBUG #if DEBUG
(Exception e) (Exception e)
{ {
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + DebugForm.Current.Log(
" for " + appId + (isDlc ? " (DLC)" : "") FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful serialization ({e.Message})", parentGameName, parentGameAppId));
+ ": Unsuccessful serialization (" + e.Message + ")");
} }
#else #else
{ {
@@ -90,27 +110,24 @@ internal static class SteamStore
return data; return data;
} }
#if DEBUG #if DEBUG
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + DebugForm.Current.Log(
appId + (isDlc ? " (DLC)" : "") FormatErrorLog(attempts, appId, gameName, isDlc, "Response data null", parentGameName, parentGameAppId));
+ ": Response data null (" +
app.Value.ToString(Formatting.None) + ")");
#endif #endif
} }
#if DEBUG #if DEBUG
else else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + {
appId + (isDlc ? " (DLC)" : "") DebugForm.Current.Log(
+ ": Response details null (" + FormatErrorLog(attempts, appId, gameName, isDlc, "Response details null", parentGameName, parentGameAppId));
app.Value.ToString(Formatting.None) + ")"); }
#endif #endif
} }
catch catch
#if DEBUG #if DEBUG
(Exception e) (Exception e)
{ {
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + DebugForm.Current.Log(
appId + (isDlc ? " (DLC)" : "") FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful deserialization ({e.Message})", parentGameName, parentGameAppId));
+ ": Unsuccessful deserialization (" + e.Message + ")");
} }
#else #else
{ {
@@ -119,17 +136,19 @@ internal static class SteamStore
#endif #endif
#if DEBUG #if DEBUG
else else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + appId + {
(isDlc ? " (DLC)" : "") DebugForm.Current.Log(
+ ": Response deserialization null"); FormatErrorLog(attempts, appId, gameName, isDlc, "Response deserialization null", parentGameName, parentGameAppId));
}
#endif #endif
} }
#if DEBUG #if DEBUG
else else
{
DebugForm.Current.Log( DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") + FormatErrorLog(attempts, appId, gameName, isDlc, "Null or empty response", parentGameName, parentGameAppId),
": Response null",
LogTextBox.Warning); LogTextBox.Warning);
}
#endif #endif
} }
@@ -148,7 +167,8 @@ internal static class SteamStore
if (attempts > 10) if (attempts > 10)
{ {
#if DEBUG #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 #endif
break; break;
} }
+96 -13
View File
@@ -13,23 +13,59 @@ namespace CreamInstaller.Utility;
internal static class HttpClientManager 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(); private static readonly ConcurrentDictionary<string, string> HttpContentCache = new();
internal static void Setup() internal static void Setup()
{ {
HttpClient = new(); lock (_lock)
if (CreamInstaller.Platforms.Epic.EpicStore.EpicBool)
{ {
HttpClient.DefaultRequestHeaders.UserAgent.Add(new("EpicGamesLauncher", "18.9.0-45233261+++Portal+Release-Live")); // If already set up, don't recreate to avoid socket exhaustion
CreamInstaller.Platforms.Epic.EpicStore.EpicBool = false; 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) internal static async Task<string> EnsureGet(string url)
@@ -52,16 +88,31 @@ internal static class HttpClientManager
if (e.StatusCode != HttpStatusCode.TooManyRequests) if (e.StatusCode != HttpStatusCode.TooManyRequests)
{ {
#if DEBUG #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 #endif
return null; return null;
} }
#if DEBUG #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 #endif
// do something special? // do something special?
return null; 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 #if DEBUG
catch (Exception e) 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;
}
}
} }