Skip to content

Commit

Permalink
Implement serial rate limiter with backoff
Browse files Browse the repository at this point in the history
  • Loading branch information
lyarenei committed Nov 10, 2023
1 parent 3586e5e commit 226ca59
Show file tree
Hide file tree
Showing 2 changed files with 79 additions and 1 deletion.
53 changes: 52 additions & 1 deletion src/Jellyfin.Plugin.ListenBrainz.Api/BaseClient.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Jellyfin.Plugin.ListenBrainz.Api.Exceptions;
using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
using Jellyfin.Plugin.ListenBrainz.Api.Resources;
using Jellyfin.Plugin.ListenBrainz.Http;
using Jellyfin.Plugin.ListenBrainz.Http.Exceptions;
using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
using Jellyfin.Plugin.ListenBrainz.Http.Services;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
Expand All @@ -26,6 +30,10 @@ public class BaseClient : HttpClient
ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() }
};

private readonly object _lock = new();
private readonly ILogger _logger;
private readonly ISleepService _sleepService;

/// <summary>
/// Initializes a new instance of the <see cref="BaseClient"/> class.
/// </summary>
Expand All @@ -35,6 +43,8 @@ public class BaseClient : HttpClient
protected BaseClient(IHttpClientFactory httpClientFactory, ILogger logger, ISleepService? sleepService)
: base(httpClientFactory, logger, sleepService)
{
_logger = logger;
_sleepService = sleepService ?? new DefaultSleepService();
}

/// <summary>
Expand Down Expand Up @@ -90,12 +100,53 @@ protected BaseClient(IHttpClientFactory httpClientFactory, ILogger logger, ISlee
private async Task<TResponse?> DoRequest<TResponse>(HttpRequestMessage requestMessage, CancellationToken cancellationToken)
where TResponse : IListenBrainzResponse
{
var response = await SendRequest(requestMessage, cancellationToken);
HttpResponseMessage response;
try
{
Monitor.Enter(_lock);
while (true)
{
response = await SendRequest(requestMessage, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogDebug("Rate limit reached, will retry after new window opens");
HandleRateLimit(response);
continue;
}

_logger.LogDebug("Did not hit any rate limits, all OK");
break;
}
}
finally
{
Monitor.Exit(_lock);
}

var stringContent = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonConvert.DeserializeObject<TResponse>(stringContent, SerializerSettings);
if (result is null) throw new InvalidResponseException("Response deserialized to NULL");

result.IsOk = response.IsSuccessStatusCode;
return result;
}

private void HandleRateLimit(HttpResponseMessage response)
{
// X-RateLimit-Reset-In: Number of seconds when current time window expires
var header = response.Headers.FirstOrDefault(h => h.Key == Headers.RateLimitResetIn);
var resetIn = header.Value.FirstOrDefault();
if (resetIn is null)
{
throw new ListenBrainzException("No 'rate limit reset in' value available, cannot continue");
}

if (!int.TryParse(resetIn, out var resetInSec))
{
throw new ListenBrainzException("Invalid value for 'rate limit reset in', cannot continue");
}

_logger.LogDebug("Waiting for {Seconds} seconds before trying again", resetInSec);
_sleepService.Sleep(resetInSec);
}
}
27 changes: 27 additions & 0 deletions src/Jellyfin.Plugin.ListenBrainz.Api/Resources/Headers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;

/// <summary>
/// ListenBrainz API custom headers.
/// </summary>
public static class Headers
{
/// <summary>
/// Number of requests allowed in given time window.
/// </summary>
public const string RateLimitLimit = "X-RateLimit-Limit";

/// <summary>
/// Number of requests remaining in current time window.
/// </summary>
public const string RateLimitRemaining = "X-RateLimit-Remaining";

/// <summary>
/// Number of seconds when current time window expires (recommended: this header is resilient against clients with incorrect clocks).
/// </summary>
public const string RateLimitResetIn = "X-RateLimit-Reset-In";

/// <summary>
/// UNIX epoch number of seconds (without timezone) when current time window expires.
/// </summary>
public const string RateLimitReset = "X-RateLimit-Reset";
}

0 comments on commit 226ca59

Please sign in to comment.