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.
|
/// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user