From 06de1127b0f1735b392f288cc88dcaeb1c33c3dd Mon Sep 17 00:00:00 2001 From: maxisoft Date: Wed, 22 May 2024 11:14:41 +0200 Subject: [PATCH 1/3] Fix crash on Reddit request failure This commit introduces error handling for scenarios where a Reddit request fails due to a 403 Forbidden response or other server errors. The changes fix the retry mechanism with exponential backoff and improved exception handling to ensure that the plugin does not crash and can handle Reddit server errors gracefully. --- ASFFreeGames/Reddit/RedditHelper.cs | 45 +++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index be5c0b9..20a51c0 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -38,16 +38,13 @@ public static async ValueTask> GetGames(Cancellatio return result; } - JsonNode jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false) ?? JsonNode.Parse("{}")!; + JsonNode jsonPayload; try { - if ((jsonPayload["kind"]?.GetValue() != "Listing") || - jsonPayload["data"] is null) { - return result; - } + jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false); } - catch (Exception e) when (e is FormatException or InvalidOperationException) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo("invalid json"); + catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); return result; } @@ -171,28 +168,46 @@ internal static RedditGameEntry[] LoadMessages(JsonNode children) { /// A JSON object response or null if failed. /// Thrown when Reddit returns a server error. /// This method is based on this GitHub issue: https://github.com/maxisoft/ASFFreeGames/issues/28 - private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken, uint retry = 5) { + private static async ValueTask GetPayload(WebBrowser webBrowser, CancellationToken cancellationToken, uint retry = 5) { StreamResponse? stream = null; for (int t = 0; t < retry; t++) { try { - stream = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500, cancellationToken: cancellationToken).ConfigureAwait(false); + stream = await webBrowser.UrlGetToStream(GetUrl(), rateLimitingDelay: 500, maxTries: 1, cancellationToken: cancellationToken).ConfigureAwait(false); if (stream?.Content is null) { - throw new RedditServerException("Reddit server error: content is null", stream?.StatusCode ?? HttpStatusCode.InternalServerError); + throw new RedditServerException("content is null", stream?.StatusCode ?? HttpStatusCode.InternalServerError); } if (stream.StatusCode.IsServerErrorCode()) { - throw new RedditServerException($"Reddit server error: {stream.StatusCode}", stream.StatusCode); + throw new RedditServerException($"server error code is {stream.StatusCode}", stream.StatusCode); + } + + JsonNode? res = await ParseJsonNode(stream, cancellationToken).ConfigureAwait(false); + + if (res is null) { + throw new RedditServerException("empty response", stream.StatusCode); } - return await ParseJsonNode(stream, cancellationToken).ConfigureAwait(false); + try { + if ((res["kind"]?.GetValue() != "Listing") || + res["data"] is null) { + throw new RedditServerException("invalid response", stream.StatusCode); + } + } + catch (Exception e) when (e is FormatException or InvalidOperationException) { + throw new RedditServerException("invalid response", stream.StatusCode); + } + + return res; } catch (Exception e) when (e is JsonException or IOException or RedditServerException or HttpRequestException) { - // If no RedditServerException was thrown, re-throw the original Exception + // If it's the last retry, re-throw the original Exception if (t + 1 == retry) { throw; } + + cancellationToken.ThrowIfCancellationRequested(); } finally { if (stream is not null) { @@ -202,11 +217,11 @@ internal static RedditGameEntry[] LoadMessages(JsonNode children) { stream = null; } - await Task.Delay((2 << t) * 100, cancellationToken).ConfigureAwait(false); + await Task.Delay((2 << (t + 1)) * 100, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); } - return JsonNode.Parse("{}"); + return JsonNode.Parse("{}")!; } /// From e95b41b2e7f318d52a6e3eae6160a59ab1edc3ae Mon Sep 17 00:00:00 2001 From: maxisoft Date: Wed, 22 May 2024 11:27:51 +0200 Subject: [PATCH 2/3] Enhance error handling in CommandDispatcher Refactored the CommandDispatcher to better handle unexpected exceptions during command execution. The changes include a try-catch block that logs detailed information about the exception based on the VerboseLog setting in ASFFreeGamesOptions. --- ASFFreeGames/Commands/CommandDispatcher.cs | 52 +++++++++++++--------- ASFFreeGames/Reddit/RedditHelper.cs | 2 +- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/ASFFreeGames/Commands/CommandDispatcher.cs b/ASFFreeGames/Commands/CommandDispatcher.cs index 491ac83..67bde6c 100644 --- a/ASFFreeGames/Commands/CommandDispatcher.cs +++ b/ASFFreeGames/Commands/CommandDispatcher.cs @@ -5,39 +5,51 @@ using ArchiSteamFarm.Steam; using ASFFreeGames.Commands.GetIp; using ASFFreeGames.Configurations; -using Maxisoft.ASF; namespace ASFFreeGames.Commands { // Implement the IBotCommand interface - internal sealed class CommandDispatcher : IBotCommand { + internal sealed class CommandDispatcher(ASFFreeGamesOptions options) : IBotCommand { // Declare a private field for the plugin options instance - private readonly ASFFreeGamesOptions Options; + private readonly ASFFreeGamesOptions Options = options ?? throw new ArgumentNullException(nameof(options)); // Declare a private field for the dictionary that maps command names to IBotCommand instances - private readonly Dictionary Commands; + private readonly Dictionary Commands = new(StringComparer.OrdinalIgnoreCase) { + { "GETIP", new GetIPCommand() }, + { "FREEGAMES", new FreeGamesCommand(options) } + }; // Define a constructor that takes an plugin options instance as a parameter - public CommandDispatcher(ASFFreeGamesOptions options) { - Options = options ?? throw new ArgumentNullException(nameof(options)); + // Initialize the commands dictionary with instances of GetIPCommand and FreeGamesCommand - // Initialize the commands dictionary with instances of GetIPCommand and FreeGamesCommand - Commands = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { "GETIP", new GetIPCommand() }, - { "FREEGAMES", new FreeGamesCommand(options) } - }; - } - - // Define a method named Execute that takes the bot, message, args, steamID, and cancellationToken parameters and returns a string response public async Task Execute(Bot? bot, string message, string[] args, ulong steamID = 0, CancellationToken cancellationToken = default) { - if (args is { Length: > 0 }) { - // Try to get the corresponding IBotCommand instance from the commands dictionary based on the first argument - if (Commands.TryGetValue(args[0], out IBotCommand? command)) { - // Delegate the command execution to the IBotCommand instance, passing the bot and other parameters - return await command.Execute(bot, message, args, steamID, cancellationToken).ConfigureAwait(false); + try { + if (args is { Length: > 0 }) { + // Try to get the corresponding IBotCommand instance from the commands dictionary based on the first argument + if (Commands.TryGetValue(args[0], out IBotCommand? command)) { + // Delegate the command execution to the IBotCommand instance, passing the bot and other parameters + return await command.Execute(bot, message, args, steamID, cancellationToken).ConfigureAwait(false); + } + } + } + catch (Exception ex) { + // Check if verbose logging is enabled or if the build is in debug mode + // ReSharper disable once RedundantAssignment + bool verboseLogging = Options.VerboseLog ?? false; +#if DEBUG + verboseLogging = true; // Enable verbose logging in debug mode +#endif + + if (verboseLogging) { + // Log the detailed stack trace and full description of the exception + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(ex); + } + else { + // Log a compact error message + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"An error occurred: {ex.GetType().Name} {ex.Message}"); } } - return null; + return null; // Return null if an exception occurs or if no command is found } } } diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index 20a51c0..ad5baed 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -44,7 +44,7 @@ public static async ValueTask> GetGames(Cancellatio jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericInfo($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); return result; } From a3a5226b833da982de6342b91cfc51d3e0ddf491 Mon Sep 17 00:00:00 2001 From: maxisoft Date: Wed, 22 May 2024 11:37:46 +0200 Subject: [PATCH 3/3] Refactor error handling in FreeGamesCommand Moved the exception handling for Reddit JSON loading into the FreeGamesCommand to centralize error management. This change ensures that any exceptions thrown during the retrieval of games from Reddit are caught and logged appropriately, depending on the VerboseLog setting. Simplified the GetPayload method in RedditHelper by removing redundant try-catch blocks. --- ASFFreeGames/Commands/FreeGamesCommand.cs | 18 +++++++++++++++++- ASFFreeGames/Reddit/RedditHelper.cs | 11 +---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ASFFreeGames/Commands/FreeGamesCommand.cs b/ASFFreeGames/Commands/FreeGamesCommand.cs index 113f8ad..816efc8 100644 --- a/ASFFreeGames/Commands/FreeGamesCommand.cs +++ b/ASFFreeGames/Commands/FreeGamesCommand.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -202,7 +204,21 @@ private async Task CollectGames(IEnumerable bots, ECollectGameRequestS int res = 0; try { - ICollection games = await RedditHelper.GetGames(cancellationToken).ConfigureAwait(false); + ICollection games; + + try { + games = await RedditHelper.GetGames(cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { + if (Options.VerboseLog ?? false) { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericException(e); + } + else { + ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); + } + + return 0; + } LogNewGameCount(games, VerboseLog || requestSource is ECollectGameRequestSource.RequestedByUser); diff --git a/ASFFreeGames/Reddit/RedditHelper.cs b/ASFFreeGames/Reddit/RedditHelper.cs index ad5baed..68f324c 100644 --- a/ASFFreeGames/Reddit/RedditHelper.cs +++ b/ASFFreeGames/Reddit/RedditHelper.cs @@ -38,16 +38,7 @@ public static async ValueTask> GetGames(Cancellatio return result; } - JsonNode jsonPayload; - - try { - jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) when (e is InvalidOperationException or JsonException or IOException or RedditServerException) { - ArchiSteamFarm.Core.ASF.ArchiLogger.LogGenericError($"Unable to load json from reddit {e.GetType().Name}: {e.Message}"); - - return result; - } + JsonNode? jsonPayload = await GetPayload(webBrowser, cancellationToken).ConfigureAwait(false); JsonNode? childrenElement = jsonPayload["data"]?["children"];