defmodule ShopQL do
@moduledoc """
Client library for the [Shopify GraphQL Admin API](https://shopify.dev/api/admin-graphql).
"""
defmodule Error do
defexception [:body]
def message(exception), do: inspect(exception.body)
end
require Logger
@query_opts_validation [
access_token: [
type: :string,
required: true,
doc: "Shopify access token."
],
api_version: [
type: :string,
required: true,
doc: "Shopify API version, like `2022-07`."
],
gql_mod: [
type: :atom,
default: GQL,
doc: false
],
max_attempts: [
type: :pos_integer,
default: 1,
doc:
"Maximum number of attempts to make. You should only increase this value when making an idempotent request."
],
max_attempts_rate_limit: [
type: :pos_integer,
default: 3,
doc:
"Maximum number of attempts to make. This only applies when Shopify returns an error indicating that the rate limit has been exceeded."
],
min_retry_delay: [
type: :pos_integer,
default: 500,
doc:
"Delay in ms between failed attempts. The delay will be doubled after each subsequent retry. For example, if set to `500` the first delay will be 500ms, the second 1000ms, and the third 2000ms. This doesn't apply when a rate limit error occurs."
],
shop_name: [
type: :string,
required: true,
doc: "Your Shopify domain is `<shop_name>.myshopify.com`."
]
]
@doc """
Like `query/3`, except raises `ShopQL.Error` if an error occurs.
"""
def query!(query, variables \\ %{}, opts) do
case query(query, variables, opts) do
{:ok, body} -> body
{:error, body} -> raise %Error{body: body}
end
end
@doc """
Submits a query to the [Shopify Admin GraphQL API](https://shopify.dev/api/admin-graphql).
Returns `{:ok, body}` or `{:error, body}`.
## Options
#{NimbleOptions.docs(@query_opts_validation)}
## Retries after error
The retry logic depends upon the type of error. A warnings will be logged whenever a request is
retried.
* Connection errors like a 5xx HTTP response or timeout - Request will be retried up to
`:max_attempts` times. For this type of error we don't know whether or not Shopify ran the
query. Therefore, only increase `:max_attempts` if you are running an idempotent query. The
`:min_retry_delay` setting applies in this case.
* Rate limit exceeded error - Failed request will be retried up to `:max_attempts_rate_limit`
times. For this type of error we know that Shopify didn't run the query so it is safe to retry
even if your query is not idempotent. Will delay between requests for enough time for
[Shopify's cost points](https://shopify.dev/api/admin-graphql#rate_limits) to be fully
replenished.
* GraphQL error ("errors" key in response) - Request will never be retried because this type of
error generally means that your request is invalid and if we retry we'll just get the same
error again.
"""
def query(query, variables \\ %{}, opts) do
opts = NimbleOptions.validate!(opts, @query_opts_validation)
try do
case opts[:gql_mod].query(query, Keyword.merge(gql_opts(opts), variables: variables)) do
{:ok, %{"data" => _} = body, _headers} ->
{:ok, body}
{:error,
%{
"errors" => [%{"extensions" => %{"code" => "THROTTLED"}}],
"extensions" => extensions
} = body, _headers} ->
if opts[:max_attempts_rate_limit] > 1 do
delay_until_quota_fully_replenished(extensions)
query(query, variables, retry_opts(opts))
else
{:error, body}
end
{:error, body, _headers} ->
{:error, body}
end
rescue
e in [GQL.ConnectionError, GQL.ServerError] ->
if opts[:max_attempts] > 1 do
Logger.warning("Retrying request in #{opts[:min_retry_delay]}ms: #{inspect(e)}")
:timer.sleep(opts[:min_retry_delay])
query(query, variables, retry_opts(opts))
else
reraise e, __STACKTRACE__
end
end
end
defp decrement_attempt_count(1), do: 1
defp decrement_attempt_count(n), do: n - 1
defp retry_opts(opts) do
opts
|> Keyword.update!(:max_attempts, &decrement_attempt_count/1)
|> Keyword.update!(:max_attempts_rate_limit, &decrement_attempt_count/1)
|> Keyword.update!(:min_retry_delay, &(&1 * 2))
end
defp delay_until_quota_fully_replenished(%{
"cost" => %{
"throttleStatus" => %{
"currentlyAvailable" => currently_available,
"maximumAvailable" => max_available,
"restoreRate" => restore_rate
}
}
}) do
delay = round((max_available - currently_available) * 1000 / restore_rate) |> max(0)
Logger.warning("Shopify rate limit exceeded; delaying #{delay}ms before retry")
:timer.sleep(delay)
end
defp gql_opts(opts) do
[
headers: [{"X-Shopify-Access-Token", opts[:access_token]}],
url:
"https://#{opts[:shop_name]}.myshopify.com/admin/api/#{opts[:api_version]}/graphql.json"
]
end
end