diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index bbde0c41..3e4dc228 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -25,6 +25,7 @@ using Serilog.Context; using static SharedLibraryCore.Database.Models.EFClient; using Data.Models; using Data.Models.Server; +using Humanizer; using IW4MAdmin.Application.Alerts; using IW4MAdmin.Application.Commands; using IW4MAdmin.Application.Plugin.Script; @@ -193,18 +194,54 @@ namespace IW4MAdmin Command command = null; if (E.Type == GameEvent.EventType.Command) { + if (E.Origin is not null) + { + var canExecute = true; + + if (E.Origin.CommandExecutionAttempts > 0) + { + var remainingTimeout = + E.Origin.LastCommandExecutionAttempt + + Utilities.GetExponentialBackoffDelay(E.Origin.CommandExecutionAttempts) - + DateTimeOffset.UtcNow; + + if (remainingTimeout.TotalSeconds > 0) + { + if (E.Origin.CommandExecutionAttempts < 2 || + E.Origin.CommandExecutionAttempts % 5 == 0) + { + E.Origin.Tell(_translationLookup["COMMANDS_BACKOFF_MESSAGE"] + .FormatExt(remainingTimeout.Humanize())); + } + + canExecute = false; + } + else + { + E.Origin.CommandExecutionAttempts = 0; + } + } + + E.Origin.LastCommandExecutionAttempt = DateTimeOffset.UtcNow; + E.Origin.CommandExecutionAttempts++; + + if (!canExecute) + { + return; + } + } + try { command = await SharedLibraryCore.Commands.CommandProcessing.ValidateCommand(E, Manager.GetApplicationSettings().Configuration(), _commandConfiguration); } - catch (CommandException e) { ServerLogger.LogWarning(e, "Error validating command from event {@Event}", new { E.Type, E.Data, E.Message, E.Subtype, E.IsRemote, E.CorrelationId }); E.FailReason = GameEvent.EventFailReason.Invalid; } - + if (command != null) { E.Extra = command; diff --git a/SharedLibraryCore/PartialEntities/EFClient.cs b/SharedLibraryCore/PartialEntities/EFClient.cs index c5c764f4..64282eeb 100644 --- a/SharedLibraryCore/PartialEntities/EFClient.cs +++ b/SharedLibraryCore/PartialEntities/EFClient.cs @@ -120,6 +120,11 @@ namespace SharedLibraryCore.Database.Models [NotMapped] public string TimeSinceLastConnectionString => (DateTime.UtcNow - LastConnection).HumanizeForCurrentCulture(); + public DateTimeOffset LastCommandExecutionAttempt { get; set; } = DateTimeOffset.MinValue; + + [NotMapped] + public int CommandExecutionAttempts { get; set; } + [NotMapped] // this is kinda dirty, but I need localizable level names public ClientPermission ClientPermission => new ClientPermission diff --git a/SharedLibraryCore/Utilities.cs b/SharedLibraryCore/Utilities.cs index 9ef0681b..1ea1736a 100644 --- a/SharedLibraryCore/Utilities.cs +++ b/SharedLibraryCore/Utilities.cs @@ -1339,6 +1339,14 @@ namespace SharedLibraryCore return serviceCollection; } + + public static TimeSpan GetExponentialBackoffDelay(int retryCount, int staticDelay = 5) + { + var maxTimeout = TimeSpan.FromMinutes(2.1); + const double factor = 2.0; + var delay = Math.Min(staticDelay + Math.Pow(factor, retryCount - 1), maxTimeout.TotalSeconds); + return TimeSpan.FromSeconds(delay); + } public static void ExecuteAfterDelay(TimeSpan duration, Func action, CancellationToken token = default) => ExecuteAfterDelay((int)duration.TotalMilliseconds, action, token);