diff --git a/CreamInstaller/Forms/SelectForm.cs b/CreamInstaller/Forms/SelectForm.cs index 4e5c925..21873ae 100644 --- a/CreamInstaller/Forms/SelectForm.cs +++ b/CreamInstaller/Forms/SelectForm.cs @@ -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; diff --git a/CreamInstaller/Forms/TestGameForm.cs b/CreamInstaller/Forms/TestGameForm.cs index 27e86a6..5d27d1a 100644 --- a/CreamInstaller/Forms/TestGameForm.cs +++ b/CreamInstaller/Forms/TestGameForm.cs @@ -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 { diff --git a/CreamInstaller/Platforms/Steam/SteamStore.cs b/CreamInstaller/Platforms/Steam/SteamStore.cs index 34914b5..3507b8e 100644 --- a/CreamInstaller/Platforms/Steam/SteamStore.cs +++ b/CreamInstaller/Platforms/Steam/SteamStore.cs @@ -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> ParseDlcAppIds(StoreAppData storeAppData) => await Task.Run(() => { @@ -31,8 +50,9 @@ internal static class SteamStore return dlcIds; }); - internal static async Task QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0) + internal static async Task 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; } diff --git a/CreamInstaller/Utility/HttpClientManager.cs b/CreamInstaller/Utility/HttpClientManager.cs index ffca425..6ae72b1 100644 --- a/CreamInstaller/Utility/HttpClientManager.cs +++ b/CreamInstaller/Utility/HttpClientManager.cs @@ -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 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 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(); + /// + /// Creates a new HttpClient for isolated/one-off use cases. + /// The caller is responsible for disposing the returned client. + /// + 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; + } + } } \ No newline at end of file