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 `.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