using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Polly;
using Polly.Retry;
using Refit;
namespace OTSSignsOrchestrator.Server.Clients;
///
/// Creates per-instance Refit proxies with
/// OAuth2 bearer-token caching, auto-refresh on 401, and Polly retry.
/// Registered as a singleton.
///
public sealed class XiboClientFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ConcurrentDictionary _tokenCache = new();
private readonly SemaphoreSlim _tokenLock = new(1, 1);
private static readonly TimeSpan TokenCacheTtl = TimeSpan.FromMinutes(5);
public XiboClientFactory(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
///
/// Build a Refit client targeting /api.
/// Tokens are cached per base URL for 5 minutes and auto-refreshed on 401.
///
public async Task CreateAsync(
string instanceBaseUrl,
string clientId,
string clientSecret)
{
// Ensure we have a valid token up-front
var token = await GetOrRefreshTokenAsync(instanceBaseUrl, clientId, clientSecret);
var retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
ShouldHandle = new PredicateBuilder()
.HandleResult(r =>
r.StatusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
or >= HttpStatusCode.InternalServerError),
})
.Build();
var handler = new XiboDelegatingHandler(
this, instanceBaseUrl, clientId, clientSecret, token, retryPipeline)
{
InnerHandler = new HttpClientHandler(),
};
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri(instanceBaseUrl.TrimEnd('/') + "/api"),
};
return RestService.For(httpClient);
}
// ── Token management ────────────────────────────────────────────────────
internal async Task GetOrRefreshTokenAsync(
string instanceBaseUrl,
string clientId,
string clientSecret,
bool forceRefresh = false)
{
var key = instanceBaseUrl.TrimEnd('/').ToLowerInvariant();
if (!forceRefresh
&& _tokenCache.TryGetValue(key, out var cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.AccessToken;
}
await _tokenLock.WaitAsync();
try
{
// Double-check after acquiring lock
if (!forceRefresh
&& _tokenCache.TryGetValue(key, out cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
return cached.AccessToken;
}
var token = await RequestTokenAsync(instanceBaseUrl, clientId, clientSecret);
_tokenCache[key] = new TokenEntry(token, DateTimeOffset.UtcNow.Add(TokenCacheTtl));
return token;
}
finally
{
_tokenLock.Release();
}
}
private static async Task RequestTokenAsync(
string instanceBaseUrl,
string clientId,
string clientSecret)
{
using var http = new HttpClient();
var content = new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
});
var response = await http.PostAsync(
$"{instanceBaseUrl.TrimEnd('/')}/api/authorize/access_token",
content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("Token response missing access_token.");
}
private sealed record TokenEntry(string AccessToken, DateTimeOffset ExpiresAt);
// ── Delegating handler ──────────────────────────────────────────────────
private sealed class XiboDelegatingHandler : DelegatingHandler
{
private readonly XiboClientFactory _factory;
private readonly string _instanceBaseUrl;
private readonly string _clientId;
private readonly string _clientSecret;
private readonly ResiliencePipeline _retryPipeline;
private string _accessToken;
public XiboDelegatingHandler(
XiboClientFactory factory,
string instanceBaseUrl,
string clientId,
string clientSecret,
string accessToken,
ResiliencePipeline retryPipeline)
{
_factory = factory;
_instanceBaseUrl = instanceBaseUrl;
_clientId = clientId;
_clientSecret = clientSecret;
_accessToken = accessToken;
_retryPipeline = retryPipeline;
}
protected override async Task SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return await _retryPipeline.ExecuteAsync(async ct =>
{
// Clone the request for retries (original may already be disposed)
using var clone = await CloneRequestAsync(request);
clone.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _accessToken);
var response = await base.SendAsync(clone, ct);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// Force-refresh the token and retry once
_accessToken = await _factory.GetOrRefreshTokenAsync(
_instanceBaseUrl, _clientId, _clientSecret, forceRefresh: true);
using var retry = await CloneRequestAsync(request);
retry.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _accessToken);
response = await base.SendAsync(retry, ct);
}
return response;
}, cancellationToken);
}
private static async Task CloneRequestAsync(
HttpRequestMessage original)
{
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
if (original.Content != null)
{
var body = await original.Content.ReadAsByteArrayAsync();
clone.Content = new ByteArrayContent(body);
foreach (var header in original.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in original.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
return clone;
}
}
}