Add single-product lookup methods to Columbia API client
Adds GetProductBySkuAsync (GET /products?sku=) and GetProductByIdAsync
(GET /products/{id}) for ad-hoc / on-demand refresh of a single record
without pulling the full catalog. Extracts the shared 429-retry send loop
into SendWithRetryAsync, which now also treats 404 as not-found (null) so
single lookups don't throw on a missing SKU/ID.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,4 +32,16 @@ public interface IColumbiaCoatingsApiClient
|
||||
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single product by exact SKU (<c>GET /products?sku=...</c>), or null if not found.
|
||||
/// For ad-hoc refresh of one record without pulling the whole catalog.
|
||||
/// </summary>
|
||||
Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single product by WooCommerce product ID (<c>GET /products/{id}</c>), or null if
|
||||
/// not found. Useful when we already store the catalog product's ID and want to refresh it.
|
||||
/// </summary>
|
||||
Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -56,48 +56,51 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
||||
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
|
||||
int page, int perPage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
|
||||
EnsureConfigured();
|
||||
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
|
||||
var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?page={page}&per_page={perPage}";
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-API-Key", ApiKey);
|
||||
var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
|
||||
if (json == null)
|
||||
return new ColumbiaProductsResponse();
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt > MaxRetriesPer429)
|
||||
throw new HttpRequestException(
|
||||
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries on page {page}.");
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
if (string.IsNullOrWhiteSpace(sku))
|
||||
return null;
|
||||
|
||||
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
|
||||
_logger.LogWarning(
|
||||
"Columbia API returned 429 on page {Page} (attempt {Attempt}); waiting {Delay}s before retry.",
|
||||
page, attempt, delaySeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?sku={Uri.EscapeDataString(sku)}&per_page=1";
|
||||
var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
|
||||
if (json == null)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
|
||||
return response?.Items.FirstOrDefault();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
|
||||
return result ?? new ColumbiaProductsResponse();
|
||||
}
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConfigured();
|
||||
// The by-id endpoint returns a bare product object (not the {items,pagination} envelope).
|
||||
var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}/{id}";
|
||||
var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken);
|
||||
if (json == null)
|
||||
return null;
|
||||
|
||||
return JsonSerializer.Deserialize<ColumbiaProduct>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
EnsureConfigured();
|
||||
|
||||
var all = new List<ColumbiaProduct>();
|
||||
|
||||
@@ -119,6 +122,51 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>Throws when no API key is configured so callers fail fast rather than 401.</summary>
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a GET with the API key header and returns the response body. Retries on HTTP 429
|
||||
/// (honoring Retry-After) up to <see cref="MaxRetriesPer429"/>. Returns null on 404 so
|
||||
/// single-product lookups surface "not found" without throwing; throws on any other non-success.
|
||||
/// <paramref name="describe"/> is a short label (e.g. "page 3", "sku ABC") for log/error context.
|
||||
/// </summary>
|
||||
private async Task<string?> SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-API-Key", ApiKey);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
if (attempt > MaxRetriesPer429)
|
||||
throw new HttpRequestException(
|
||||
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe}).");
|
||||
|
||||
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
|
||||
_logger.LogWarning(
|
||||
"Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.",
|
||||
describe, attempt, delaySeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
|
||||
/// or null when absent/unparseable so the caller can fall back to a default.
|
||||
|
||||
Reference in New Issue
Block a user