shopify_api-graphql-tiny 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.env.template +4 -2
- data/.github/workflows/ci.yml +2 -2
- data/Changes +9 -0
- data/README.md +94 -7
- data/Rakefile +158 -0
- data/lib/shopify_api/graphql/tiny/version.rb +1 -1
- data/lib/shopify_api/graphql/tiny.rb +160 -50
- data/shopify_api-graphql-tiny.gemspec +1 -1
- metadata +8 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c827a0b468aeb34cfb05760a7a1a25e641c61f21f6d2d15c4ed8a05b29bd383b
|
|
4
|
+
data.tar.gz: 23b357593431fb8beda6a29689b6b90e4101fde3cac2eaaad38c7a2cb48f1437
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7fe6d5f27b0936703ce4f7f45875406ab11069d41f8e83e9d57a873ce5dd63d1093e88219cd8b5b69eed2bdd3b39bb233292360aa8bb4534f5a799690cceab5f
|
|
7
|
+
data.tar.gz: d7231b2be188d9bedbfa92f6de69a0ee4c082b8801633db73c570c7cef6c6a349afc81e942e2f36b685edf26a02d9e74817595d7b4988298f42092182a49a718
|
data/.env.template
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
# 2. Fill-in the below variables with valid values.
|
|
5
5
|
# These values will be used by the tests. Data will be written to SHOPIFY_DOMAIN
|
|
6
6
|
#
|
|
7
|
+
|
|
8
|
+
# Must be full domain as it's used in test assertions
|
|
7
9
|
SHOPIFY_DOMAIN=
|
|
8
10
|
SHOPIFY_TOKEN=
|
|
9
11
|
|
|
10
|
-
# Will result in the creation of a
|
|
12
|
+
# Will result in the creation of a metadafield on the customer. write_customers permission required
|
|
11
13
|
SHOPIFY_CUSTOMER_ID=
|
|
12
14
|
|
|
13
|
-
# Must have more than 1 variant
|
|
15
|
+
# Must have more than 1 variant values
|
|
14
16
|
SHOPIFY_PRODUCT_ID=
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -15,10 +15,10 @@ jobs:
|
|
|
15
15
|
|
|
16
16
|
strategy:
|
|
17
17
|
matrix:
|
|
18
|
-
ruby: [
|
|
18
|
+
ruby: ['3.4', '3.3', '3.2', '3.1', '3.0', '2.7']
|
|
19
19
|
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
22
|
- uses: ruby/setup-ruby@v1
|
|
23
23
|
with:
|
|
24
24
|
bundler-cache: true
|
data/Changes
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
2026-01-20 v1.0.0
|
|
2
|
+
--------------------
|
|
3
|
+
* Add support for exponential backoff and remove ShopifyAPIRetry
|
|
4
|
+
* Support HTTP status and Shopify error codes as retry options
|
|
5
|
+
|
|
6
|
+
2026-01-20 v0.2.0
|
|
7
|
+
--------------------
|
|
8
|
+
* Deprecate :retry with a Hash value
|
|
9
|
+
|
|
1
10
|
2023-02-12 v0.1.1
|
|
2
11
|
--------------------
|
|
3
12
|
* Add pagination support
|
data/README.md
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
# ShopifyAPI::GraphQL::Tiny
|
|
2
2
|
|
|
3
|
-
Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry
|
|
3
|
+
Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry
|
|
4
4
|
|
|
5
5
|
[](https://github.com/ScreenStaring/shopify_api-graphql-tiny/actions)
|
|
6
6
|
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add this line to your application's `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "shopify_api-graphql-tiny"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
And then execute:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bundle
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install it yourself as:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
gem install shopify_api-graphql-tiny
|
|
25
|
+
```
|
|
26
|
+
|
|
7
27
|
## Usage
|
|
8
28
|
|
|
9
29
|
```rb
|
|
@@ -54,6 +74,69 @@ GQL
|
|
|
54
74
|
p result.dig("data", "customerUpdate", "userErrors")
|
|
55
75
|
```
|
|
56
76
|
|
|
77
|
+
### Automatically Retrying Failed Requests
|
|
78
|
+
|
|
79
|
+
There are 2 types of retries: 1) request is rate-limited by Shopify 2) request fails due to an exception or non-200 HTTP response.
|
|
80
|
+
|
|
81
|
+
When a request is rate-limited by Shopify retry occurs according to [Shopify's `throttleStatus`](https://shopify.dev/docs/api/admin-graphql/unstable#rate-limits)
|
|
82
|
+
|
|
83
|
+
When a request fails due to an exception or non-200 HTTP status a retry will be attempted after an exponential backoff waiting period.
|
|
84
|
+
This is controlled by `ShopifyAPI::GraphQL::Tiny::DEFAULT_BACKOFF_OPTIONS`. It contains:
|
|
85
|
+
|
|
86
|
+
* `:base_delay` - `0.5`
|
|
87
|
+
* `:jitter` - `true`
|
|
88
|
+
* `:max_attempts` - `10`
|
|
89
|
+
* `:max_delay` - `60`
|
|
90
|
+
* `:multiplier` - `2.0`
|
|
91
|
+
|
|
92
|
+
`:max_attempts` dictates how many retry attempts will be made **for all** types of retries.
|
|
93
|
+
|
|
94
|
+
These can be overridden globally (by assigning to the constant) or per instance:
|
|
95
|
+
|
|
96
|
+
```rb
|
|
97
|
+
gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :max_attempts => 20, :max_delay => 90)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`ShopifyAPI::GraphQL::Tiny::DEFAULT_RETRY_ERRORS` determines what is retried. It contains and HTTP statuses codes, Shopify GraphQL errors codes, and exceptions.
|
|
101
|
+
By default it contains:
|
|
102
|
+
|
|
103
|
+
* `"5XX"` - Any HTTP 5XX status
|
|
104
|
+
* `"INTERNAL_SERVER_ERROR"` - Shopify GraphQL error code
|
|
105
|
+
* `"TIMEOUT"` - Shopify GraphQL error code
|
|
106
|
+
* `EOFError`
|
|
107
|
+
* `Errno::ECONNABORTED`
|
|
108
|
+
* `Errno::ECONNREFUSED`
|
|
109
|
+
* `Errno::ECONNRESET`
|
|
110
|
+
* `Errno::EHOSTUNREACH`
|
|
111
|
+
* `Errno::EINVAL`
|
|
112
|
+
* `Errno::ENETUNREACH`
|
|
113
|
+
* `Errno::ENOPROTOOPT`
|
|
114
|
+
* `Errno::ENOTSOCK`
|
|
115
|
+
* `Errno::EPIPE`
|
|
116
|
+
* `Errno::ETIMEDOUT`
|
|
117
|
+
* `Net::HTTPBadResponse`
|
|
118
|
+
* `Net::HTTPHeaderSyntaxError`
|
|
119
|
+
* `Net::ProtocolError`
|
|
120
|
+
* `Net::ReadTimeout`
|
|
121
|
+
* `OpenSSL::SSL::SSLError`
|
|
122
|
+
* `SocketError`
|
|
123
|
+
* `Timeout::Error`
|
|
124
|
+
|
|
125
|
+
These can be overridden globally (by assigning to the constant) or per instance:
|
|
126
|
+
|
|
127
|
+
```rb
|
|
128
|
+
# Only retry on 2 errors
|
|
129
|
+
gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :retry => [SystemCallError, "500"])
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Disabling Automatic Retry
|
|
133
|
+
|
|
134
|
+
To disable retries set the `:retry` option to `false`:
|
|
135
|
+
|
|
136
|
+
```rb
|
|
137
|
+
gql = ShopifyAPI::GraphQL::Tiny.new(shop, token, :retry => false)
|
|
138
|
+
```
|
|
139
|
+
|
|
57
140
|
### Pagination
|
|
58
141
|
|
|
59
142
|
In addition to built-in request retry `ShopifyAPI::GraphQL::Tiny` also builds in support for pagination.
|
|
@@ -64,6 +147,8 @@ in your queries and wrap them in a function that accepts a page/cursor argument.
|
|
|
64
147
|
The pager's `#execute` is like the non-paginated `#execute` method and accepts additional, non-pagination query arguments:
|
|
65
148
|
|
|
66
149
|
```rb
|
|
150
|
+
gql = ShopifyAPI::GraphQL::Tiny.new("my-shop", token)
|
|
151
|
+
pager = gql.paginate
|
|
67
152
|
pager.execute(query, :foo => 123)
|
|
68
153
|
```
|
|
69
154
|
|
|
@@ -174,19 +259,21 @@ pager.execute(query) { |page| }
|
|
|
174
259
|
|
|
175
260
|
The `"data"` and `"pageInfo"` keys are automatically added if not provided.
|
|
176
261
|
|
|
177
|
-
### Automatically Retrying Failed Requests
|
|
178
|
-
|
|
179
|
-
See [the docs](https://rubydoc.info/gems/shopify_api-graphql-tiny) for more information.
|
|
180
|
-
|
|
181
262
|
## Testing
|
|
182
263
|
|
|
183
264
|
`cp env.template .env` and fill-in `.env` with the missing values. This requires a Shopify store.
|
|
184
265
|
|
|
266
|
+
To elicit a request that will be rate-limited by Shopify run following Rake task:
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
bundle exec rake rate_limit SHOPIFY_DOMAIN=your-domain SHOPIFY_TOKEN=your-token
|
|
270
|
+
```
|
|
271
|
+
|
|
185
272
|
## See Also
|
|
186
273
|
|
|
187
274
|
- [Shopify Dev Tools](https://github.com/ScreenStaring/shopify-dev-tools) - Command-line program to assist with the development and/or maintenance of Shopify apps and stores
|
|
188
|
-
- [Shopify ID Export](https://github.com/ScreenStaring/shopify_id_export/) Dump Shopify product and variant IDs —along with other identifiers— to a CSV or JSON file
|
|
189
|
-
- [
|
|
275
|
+
- [Shopify ID Export](https://github.com/ScreenStaring/shopify_id_export/) - Dump Shopify product and variant IDs —along with other identifiers— to a CSV or JSON file
|
|
276
|
+
- [`TinyGID`](https://github.com/sshaw/tiny_gid/) - Build Global ID (gid://) URI strings from scalar values
|
|
190
277
|
|
|
191
278
|
## License
|
|
192
279
|
|
data/Rakefile
CHANGED
|
@@ -4,3 +4,161 @@ require "rspec/core/rake_task"
|
|
|
4
4
|
RSpec::Core::RakeTask.new(:spec)
|
|
5
5
|
|
|
6
6
|
task :default => :spec
|
|
7
|
+
|
|
8
|
+
desc "Elicit a Shopify rate limit"
|
|
9
|
+
task :rate_limit do
|
|
10
|
+
require "shopify_api/graphql/tiny"
|
|
11
|
+
|
|
12
|
+
query =<<-GQL
|
|
13
|
+
query {
|
|
14
|
+
products(first: 50, sortKey: UPDATED_AT, reverse: true) {
|
|
15
|
+
pageInfo {
|
|
16
|
+
hasNextPage
|
|
17
|
+
endCursor
|
|
18
|
+
}
|
|
19
|
+
edges {
|
|
20
|
+
node {
|
|
21
|
+
id
|
|
22
|
+
title
|
|
23
|
+
handle
|
|
24
|
+
status
|
|
25
|
+
createdAt
|
|
26
|
+
updatedAt
|
|
27
|
+
publishedAt
|
|
28
|
+
vendor
|
|
29
|
+
productType
|
|
30
|
+
tags
|
|
31
|
+
descriptionHtml
|
|
32
|
+
description
|
|
33
|
+
onlineStoreUrl
|
|
34
|
+
options(first: 3) {
|
|
35
|
+
id
|
|
36
|
+
name
|
|
37
|
+
position
|
|
38
|
+
values
|
|
39
|
+
}
|
|
40
|
+
variants(first: 80) {
|
|
41
|
+
edges {
|
|
42
|
+
node {
|
|
43
|
+
id
|
|
44
|
+
title
|
|
45
|
+
price
|
|
46
|
+
compareAtPrice
|
|
47
|
+
sku
|
|
48
|
+
barcode
|
|
49
|
+
inventoryItem {
|
|
50
|
+
id
|
|
51
|
+
unitCost {
|
|
52
|
+
amount
|
|
53
|
+
currencyCode
|
|
54
|
+
}
|
|
55
|
+
countryCodeOfOrigin
|
|
56
|
+
harmonizedSystemCode
|
|
57
|
+
}
|
|
58
|
+
inventoryPolicy
|
|
59
|
+
taxable
|
|
60
|
+
availableForSale
|
|
61
|
+
metafields(first: 20) {
|
|
62
|
+
edges {
|
|
63
|
+
node {
|
|
64
|
+
id
|
|
65
|
+
namespace
|
|
66
|
+
key
|
|
67
|
+
value
|
|
68
|
+
type
|
|
69
|
+
description
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
media(first: 20) {
|
|
77
|
+
edges {
|
|
78
|
+
node {
|
|
79
|
+
__typename
|
|
80
|
+
alt
|
|
81
|
+
status
|
|
82
|
+
... on MediaImage {
|
|
83
|
+
id
|
|
84
|
+
preview {
|
|
85
|
+
image {
|
|
86
|
+
url
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
image {
|
|
90
|
+
id
|
|
91
|
+
url
|
|
92
|
+
width
|
|
93
|
+
height
|
|
94
|
+
altText
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
... on Video {
|
|
98
|
+
id
|
|
99
|
+
sources {
|
|
100
|
+
url
|
|
101
|
+
format
|
|
102
|
+
height
|
|
103
|
+
width
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
... on ExternalVideo {
|
|
107
|
+
id
|
|
108
|
+
originUrl
|
|
109
|
+
embedUrl
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
images(first: 20) {
|
|
115
|
+
edges {
|
|
116
|
+
node {
|
|
117
|
+
id
|
|
118
|
+
url
|
|
119
|
+
altText
|
|
120
|
+
width
|
|
121
|
+
height
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
seo {
|
|
126
|
+
title
|
|
127
|
+
description
|
|
128
|
+
}
|
|
129
|
+
metafields(first: 30) {
|
|
130
|
+
edges {
|
|
131
|
+
node {
|
|
132
|
+
id
|
|
133
|
+
namespace
|
|
134
|
+
key
|
|
135
|
+
value
|
|
136
|
+
type
|
|
137
|
+
ownerType
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
collections(first: 10) {
|
|
142
|
+
edges {
|
|
143
|
+
node {
|
|
144
|
+
id
|
|
145
|
+
title
|
|
146
|
+
handle
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
GQL
|
|
155
|
+
|
|
156
|
+
threads = 5.times.map do
|
|
157
|
+
Thread.new do
|
|
158
|
+
gql = ShopifyAPI::GQL::Tiny.new(ENV.fetch("SHOPIFY_DOMAIN"), ENV.fetch("SHOPIFY_TOKEN"), :debug => true)
|
|
159
|
+
pp gql.execute(query).dig("extensions", "cost", "throttleStatus")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
threads.each(&:join)
|
|
164
|
+
end
|
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
require "json"
|
|
3
3
|
require "net/http"
|
|
4
4
|
|
|
5
|
-
require "
|
|
5
|
+
require "net_http_timeout_errors"
|
|
6
6
|
require "shopify_api/graphql/tiny/version"
|
|
7
7
|
|
|
8
8
|
module ShopifyAPI
|
|
9
9
|
module GraphQL
|
|
10
10
|
##
|
|
11
|
-
#
|
|
11
|
+
# Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry
|
|
12
12
|
#
|
|
13
13
|
class Tiny
|
|
14
14
|
Error = Class.new(StandardError)
|
|
15
15
|
ConnectionError = Class.new(Error)
|
|
16
16
|
|
|
17
|
+
ERROR_CODE_THROTTLED = "THROTTLED"
|
|
18
|
+
ERROR_CODE_TIMEOUT = "TIMEOUT"
|
|
19
|
+
ERROR_CODE_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
|
|
20
|
+
|
|
17
21
|
class GraphQLError < Error
|
|
18
22
|
# Hash of failed GraphQL response
|
|
19
23
|
attr_reader :response
|
|
@@ -44,18 +48,26 @@ module ShopifyAPI
|
|
|
44
48
|
|
|
45
49
|
DEFAULT_HEADERS = { "Content-Type" => "application/json" }.freeze
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
DEFAULT_BACKOFF_OPTIONS = {
|
|
52
|
+
:base_delay => 0.5,
|
|
53
|
+
:jitter => true,
|
|
54
|
+
:max_attempts => 10,
|
|
55
|
+
:max_delay => 60,
|
|
56
|
+
:multiplier => 2.0
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
DEFAULT_RETRY_ERRORS = [
|
|
60
|
+
"5XX",
|
|
61
|
+
ERROR_CODE_SERVER_ERROR,
|
|
62
|
+
ERROR_CODE_TIMEOUT,
|
|
63
|
+
*NetHttpTimeoutErrors.all
|
|
64
|
+
]
|
|
65
|
+
|
|
54
66
|
ENDPOINT = "https://%s/admin/api%s/graphql.json" # Note that we omit the "/" after API for the case where there's no version.
|
|
55
67
|
|
|
56
68
|
##
|
|
57
69
|
#
|
|
58
|
-
# Create a new GraphQL client
|
|
70
|
+
# Create a new GraphQL client
|
|
59
71
|
#
|
|
60
72
|
# === Arguments
|
|
61
73
|
#
|
|
@@ -65,8 +77,12 @@ module ShopifyAPI
|
|
|
65
77
|
#
|
|
66
78
|
# === Options
|
|
67
79
|
#
|
|
68
|
-
# [:retry (Boolean|
|
|
80
|
+
# [:retry (Boolean|Array)] If +false+ disable retries or an +Array+ of errors to retry. Can be HTTP status codes, GraphQL errors, or exception classes.
|
|
69
81
|
# [:version (String)] Shopify API version to use. Defaults to the latest version.
|
|
82
|
+
# [:max_attempts (Integer)] Maximum number of retry attempts across all errors. Defaults to +10+
|
|
83
|
+
# [:base_delay (Float)] Exponential backoff base delay. Defaults to +0.5+
|
|
84
|
+
# [:jitter (Boolean)] Exponential backoff jitter (random delay added to backoff). Defaults to +true+
|
|
85
|
+
# [:multiplier (Float)] Exponential backoff multiplier. Defaults to +2.0+
|
|
70
86
|
# [:debug (Boolean|IO)] Output the HTTP request/response to +STDERR+ or to its value if it's an +IO+. Defaults to +false+.
|
|
71
87
|
#
|
|
72
88
|
# === Errors
|
|
@@ -83,13 +99,27 @@ module ShopifyAPI
|
|
|
83
99
|
|
|
84
100
|
@headers = DEFAULT_HEADERS.dup
|
|
85
101
|
@headers[ACCESS_TOKEN_HEADER] = token
|
|
86
|
-
@headers[QUERY_COST_HEADER] = "true"
|
|
102
|
+
@headers[QUERY_COST_HEADER] = "true" unless @options[:retry] == false
|
|
87
103
|
|
|
88
104
|
@endpoint = URI(sprintf(ENDPOINT, @domain, !@options[:version].to_s.strip.empty? ? "/#{@options[:version]}" : ""))
|
|
105
|
+
@backoff_options = DEFAULT_BACKOFF_OPTIONS.merge(@options.slice(*DEFAULT_BACKOFF_OPTIONS.keys))
|
|
106
|
+
|
|
107
|
+
if @options[:debug]
|
|
108
|
+
@debug = @options[:debug].is_a?(IO) ? @options[:debug] : $stderr
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
case @options[:retry]
|
|
112
|
+
when false
|
|
113
|
+
@retryable = []
|
|
114
|
+
when Array
|
|
115
|
+
@retryable = @options[:retry]
|
|
116
|
+
else
|
|
117
|
+
@retryable = DEFAULT_RETRY_ERRORS
|
|
118
|
+
end
|
|
89
119
|
end
|
|
90
120
|
|
|
91
121
|
#
|
|
92
|
-
# Execute a GraphQL query or mutation
|
|
122
|
+
# Execute a GraphQL query or mutation
|
|
93
123
|
#
|
|
94
124
|
# === Arguments
|
|
95
125
|
#
|
|
@@ -100,6 +130,8 @@ module ShopifyAPI
|
|
|
100
130
|
#
|
|
101
131
|
# ArgumentError, ConnectionError, HTTPError, RateLimitError, GraphQLError
|
|
102
132
|
#
|
|
133
|
+
# Outside of ArgumentError these are raised after exhausing the configured retry.
|
|
134
|
+
#
|
|
103
135
|
# * An ShopifyAPI::GraphQL::Tiny::HTTPError is raised of the response does not have 200 status code
|
|
104
136
|
# * A ShopifyAPI::GraphQL::Tiny::RateLimitError is raised if rate-limited and retries are disabled or if still
|
|
105
137
|
# rate-limited after the configured number of retry attempts
|
|
@@ -113,8 +145,9 @@ module ShopifyAPI
|
|
|
113
145
|
def execute(q, variables = nil)
|
|
114
146
|
raise ArgumentError, "query required" if q.nil? || q.to_s.strip.empty?
|
|
115
147
|
|
|
116
|
-
|
|
117
|
-
|
|
148
|
+
@request_attempts = 0
|
|
149
|
+
|
|
150
|
+
make_request(q, variables)
|
|
118
151
|
end
|
|
119
152
|
|
|
120
153
|
##
|
|
@@ -165,57 +198,134 @@ module ShopifyAPI
|
|
|
165
198
|
|
|
166
199
|
private
|
|
167
200
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
201
|
+
def make_request(query, variables = nil)
|
|
202
|
+
response = nil
|
|
203
|
+
exceptions = @retryable.select { |target| target.is_a?(Class) }
|
|
171
204
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
begin
|
|
206
|
+
@request_attempts += 1
|
|
207
|
+
response = post(query, variables)
|
|
208
|
+
rescue => e
|
|
209
|
+
retry if exceptions.include?(e.class) && wait_to_retry
|
|
210
|
+
raise ConnectionError.new("failed to execute query for #@domain: #{e.message}")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if response.code != "200"
|
|
214
|
+
return make_request(query, variables) if handle_http_error(response.code)
|
|
215
|
+
raise HTTPError.new("failed to execute query for #@domain: #{response.body}", response.code)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
json = parse_json(response.body)
|
|
219
|
+
return json unless json.include?("errors")
|
|
220
|
+
|
|
221
|
+
return make_request(query, variables) if handle_graphql_error(json)
|
|
222
|
+
|
|
223
|
+
message = error_message(json["errors"])
|
|
224
|
+
raise GraphQLError.new("failed to execute query for #@domain: #{message}", json)
|
|
176
225
|
end
|
|
177
226
|
|
|
178
227
|
def post(query, variables = nil)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
params[:variables] = variables if variables
|
|
228
|
+
# Newer versions of Ruby:
|
|
229
|
+
# response = Net::HTTP.post(@endpoint, query, @headers)
|
|
230
|
+
params = { :query => query }
|
|
231
|
+
params[:variables] = variables if variables
|
|
184
232
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
233
|
+
post = Net::HTTP::Post.new(@endpoint.path)
|
|
234
|
+
post.body = params.to_json
|
|
235
|
+
post["User-Agent"] = USER_AGENT
|
|
188
236
|
|
|
189
|
-
|
|
237
|
+
@headers.each { |k,v| post[k] = v }
|
|
190
238
|
|
|
191
|
-
|
|
192
|
-
|
|
239
|
+
request = Net::HTTP.new(@endpoint.host, @endpoint.port)
|
|
240
|
+
request.use_ssl = true
|
|
241
|
+
request.set_debug_output(@debug) if @debug
|
|
193
242
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
@options[:debug].is_a?(IO) ? @options[:debug] : $stderr
|
|
197
|
-
)
|
|
198
|
-
end
|
|
243
|
+
request.start { |http| http.request(post) }
|
|
244
|
+
end
|
|
199
245
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
246
|
+
def handle_graphql_error(json)
|
|
247
|
+
errors = json["errors"]
|
|
248
|
+
codes = errors.map { |error| error.dig("extensions", "code") }
|
|
249
|
+
|
|
250
|
+
if codes.include?(ERROR_CODE_THROTTLED)
|
|
251
|
+
return true if wait_for_shopify_retry(json.dig("extensions", "cost"))
|
|
252
|
+
|
|
253
|
+
raise RateLimitError.new(error_message(errors), json)
|
|
203
254
|
end
|
|
204
255
|
|
|
205
|
-
|
|
206
|
-
prefix = "failed to execute query for #@domain: "
|
|
207
|
-
raise HTTPError.new("#{prefix}#{response.body}", response.code) if response.code != "200"
|
|
256
|
+
return true if @retryable.any? { |error| codes.include?(error) } && wait_to_retry
|
|
208
257
|
|
|
209
|
-
|
|
210
|
-
|
|
258
|
+
false
|
|
259
|
+
end
|
|
211
260
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
261
|
+
def handle_http_error(status)
|
|
262
|
+
return false unless @retryable.include?(status) || @retryable.any? { |error| error.is_a?(String) && error.size == 3 && error.end_with?("XX") && error[0] == status[0] }
|
|
263
|
+
|
|
264
|
+
wait_to_retry
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def wait_to_retry
|
|
268
|
+
return false unless request_attempts_remain?
|
|
269
|
+
|
|
270
|
+
backoff(@request_attempts)
|
|
271
|
+
|
|
272
|
+
true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def wait_for_shopify_retry(cost)
|
|
276
|
+
return false if cost.nil? || cost["actualQueryCost"] || !request_attempts_remain?
|
|
277
|
+
|
|
278
|
+
status = cost["throttleStatus"]
|
|
279
|
+
time = (cost["requestedQueryCost"] - status["currentlyAvailable"]) / status["restoreRate"]
|
|
280
|
+
|
|
281
|
+
debug("retrying rate-limited request (retry count: #@request_attempts, status: #{status}, sleep: #{time})")
|
|
282
|
+
|
|
283
|
+
sleep(time)
|
|
284
|
+
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def backoff(attempts)
|
|
289
|
+
delay = @backoff_options[:base_delay] * (@backoff_options[:multiplier] ** (attempts - 1))
|
|
290
|
+
delay = [delay, @backoff_options[:max_delay]].min if @backoff_options[:max_delay]
|
|
291
|
+
delay = rand * delay if @backoff_options[:jitter]
|
|
292
|
+
|
|
293
|
+
debug("backoff sleeping for #{delay}")
|
|
294
|
+
|
|
295
|
+
sleep(delay)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def shopify_domain(host)
|
|
299
|
+
domain = host.sub(%r{\Ahttps?://}i, "")
|
|
300
|
+
domain << SHOPIFY_DOMAIN unless domain.end_with?(SHOPIFY_DOMAIN)
|
|
301
|
+
domain
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def parse_json(json)
|
|
305
|
+
JSON.parse(json)
|
|
306
|
+
rescue JSON::ParserError => e
|
|
307
|
+
raise Error, "failed to parse JSON response: #{e.message}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def error_message(errors)
|
|
311
|
+
errors.map do |e|
|
|
312
|
+
message = e["message"]
|
|
313
|
+
|
|
314
|
+
path = e["path"]
|
|
315
|
+
message << sprintf(" at %s", path.join(".")) if path
|
|
316
|
+
|
|
317
|
+
message
|
|
318
|
+
end.join(", ")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def request_attempts_remain?
|
|
322
|
+
@request_attempts < @backoff_options[:max_attempts]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def debug(message)
|
|
326
|
+
return unless @debug
|
|
217
327
|
|
|
218
|
-
|
|
328
|
+
@debug.puts "#{self.class}: #{message}"
|
|
219
329
|
end
|
|
220
330
|
end
|
|
221
331
|
|
|
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
"source_code_uri" => "https://github.com/ScreenStaring/shopify_api-graphql-tiny",
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
spec.add_dependency "
|
|
31
|
+
spec.add_dependency "net_http_timeout_errors"
|
|
32
32
|
spec.add_development_dependency "webmock", "~> 3.0"
|
|
33
33
|
spec.add_development_dependency "bundler"
|
|
34
34
|
spec.add_development_dependency "rake", ">= 12.3.3"
|
metadata
CHANGED
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shopify_api-graphql-tiny
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Skye Shaw
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 2026-01-20 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
13
|
+
name: net_http_timeout_errors
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
17
|
-
- - "
|
|
16
|
+
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0
|
|
18
|
+
version: '0'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
|
-
- - "
|
|
23
|
+
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0
|
|
25
|
+
version: '0'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: webmock
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -80,7 +79,6 @@ dependencies:
|
|
|
80
79
|
- - "~>"
|
|
81
80
|
- !ruby/object:Gem::Version
|
|
82
81
|
version: '3.0'
|
|
83
|
-
description:
|
|
84
82
|
email:
|
|
85
83
|
- skye.shaw@gmail.com
|
|
86
84
|
executables: []
|
|
@@ -109,7 +107,6 @@ metadata:
|
|
|
109
107
|
changelog_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny/blob/master/Changes
|
|
110
108
|
documentation_uri: https://rubydoc.info/gems/shopify_api-graphql-tiny
|
|
111
109
|
source_code_uri: https://github.com/ScreenStaring/shopify_api-graphql-tiny
|
|
112
|
-
post_install_message:
|
|
113
110
|
rdoc_options: []
|
|
114
111
|
require_paths:
|
|
115
112
|
- lib
|
|
@@ -124,8 +121,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
124
121
|
- !ruby/object:Gem::Version
|
|
125
122
|
version: '0'
|
|
126
123
|
requirements: []
|
|
127
|
-
rubygems_version: 3.
|
|
128
|
-
signing_key:
|
|
124
|
+
rubygems_version: 3.6.2
|
|
129
125
|
specification_version: 4
|
|
130
126
|
summary: Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in
|
|
131
127
|
pagination and retry
|