PhoenixKitEcommerce.Shopify.StorefrontClient (PhoenixKitEcommerce v0.4.2)

Copy Markdown View Source

Reads live product/price data from a store's public storefront JSON endpoint (/products.json) — no Admin API token required, because nothing is authenticated. This is the fallback price source: when the Admin API token configured for a store is missing or rejected, price sync keeps working through this keyless path instead of stalling until someone notices and rotates the token.

Only sees products published to the Online Store sales channel, which is a narrower view than the Admin API gives (e.g. draft products are invisible here). That is an accepted trade-off for a fallback.

The returned payload is deliberately trimmed to "handle" and "variants" alone, even though the endpoint also returns "title" and "body_html". Two reasons:

  1. Published storefront text is not the same view of a product that the Admin API gives — it should never be treated as if it were.
  2. PhoenixKitEcommerce.Shopify.ProductDiff.diff/4's opts[:only] exists precisely to stop an unfiltered comparison from reporting a source's absent fields as deletions. A fallback that quietly widened its authority to text fields would defeat that guard — silently overwriting real product copy with nothing, the moment this path activates.

Being narrow is the feature. Do not widen the returned fields.

Unlike the Admin API, storefront pagination has no natural terminator (no Link: rel="next" header to follow) — it is just ?page=N until a page comes back empty. That makes two failure modes possible that AdminClient cannot have: a malformed page whose "products" value is not a list (rejected without touching the accumulator), and a server that never returns an empty page (bounded by @max_pages). Both return an error tuple instead of raising or looping.

A real store fetched during development turned out to have thousands of published products across a dozen pages, and burst requests against it reliably trigger a 429 that (per the endpoint's own behavior) can take minutes to clear — so 429 handling here is not a hypothetical.

The Retry-After header this reads to decide how long to sleep on a 429 comes from the unauthenticated public endpoint itself — the one input in this whole module that isn't just "a store operator's own data read back." retry_after_seconds/1 clamps it to 0..60 seconds before it ever reaches Process.sleep/1: unclamped, a negative value crashes Process.sleep/1 (which only accepts a non-negative integer or :infinity) instead of returning the promised error tuple, and a huge one sleeps for real.

That per-sleep clamp bounds any ONE sleep, but not the fetch as a whole: @max_pages pages, each retried up to @max_retries times at up to @max_retry_after_seconds, is 200 * 5 * 60 seconds — over 16 hours — of a start_async task sleeping with the spinner up, with no escape but a page reload. fetch_products/2's :deadline_ms option (see its own doc) bounds the aggregate: a wall-clock deadline computed once via System.monotonic_time/1 and threaded through every recursive fetch_pages/6 call, checked before each page request (and therefore before whatever sleep that iteration might otherwise start).

Summary

Functions

Fetches every priced product from shop_domain's public storefront, paging until an empty page is returned.

Functions

fetch_products(shop_domain, opts \\ [])

@spec fetch_products(String.t(), keyword()) :: {:ok, [map()]} | {:error, term()}

Fetches every priced product from shop_domain's public storefront, paging until an empty page is returned.

Each returned map has exactly two keys: "handle" and "variants" (each variant trimmed to "price" only). Products with no priced variants are omitted.

A 429 response is retried up to 5 times, honoring the Retry-After header (defaulting to 5s when the header is absent), resuming the same page with the accumulator intact. The retry budget resets after each page that succeeds. Exhausting it returns {:error, :rate_limited}.

Paging stops with {:error, :too_many_pages} after 200 pages without an empty page — a store would need over 50000 products to hit this legitimately; in practice it means the endpoint is not honoring ?page= at all.

The whole fetch also carries a wall-clock deadline, independent of the per-page/per-retry budgets above (see the moduledoc): once it passes, the NEXT page request (or retry) is skipped and {:error, :deadline_exceeded} is returned instead — an in-flight sleep is not interrupted, but no new one is started, so the fetch can never run past deadline_ms by more than a single already-clamped sleep.

Options

  • :req_options — keyword list merged into Req.new/1 (e.g. plug: to stub the transport in tests).
  • :page_delay_ms — pause between page requests, to stay gentle with the storefront's rate limit. Defaults to 500ms; tests pass 0.
  • :deadline_ms — the wall-clock budget for the whole fetch (paging and retries together), starting from the moment this function is called. Defaults to 120000ms (2 minutes).