feat: add publisher and browser client SDK (#9)
quality-gate / quality (push) Successful in 1m6s
quality-gate / quality (push) Successful in 1m6s
Closes #9
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
internal sealed class RendezvousHttpTransport
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RendezvousClientOptions _options;
|
||||
private readonly IRendezvousDelay _delay;
|
||||
|
||||
internal RendezvousHttpTransport(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options,
|
||||
IRendezvousDelay? delay)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
|
||||
suppliedOptions.Validate();
|
||||
_options = new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
|
||||
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
|
||||
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
|
||||
JitterRatio = suppliedOptions.JitterRatio,
|
||||
};
|
||||
_delay = delay ?? new SystemRendezvousDelay();
|
||||
}
|
||||
|
||||
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
using HttpResponseMessage response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return RendezvousClientResult.Success((T)(object)true);
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an oversized success response.");
|
||||
}
|
||||
|
||||
T? value;
|
||||
try
|
||||
{
|
||||
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
value = default;
|
||||
}
|
||||
|
||||
return value is null
|
||||
? RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid success response.")
|
||||
: RendezvousClientResult.Success(value);
|
||||
}
|
||||
|
||||
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
|
||||
{
|
||||
await _delay.DelayAsync(
|
||||
GetRetryDelay(attempt, retryAfter),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
IsTransientTransportFailure(exception, cancellationToken)
|
||||
&& attempt < _options.MaximumSafeRetries)
|
||||
{
|
||||
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||
{
|
||||
return RendezvousClientResult.Failure<T>(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
"The Rendezvous service did not return a valid response.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static HttpRequestMessage JsonRequest<T>(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
T body,
|
||||
string? publisherCredential = null)
|
||||
{
|
||||
HttpRequestMessage request = new(method, uri)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(body, ContractJson.Options),
|
||||
Encoding.UTF8,
|
||||
"application/json"),
|
||||
};
|
||||
if (publisherCredential is not null)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
RequireCredential(publisherCredential));
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
internal static string RequireCredential(string credential) =>
|
||||
!string.IsNullOrWhiteSpace(credential)
|
||||
? credential
|
||||
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
|
||||
|
||||
private static async Task<ApiError> ReadErrorAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
|
||||
return error is not null && error.Code != RendezvousErrorCode.None
|
||||
? error
|
||||
: FallbackError(response.StatusCode);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidDataException)
|
||||
{
|
||||
return FallbackError(response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(
|
||||
HttpContent content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
using MemoryStream destination = new();
|
||||
byte[] buffer = new byte[8192];
|
||||
while (true)
|
||||
{
|
||||
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return destination.ToArray();
|
||||
}
|
||||
|
||||
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("The service response exceeded the SDK limit.");
|
||||
}
|
||||
|
||||
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
|
||||
{
|
||||
TimeSpan basis = retryAfterSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
|
||||
: TimeSpan.FromMilliseconds(
|
||||
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
|
||||
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
|
||||
if (_options.JitterRatio == 0 || bounded == 0)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(bounded);
|
||||
}
|
||||
|
||||
byte[] random = new byte[1];
|
||||
RandomNumberGenerator.Fill(random);
|
||||
double unit = random[0] / 255d;
|
||||
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
|
||||
return TimeSpan.FromMilliseconds(Math.Min(
|
||||
bounded * multiplier,
|
||||
_options.MaximumRetryDelay.TotalMilliseconds));
|
||||
}
|
||||
|
||||
private static bool IsTransient(RendezvousErrorCode code) => code is
|
||||
RendezvousErrorCode.RateLimited
|
||||
or RendezvousErrorCode.CapacityExceeded
|
||||
or RendezvousErrorCode.ServiceUnavailable;
|
||||
|
||||
private static bool IsTransientTransportFailure(
|
||||
Exception exception,
|
||||
CancellationToken callerCancellation) =>
|
||||
exception is HttpRequestException
|
||||
or IOException
|
||||
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
|
||||
|
||||
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
|
||||
retryAfter?.Delta is TimeSpan delta
|
||||
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
|
||||
: null;
|
||||
|
||||
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
|
||||
{
|
||||
Code = statusCode switch
|
||||
{
|
||||
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
|
||||
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
|
||||
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
|
||||
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
|
||||
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
|
||||
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
},
|
||||
Message = "The service returned an error without a valid Rendezvous envelope.",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user