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:
2026-06-17 10:51:02 -04:00
parent 39f61b9718
commit a4a3dde7e4
2 changed files with 88 additions and 28 deletions
@@ -32,4 +32,16 @@ public interface IColumbiaCoatingsApiClient
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set. /// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
/// </summary> /// </summary>
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default); 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( public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
int page, int perPage, CancellationToken cancellationToken = default) int page, int perPage, CancellationToken cancellationToken = default)
{ {
if (!IsConfigured) EnsureConfigured();
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage); perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?page={page}&per_page={perPage}"; var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?page={page}&per_page={perPage}";
for (var attempt = 1; ; attempt++) var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
{ if (json == null)
using var request = new HttpRequestMessage(HttpMethod.Get, url); return new ColumbiaProductsResponse();
request.Headers.Add("X-API-Key", ApiKey);
var client = _httpClientFactory.CreateClient(); return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
using var response = await client.SendAsync(request, cancellationToken); }
if (response.StatusCode == HttpStatusCode.TooManyRequests) /// <inheritdoc />
{ public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
if (attempt > MaxRetriesPer429) {
throw new HttpRequestException( EnsureConfigured();
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries on page {page}."); if (string.IsNullOrWhiteSpace(sku))
return null;
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds; var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?sku={Uri.EscapeDataString(sku)}&per_page=1";
_logger.LogWarning( var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
"Columbia API returned 429 on page {Page} (attempt {Attempt}); waiting {Delay}s before retry.", if (json == null)
page, attempt, delaySeconds); return null;
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
continue;
}
response.EnsureSuccessStatusCode(); var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
return response?.Items.FirstOrDefault();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken); /// <inheritdoc />
var result = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions); public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
return result ?? new ColumbiaProductsResponse(); {
} 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 /> /// <inheritdoc />
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync( public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!IsConfigured) EnsureConfigured();
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
var all = new List<ColumbiaProduct>(); var all = new List<ColumbiaProduct>();
@@ -119,6 +122,51 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
return all; 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> /// <summary>
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds, /// 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. /// or null when absent/unparseable so the caller can fall back to a default.