airbyte-source-shopify 2.4.14.dev202407181247__py3-none-any.whl → 3.1.0__py3-none-any.whl
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.
- {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/METADATA +4 -4
- {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/RECORD +25 -27
- {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/WHEEL +1 -1
- source_shopify/auth.py +0 -1
- source_shopify/config_migrations.py +4 -1
- source_shopify/http_request.py +4 -2
- source_shopify/schemas/countries.json +7 -19
- source_shopify/schemas/customer_journey_summary.json +228 -148
- source_shopify/schemas/deleted_products.json +27 -0
- source_shopify/schemas/orders.json +38 -0
- source_shopify/schemas/product_variants.json +26 -8
- source_shopify/schemas/profile_location_groups.json +10 -0
- source_shopify/scopes.py +7 -6
- source_shopify/shopify_graphql/bulk/exceptions.py +6 -1
- source_shopify/shopify_graphql/bulk/job.py +173 -65
- source_shopify/shopify_graphql/bulk/query.py +440 -88
- source_shopify/shopify_graphql/bulk/record.py +260 -29
- source_shopify/shopify_graphql/bulk/retry.py +12 -12
- source_shopify/shopify_graphql/bulk/tools.py +17 -2
- source_shopify/source.py +6 -10
- source_shopify/spec.json +11 -5
- source_shopify/streams/base_streams.py +181 -54
- source_shopify/streams/streams.py +211 -58
- source_shopify/utils.py +47 -12
- source_shopify/schemas/customer_saved_search.json +0 -32
- source_shopify/schemas/products_graph_ql.json +0 -123
- source_shopify/shopify_graphql/graphql.py +0 -64
- source_shopify/shopify_graphql/schema.py +0 -29442
- {airbyte_source_shopify-2.4.14.dev202407181247.dist-info → airbyte_source_shopify-3.1.0.dist-info}/entry_points.txt +0 -0
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
#
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
6
8
|
from typing import Any, Iterable, Mapping, MutableMapping, Optional
|
|
7
9
|
|
|
8
10
|
import requests
|
|
9
|
-
from airbyte_cdk.sources.streams.core import package_name_from_class
|
|
10
|
-
from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader
|
|
11
|
-
from requests.exceptions import RequestException
|
|
12
11
|
from source_shopify.shopify_graphql.bulk.query import (
|
|
13
12
|
Collection,
|
|
14
13
|
CustomerAddresses,
|
|
15
14
|
CustomerJourney,
|
|
15
|
+
DeliveryProfile,
|
|
16
16
|
DiscountCode,
|
|
17
17
|
FulfillmentOrder,
|
|
18
18
|
InventoryItem,
|
|
@@ -30,13 +30,19 @@ from source_shopify.shopify_graphql.bulk.query import (
|
|
|
30
30
|
Product,
|
|
31
31
|
ProductImage,
|
|
32
32
|
ProductVariant,
|
|
33
|
+
ProfileLocationGroups,
|
|
33
34
|
Transaction,
|
|
34
35
|
)
|
|
35
|
-
from source_shopify.
|
|
36
|
-
|
|
37
|
-
from
|
|
36
|
+
from source_shopify.utils import LimitReducingErrorHandler, ShopifyNonRetryableErrors
|
|
37
|
+
|
|
38
|
+
from airbyte_cdk import HttpSubStream
|
|
39
|
+
from airbyte_cdk.sources.streams.core import package_name_from_class
|
|
40
|
+
from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
|
|
41
|
+
from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
|
|
42
|
+
from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader
|
|
38
43
|
|
|
39
44
|
from .base_streams import (
|
|
45
|
+
FullRefreshShopifyGraphQlBulkStream,
|
|
40
46
|
IncrementalShopifyGraphQlBulkStream,
|
|
41
47
|
IncrementalShopifyNestedStream,
|
|
42
48
|
IncrementalShopifyStream,
|
|
@@ -76,21 +82,32 @@ class Customers(IncrementalShopifyStream):
|
|
|
76
82
|
|
|
77
83
|
|
|
78
84
|
class MetafieldCustomers(IncrementalShopifyGraphQlBulkStream):
|
|
85
|
+
parent_stream_class = Customers
|
|
79
86
|
bulk_query: MetafieldCustomer = MetafieldCustomer
|
|
80
87
|
|
|
81
88
|
|
|
82
89
|
class Orders(IncrementalShopifyStreamWithDeletedEvents):
|
|
83
90
|
data_field = "orders"
|
|
84
91
|
deleted_events_api_name = "Order"
|
|
92
|
+
initial_limit = 250
|
|
85
93
|
|
|
86
|
-
def
|
|
87
|
-
self
|
|
88
|
-
|
|
94
|
+
def __init__(self, config: Mapping[str, Any]):
|
|
95
|
+
self._error_handler = LimitReducingErrorHandler(
|
|
96
|
+
max_retries=5,
|
|
97
|
+
error_mapping=DEFAULT_ERROR_MAPPING | ShopifyNonRetryableErrors("orders"),
|
|
98
|
+
)
|
|
99
|
+
super().__init__(config)
|
|
100
|
+
|
|
101
|
+
def request_params(self, stream_state=None, next_page_token=None, **kwargs):
|
|
89
102
|
params = super().request_params(stream_state=stream_state, next_page_token=next_page_token, **kwargs)
|
|
103
|
+
params["limit"] = self.initial_limit # Always start with the default limit; error handler will mutate on retry
|
|
90
104
|
if not next_page_token:
|
|
91
105
|
params["status"] = "any"
|
|
92
106
|
return params
|
|
93
107
|
|
|
108
|
+
def get_error_handler(self):
|
|
109
|
+
return self._error_handler
|
|
110
|
+
|
|
94
111
|
|
|
95
112
|
class Disputes(IncrementalShopifyStream):
|
|
96
113
|
data_field = "disputes"
|
|
@@ -116,69 +133,130 @@ class MetafieldDraftOrders(IncrementalShopifyGraphQlBulkStream):
|
|
|
116
133
|
|
|
117
134
|
class Products(IncrementalShopifyGraphQlBulkStream):
|
|
118
135
|
bulk_query: Product = Product
|
|
119
|
-
# pin the api version
|
|
120
136
|
|
|
121
137
|
|
|
122
|
-
class
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
class DeletedProducts(IncrementalShopifyStream):
|
|
139
|
+
"""
|
|
140
|
+
Stream for fetching deleted products using the Shopify GraphQL Events API.
|
|
141
|
+
This stream queries events with action:destroy and subject_type:Product to get deleted product records.
|
|
142
|
+
https://shopify.dev/docs/api/admin-graphql/latest/queries/events
|
|
143
|
+
|
|
144
|
+
Note: This stream extends IncrementalShopifyStream (REST base class) rather than IncrementalShopifyGraphQlBulkStream
|
|
145
|
+
because it uses Shopify's standard GraphQL Events API, NOT the Bulk Operations API (bulkOperationRunQuery).
|
|
146
|
+
The Events API has a fundamentally different architecture:
|
|
147
|
+
- Uses immediate queries with cursor pagination (not async job creation + file download)
|
|
148
|
+
- Returns results in response.data.events.nodes (not JSONL files)
|
|
149
|
+
- Requires different URL endpoint (/graphql.json), request format (POST with query in body), and pagination logic
|
|
150
|
+
|
|
151
|
+
Therefore, url_base, path, request_params, next_page_token, and parse_response are overridden to accommodate
|
|
152
|
+
the GraphQL request/response structure while maintaining incremental sync capabilities from the base class.
|
|
153
|
+
"""
|
|
154
|
+
|
|
125
155
|
data_field = "graphql"
|
|
156
|
+
cursor_field = "deleted_at"
|
|
126
157
|
http_method = "POST"
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
filter_field = None
|
|
159
|
+
|
|
160
|
+
_page_cursor: Optional[str] = None
|
|
161
|
+
|
|
162
|
+
EVENTS_QUERY = """
|
|
163
|
+
query GetDeletedProductEvents($first: Int!, $after: String, $query: String) {
|
|
164
|
+
events(first: $first, after: $after, query: $query, sortKey: CREATED_AT) {
|
|
165
|
+
pageInfo {
|
|
166
|
+
hasNextPage
|
|
167
|
+
endCursor
|
|
168
|
+
}
|
|
169
|
+
nodes {
|
|
170
|
+
... on BasicEvent {
|
|
171
|
+
id
|
|
172
|
+
createdAt
|
|
173
|
+
message
|
|
174
|
+
subjectId
|
|
175
|
+
subjectType
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def url_base(self) -> str:
|
|
184
|
+
return f"https://{self.config['shop']}.myshopify.com/admin/api/{self.api_version}/graphql.json"
|
|
185
|
+
|
|
186
|
+
def path(self, **kwargs) -> str:
|
|
187
|
+
return ""
|
|
129
188
|
|
|
130
189
|
def request_params(
|
|
131
|
-
self,
|
|
132
|
-
stream_state: Optional[Mapping[str, Any]] = None,
|
|
133
|
-
next_page_token: Optional[Mapping[str, Any]] = None,
|
|
134
|
-
**kwargs,
|
|
190
|
+
self, stream_state: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None, **kwargs
|
|
135
191
|
) -> MutableMapping[str, Any]:
|
|
136
192
|
return {}
|
|
137
193
|
|
|
138
194
|
def request_body_json(
|
|
139
195
|
self,
|
|
140
|
-
stream_state: Mapping[str, Any],
|
|
196
|
+
stream_state: Optional[Mapping[str, Any]] = None,
|
|
141
197
|
stream_slice: Optional[Mapping[str, Any]] = None,
|
|
142
198
|
next_page_token: Optional[Mapping[str, Any]] = None,
|
|
143
|
-
) -> Optional[Mapping]:
|
|
144
|
-
|
|
145
|
-
if
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
first
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
) -> Optional[Mapping[str, Any]]:
|
|
200
|
+
query_filter = "action:destroy AND subject_type:Product"
|
|
201
|
+
if stream_state and stream_state.get(self.cursor_field):
|
|
202
|
+
state_value = stream_state[self.cursor_field]
|
|
203
|
+
query_filter += f" AND created_at:>'{state_value}'"
|
|
204
|
+
|
|
205
|
+
variables = {
|
|
206
|
+
"first": 250,
|
|
207
|
+
"query": query_filter,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if next_page_token and next_page_token.get("cursor"):
|
|
211
|
+
variables["after"] = next_page_token["cursor"]
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"query": self.EVENTS_QUERY,
|
|
215
|
+
"variables": variables,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
|
|
219
|
+
json_response = response.json()
|
|
220
|
+
page_info = json_response.get("data", {}).get("events", {}).get("pageInfo", {})
|
|
221
|
+
if page_info.get("hasNextPage"):
|
|
222
|
+
return {"cursor": page_info.get("endCursor")}
|
|
223
|
+
return None
|
|
162
224
|
|
|
163
|
-
@limiter.balance_rate_limit(api_type=ApiTypeEnum.graphql.value)
|
|
164
225
|
def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
226
|
+
json_response = response.json()
|
|
227
|
+
events = json_response.get("data", {}).get("events", {}).get("nodes", [])
|
|
228
|
+
for event in events:
|
|
229
|
+
if event.get("subjectType") == "PRODUCT" and event.get("subjectId"):
|
|
230
|
+
subject_id = event.get("subjectId", "")
|
|
231
|
+
product_id = int(subject_id.split("/")[-1]) if "/" in subject_id else None
|
|
232
|
+
if product_id:
|
|
233
|
+
yield {
|
|
234
|
+
"id": product_id,
|
|
235
|
+
"deleted_at": event.get("createdAt"),
|
|
236
|
+
"deleted_message": event.get("message"),
|
|
237
|
+
"deleted_description": None,
|
|
238
|
+
"shop_url": self.config.get("shop"),
|
|
239
|
+
}
|
|
171
240
|
|
|
172
241
|
|
|
173
242
|
class MetafieldProducts(IncrementalShopifyGraphQlBulkStream):
|
|
243
|
+
parent_stream_class = Products
|
|
174
244
|
bulk_query: MetafieldProduct = MetafieldProduct
|
|
175
245
|
|
|
246
|
+
state_checkpoint_interval = sys.maxsize
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def filter_by_state_checkpoint(self) -> bool:
|
|
250
|
+
return True
|
|
251
|
+
|
|
176
252
|
|
|
177
253
|
class ProductImages(IncrementalShopifyGraphQlBulkStream):
|
|
254
|
+
parent_stream_class = Products
|
|
178
255
|
bulk_query: ProductImage = ProductImage
|
|
179
256
|
|
|
180
257
|
|
|
181
258
|
class MetafieldProductImages(IncrementalShopifyGraphQlBulkStream):
|
|
259
|
+
parent_stream_class = Products
|
|
182
260
|
bulk_query: MetafieldProductImage = MetafieldProductImage
|
|
183
261
|
|
|
184
262
|
|
|
@@ -246,7 +324,6 @@ class MetafieldCollections(IncrementalShopifyGraphQlBulkStream):
|
|
|
246
324
|
|
|
247
325
|
|
|
248
326
|
class BalanceTransactions(IncrementalShopifyStream):
|
|
249
|
-
|
|
250
327
|
"""
|
|
251
328
|
PaymentsTransactions stream does not support Incremental Refresh based on datetime fields, only `since_id` is supported:
|
|
252
329
|
https://shopify.dev/api/admin-rest/2021-07/resources/transactions
|
|
@@ -274,7 +351,6 @@ class OrderRefunds(IncrementalShopifyNestedStream):
|
|
|
274
351
|
|
|
275
352
|
class OrderRisks(IncrementalShopifyGraphQlBulkStream):
|
|
276
353
|
bulk_query: OrderRisk = OrderRisk
|
|
277
|
-
# the updated stream works only with >= `2024-04` shopify api version
|
|
278
354
|
|
|
279
355
|
|
|
280
356
|
class Transactions(IncrementalShopifySubstream):
|
|
@@ -375,19 +451,96 @@ class MetafieldShops(IncrementalShopifyStream):
|
|
|
375
451
|
data_field = "metafields"
|
|
376
452
|
|
|
377
453
|
|
|
378
|
-
class CustomerSavedSearch(IncrementalShopifyStream):
|
|
379
|
-
api_version = "2022-01"
|
|
380
|
-
cursor_field = "id"
|
|
381
|
-
order_field = "id"
|
|
382
|
-
data_field = "customer_saved_searches"
|
|
383
|
-
filter_field = "since_id"
|
|
384
|
-
|
|
385
|
-
|
|
386
454
|
class CustomerAddress(IncrementalShopifyGraphQlBulkStream):
|
|
387
455
|
parent_stream_class = Customers
|
|
388
456
|
bulk_query: CustomerAddresses = CustomerAddresses
|
|
389
457
|
cursor_field = "id"
|
|
390
458
|
|
|
391
459
|
|
|
392
|
-
class
|
|
393
|
-
|
|
460
|
+
class ProfileLocationGroups(IncrementalShopifyGraphQlBulkStream):
|
|
461
|
+
bulk_query: ProfileLocationGroups = ProfileLocationGroups
|
|
462
|
+
filter_field = None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Countries(HttpSubStream, FullRefreshShopifyGraphQlBulkStream):
|
|
466
|
+
# https://shopify.dev/docs/api/admin-graphql/latest/queries/deliveryProfiles
|
|
467
|
+
_page_cursor = None
|
|
468
|
+
_sub_page_cursor = None
|
|
469
|
+
|
|
470
|
+
_synced_countries_ids = []
|
|
471
|
+
|
|
472
|
+
query = DeliveryProfile
|
|
473
|
+
response_field = "deliveryProfiles"
|
|
474
|
+
|
|
475
|
+
def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]:
|
|
476
|
+
json_response = response.json().get("data", {})
|
|
477
|
+
if not json_response:
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
page_info = json_response.get("deliveryProfiles", {}).get("pageInfo", {})
|
|
481
|
+
|
|
482
|
+
sub_page_info = {"hasNextPage": False}
|
|
483
|
+
# only one top page in query
|
|
484
|
+
delivery_profiles_nodes = json_response.get("deliveryProfiles", {}).get("nodes")
|
|
485
|
+
if delivery_profiles_nodes:
|
|
486
|
+
profile_location_groups = delivery_profiles_nodes[0].get("profileLocationGroups")
|
|
487
|
+
if profile_location_groups:
|
|
488
|
+
sub_page_info = (
|
|
489
|
+
# only first one
|
|
490
|
+
profile_location_groups[0].get("locationGroupZones", {}).get("pageInfo", {})
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if not sub_page_info["hasNextPage"] and not page_info["hasNextPage"]:
|
|
494
|
+
return None
|
|
495
|
+
if sub_page_info["hasNextPage"]:
|
|
496
|
+
# The cursor to retrieve nodes after in the connection. Typically, you should pass the endCursor of the previous page as after.
|
|
497
|
+
self._sub_page_cursor = sub_page_info["endCursor"]
|
|
498
|
+
if page_info["hasNextPage"] and not sub_page_info["hasNextPage"]:
|
|
499
|
+
# The cursor to retrieve nodes after in the connection. Typically, you should pass the endCursor of the previous page as after.
|
|
500
|
+
self._page_cursor = page_info["endCursor"]
|
|
501
|
+
self._sub_page_cursor = None
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
"cursor": self._page_cursor,
|
|
505
|
+
"sub_cursor": self._sub_page_cursor,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
def request_body_json(
|
|
509
|
+
self,
|
|
510
|
+
stream_state: Optional[Mapping[str, Any]],
|
|
511
|
+
stream_slice: Optional[Mapping[str, Any]] = None,
|
|
512
|
+
next_page_token: Optional[Mapping[str, Any]] = None,
|
|
513
|
+
) -> Optional[Mapping[str, Any]]:
|
|
514
|
+
location_group_id = stream_slice["parent"]["profile_location_groups"][0]["locationGroup"]["id"]
|
|
515
|
+
return {
|
|
516
|
+
"query": self.query(location_group_id=location_group_id, location_group_zones_cursor=self._sub_page_cursor).get(
|
|
517
|
+
query_args={
|
|
518
|
+
"cursor": self._page_cursor,
|
|
519
|
+
}
|
|
520
|
+
),
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
|
|
524
|
+
# TODO: to refactor this using functools + partial, see comment https://github.com/airbytehq/airbyte/pull/55823#discussion_r2016335672
|
|
525
|
+
for node in super().parse_response(response, **kwargs):
|
|
526
|
+
for location_group in node.get("profileLocationGroups", []):
|
|
527
|
+
for location_group_zone in location_group.get("locationGroupZones", {}).get("nodes", []):
|
|
528
|
+
for country in location_group_zone.get("zone", {}).get("countries"):
|
|
529
|
+
country = self._process_country(country)
|
|
530
|
+
if country["id"] not in self._synced_countries_ids:
|
|
531
|
+
self._synced_countries_ids.append(country["id"])
|
|
532
|
+
yield country
|
|
533
|
+
|
|
534
|
+
def _process_country(self, country: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
535
|
+
country["id"] = int(country["id"].split("/")[-1])
|
|
536
|
+
|
|
537
|
+
for province in country.get("provinces", []):
|
|
538
|
+
province["id"] = int(province["id"].split("/")[-1])
|
|
539
|
+
province["country_id"] = country["id"]
|
|
540
|
+
|
|
541
|
+
if country.get("code"):
|
|
542
|
+
country["rest_of_world"] = country["code"]["rest_of_world"] if country["code"].get("rest_of_world") is not None else "*"
|
|
543
|
+
country["code"] = country["code"]["country_code"] if country["code"].get("country_code") is not None else "*"
|
|
544
|
+
|
|
545
|
+
country["shop_url"] = self.config["shop"]
|
|
546
|
+
return country
|
source_shopify/utils.py
CHANGED
|
@@ -7,12 +7,19 @@ import enum
|
|
|
7
7
|
import logging
|
|
8
8
|
from functools import wraps
|
|
9
9
|
from time import sleep
|
|
10
|
-
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
10
|
+
from typing import Any, Callable, Dict, Final, List, Mapping, Optional, Union
|
|
11
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
11
12
|
|
|
12
13
|
import requests
|
|
14
|
+
|
|
15
|
+
from airbyte_cdk.models import FailureType
|
|
16
|
+
from airbyte_cdk.sources.streams.http.error_handlers import HttpStatusErrorHandler
|
|
13
17
|
from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, ResponseAction
|
|
14
18
|
from airbyte_cdk.utils import AirbyteTracedException
|
|
15
|
-
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# default logger instance
|
|
22
|
+
LOGGER: Final[logging.Logger] = logging.getLogger("airbyte")
|
|
16
23
|
|
|
17
24
|
|
|
18
25
|
class ShopifyNonRetryableErrors:
|
|
@@ -40,11 +47,6 @@ class ShopifyNonRetryableErrors:
|
|
|
40
47
|
failure_type=FailureType.config_error,
|
|
41
48
|
error_message=f"Stream `{stream}`. Not available or missing.",
|
|
42
49
|
),
|
|
43
|
-
500: ErrorResolution(
|
|
44
|
-
response_action=ResponseAction.IGNORE,
|
|
45
|
-
failure_type=FailureType.config_error,
|
|
46
|
-
error_message=f"Stream `{stream}`. Entity might not be available or missing.",
|
|
47
|
-
)
|
|
48
50
|
# extend the mapping with more handable errors, if needed.
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -112,8 +114,6 @@ class ShopifyRateLimiter:
|
|
|
112
114
|
on_mid_load: float = 1.5
|
|
113
115
|
on_high_load: float = 5.0
|
|
114
116
|
|
|
115
|
-
logger = logging.getLogger("airbyte")
|
|
116
|
-
|
|
117
117
|
log_message_count = 0
|
|
118
118
|
log_message_frequency = 3
|
|
119
119
|
|
|
@@ -124,7 +124,7 @@ class ShopifyRateLimiter:
|
|
|
124
124
|
if ShopifyRateLimiter.log_message_count < ShopifyRateLimiter.log_message_frequency:
|
|
125
125
|
ShopifyRateLimiter.log_message_count += 1
|
|
126
126
|
else:
|
|
127
|
-
|
|
127
|
+
LOGGER.info(message)
|
|
128
128
|
ShopifyRateLimiter.log_message_count = 0
|
|
129
129
|
|
|
130
130
|
def get_response_from_args(*args) -> Optional[requests.Response]:
|
|
@@ -138,8 +138,8 @@ class ShopifyRateLimiter:
|
|
|
138
138
|
Define wait_time based on load conditions.
|
|
139
139
|
|
|
140
140
|
:: load - represents how close we are to being throttled
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
- 0.5 is half way through our allowance
|
|
142
|
+
- 1 indicates that all of the allowance is used and the api will start rejecting calls
|
|
143
143
|
:: threshold - is the % cutoff for the rate_limits/load
|
|
144
144
|
:: wait_time - time to wait between each request in seconds
|
|
145
145
|
|
|
@@ -325,3 +325,38 @@ class EagerlyCachedStreamState:
|
|
|
325
325
|
return func(*args, **kwargs)
|
|
326
326
|
|
|
327
327
|
return decorator
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class LimitReducingErrorHandler(HttpStatusErrorHandler):
|
|
331
|
+
"""
|
|
332
|
+
Error handler that halves the page size (limit) on each 500 error, down to 1.
|
|
333
|
+
No stream instance required; operates directly on the request URL.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def __init__(self, max_retries: int, error_mapping: dict):
|
|
337
|
+
super().__init__(logger=None, max_retries=max_retries, error_mapping=error_mapping)
|
|
338
|
+
|
|
339
|
+
def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> ErrorResolution:
|
|
340
|
+
if isinstance(response_or_exception, requests.Response):
|
|
341
|
+
response = response_or_exception
|
|
342
|
+
if response.status_code == 500:
|
|
343
|
+
# Extract current limit from the URL, default to 250 if not present
|
|
344
|
+
parsed = urlparse(response.request.url)
|
|
345
|
+
query = parse_qs(parsed.query)
|
|
346
|
+
current_limit = int(query.get("limit", ["250"])[0])
|
|
347
|
+
if current_limit > 1:
|
|
348
|
+
new_limit = max(1, current_limit // 2)
|
|
349
|
+
query["limit"] = [str(new_limit)]
|
|
350
|
+
new_query = urlencode(query, doseq=True)
|
|
351
|
+
response.request.url = urlunparse(parsed._replace(query=new_query))
|
|
352
|
+
return ErrorResolution(
|
|
353
|
+
response_action=ResponseAction.RETRY,
|
|
354
|
+
failure_type=FailureType.transient_error,
|
|
355
|
+
error_message=f"Server error 500: Reduced limit to {new_limit} and updating request URL",
|
|
356
|
+
)
|
|
357
|
+
return ErrorResolution(
|
|
358
|
+
response_action=ResponseAction.FAIL,
|
|
359
|
+
failure_type=FailureType.transient_error,
|
|
360
|
+
error_message="Persistent 500 error after reducing limit to 1",
|
|
361
|
+
)
|
|
362
|
+
return super().interpret_response(response_or_exception)
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"type": ["null", "object"],
|
|
3
|
-
"additionalProperties": true,
|
|
4
|
-
"properties": {
|
|
5
|
-
"created_at": {
|
|
6
|
-
"description": "The date and time when the customer saved search was created.",
|
|
7
|
-
"type": ["null", "string"],
|
|
8
|
-
"format": "date-time"
|
|
9
|
-
},
|
|
10
|
-
"id": {
|
|
11
|
-
"description": "The unique identifier for the customer saved search.",
|
|
12
|
-
"type": ["null", "integer"]
|
|
13
|
-
},
|
|
14
|
-
"name": {
|
|
15
|
-
"description": "The name given to the customer saved search.",
|
|
16
|
-
"type": ["null", "string"]
|
|
17
|
-
},
|
|
18
|
-
"query": {
|
|
19
|
-
"description": "The search query string or parameters used for this saved search.",
|
|
20
|
-
"type": ["null", "string"]
|
|
21
|
-
},
|
|
22
|
-
"shop_url": {
|
|
23
|
-
"description": "The URL of the shop associated with this customer saved search.",
|
|
24
|
-
"type": ["null", "string"]
|
|
25
|
-
},
|
|
26
|
-
"updated_at": {
|
|
27
|
-
"description": "The date and time when the customer saved search was last updated.",
|
|
28
|
-
"type": ["null", "string"],
|
|
29
|
-
"format": "date-time"
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "http://json-schema.org/schema#",
|
|
3
|
-
"properties": {
|
|
4
|
-
"createdAt": {
|
|
5
|
-
"description": "The date and time when the product was created.",
|
|
6
|
-
"type": "string"
|
|
7
|
-
},
|
|
8
|
-
"description": {
|
|
9
|
-
"description": "The product's description.",
|
|
10
|
-
"type": "string"
|
|
11
|
-
},
|
|
12
|
-
"descriptionHtml": {
|
|
13
|
-
"description": "The product's description in HTML format.",
|
|
14
|
-
"type": "string"
|
|
15
|
-
},
|
|
16
|
-
"handle": {
|
|
17
|
-
"description": "The unique URL-friendly handle of the product.",
|
|
18
|
-
"type": "string"
|
|
19
|
-
},
|
|
20
|
-
"id": {
|
|
21
|
-
"description": "The unique identifier of the product.",
|
|
22
|
-
"type": "string"
|
|
23
|
-
},
|
|
24
|
-
"isGiftCard": {
|
|
25
|
-
"description": "Indicates whether the product is a gift card.",
|
|
26
|
-
"type": "boolean"
|
|
27
|
-
},
|
|
28
|
-
"legacyResourceId": {
|
|
29
|
-
"description": "The legacy resource ID of the product.",
|
|
30
|
-
"type": "string"
|
|
31
|
-
},
|
|
32
|
-
"mediaCount": {
|
|
33
|
-
"description": "The total count of media (images/videos) associated with the product.",
|
|
34
|
-
"type": "integer"
|
|
35
|
-
},
|
|
36
|
-
"onlineStorePreviewUrl": {
|
|
37
|
-
"description": "The URL for previewing the product on the online store.",
|
|
38
|
-
"type": "string"
|
|
39
|
-
},
|
|
40
|
-
"onlineStoreUrl": {
|
|
41
|
-
"description": "The URL of the product on the online store.",
|
|
42
|
-
"type": ["null", "string"]
|
|
43
|
-
},
|
|
44
|
-
"options": {
|
|
45
|
-
"description": "Represents various options available for the product",
|
|
46
|
-
"items": {
|
|
47
|
-
"properties": {
|
|
48
|
-
"id": {
|
|
49
|
-
"description": "The unique identifier of the option.",
|
|
50
|
-
"type": "string"
|
|
51
|
-
},
|
|
52
|
-
"name": {
|
|
53
|
-
"description": "The name of the option.",
|
|
54
|
-
"type": "string"
|
|
55
|
-
},
|
|
56
|
-
"position": {
|
|
57
|
-
"description": "The position of the option.",
|
|
58
|
-
"type": "integer"
|
|
59
|
-
},
|
|
60
|
-
"values": {
|
|
61
|
-
"description": "Contains the different values for the options",
|
|
62
|
-
"items": {
|
|
63
|
-
"description": "The possible values for the option.",
|
|
64
|
-
"type": "string"
|
|
65
|
-
},
|
|
66
|
-
"type": "array"
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
"type": "object"
|
|
70
|
-
},
|
|
71
|
-
"type": "array"
|
|
72
|
-
},
|
|
73
|
-
"productType": {
|
|
74
|
-
"description": "The type or category of the product.",
|
|
75
|
-
"type": "string"
|
|
76
|
-
},
|
|
77
|
-
"publishedAt": {
|
|
78
|
-
"description": "The date and time when the product was published.",
|
|
79
|
-
"type": ["null", "string"]
|
|
80
|
-
},
|
|
81
|
-
"shop_url": {
|
|
82
|
-
"description": "The URL of the shop where the product is listed.",
|
|
83
|
-
"type": "string"
|
|
84
|
-
},
|
|
85
|
-
"status": {
|
|
86
|
-
"description": "The status of the product.",
|
|
87
|
-
"type": "string"
|
|
88
|
-
},
|
|
89
|
-
"tags": {
|
|
90
|
-
"description": "Contains tags associated with the product",
|
|
91
|
-
"items": {
|
|
92
|
-
"description": "The tags associated with the product.",
|
|
93
|
-
"type": "string"
|
|
94
|
-
},
|
|
95
|
-
"type": "array"
|
|
96
|
-
},
|
|
97
|
-
"title": {
|
|
98
|
-
"description": "The title or name of the product.",
|
|
99
|
-
"type": "string"
|
|
100
|
-
},
|
|
101
|
-
"totalInventory": {
|
|
102
|
-
"description": "The total inventory count of the product.",
|
|
103
|
-
"type": "integer"
|
|
104
|
-
},
|
|
105
|
-
"totalVariants": {
|
|
106
|
-
"description": "The total number of variants available for the product.",
|
|
107
|
-
"type": "integer"
|
|
108
|
-
},
|
|
109
|
-
"tracksInventory": {
|
|
110
|
-
"description": "Indicates whether inventory tracking is enabled for the product.",
|
|
111
|
-
"type": "boolean"
|
|
112
|
-
},
|
|
113
|
-
"updatedAt": {
|
|
114
|
-
"description": "The date and time when the product was last updated.",
|
|
115
|
-
"type": "string"
|
|
116
|
-
},
|
|
117
|
-
"vendor": {
|
|
118
|
-
"description": "The vendor or manufacturer of the product.",
|
|
119
|
-
"type": "string"
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
"type": "object"
|
|
123
|
-
}
|