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:
- 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.
PhoenixKitEcommerce.Shopify.ProductDiff.diff/4'sopts[: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
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 intoReq.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 pass0.: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).