mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-06-12 19:11:25 -07:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user