airbyte-source-shopify 3.0.6.dev202505192339__py3-none-any.whl → 3.0.8.dev202507031652__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: airbyte-source-shopify
3
- Version: 3.0.6.dev202505192339
3
+ Version: 3.0.8.dev202507031652
4
4
  Summary: Source CDK implementation for Shopify.
5
5
  License: ELv2
6
6
  Author: Airbyte
@@ -52,7 +52,7 @@ source_shopify/schemas/transactions.json,sha256=vbwscH3UcAtbSsC70mBka4oNaFR4S3S6
52
52
  source_shopify/scopes.py,sha256=N0njfMHn3Q1AQXuTj5VfjQOio10jaDarpC_oLYnWvqc,6490
53
53
  source_shopify/shopify_graphql/bulk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
54
  source_shopify/shopify_graphql/bulk/exceptions.py,sha256=4dj7Za4xIfwL-zf8joT9svF_RSoGlE3GviMiIl1e1rs,2532
55
- source_shopify/shopify_graphql/bulk/job.py,sha256=0a3els2a5Fea-o30rkotq7yoPx56c481zwpZzxDajEw,27832
55
+ source_shopify/shopify_graphql/bulk/job.py,sha256=Bk158tbTtX18FfBJFCvv-mzfhsSV3qHFOgvV7T4e5Qk,35914
56
56
  source_shopify/shopify_graphql/bulk/query.py,sha256=D8rnI1SDw50-Gt18lt7YwwNNdsbVMbBfxZa9xVJZbto,130981
57
57
  source_shopify/shopify_graphql/bulk/record.py,sha256=X6VGngugv7a_S8UEeDo121BkdCVLj5nWlHK76A21kyo,16898
58
58
  source_shopify/shopify_graphql/bulk/retry.py,sha256=R5rSJJE8D5zcj6mN-OmmNO2aFZEIdjAlWclDDVW5KPI,2626
@@ -60,11 +60,11 @@ source_shopify/shopify_graphql/bulk/status.py,sha256=RmuQ2XsYL3iRCpVGxea9F1wXGmb
60
60
  source_shopify/shopify_graphql/bulk/tools.py,sha256=nUQ2ZmPTKJNJdfLToR6KJtLKcJFCChSifkAOvwg0Vss,4065
61
61
  source_shopify/source.py,sha256=txb3wIm-3xXd8-5QLSeu2TeHBSnppwy5PEIOEl40mVw,8517
62
62
  source_shopify/spec.json,sha256=ITYWiQ-NrI5VISk5qmUQhp9ChUE2FV18d8xzVzPwvAg,6144
63
- source_shopify/streams/base_streams.py,sha256=Jhxe4rkm330g8Us1C8V-9-Zb7-4z1z5xrNtSI5QjQ28,42272
64
- source_shopify/streams/streams.py,sha256=ys0v66J2NTGEJr1L78FuxZBcFoOpLA-FJwf5OcbvwhM,14215
63
+ source_shopify/streams/base_streams.py,sha256=FFIpHd5_-Z61W_jUucdr8D2MzUete1Y2E50bQDCLakE,41555
64
+ source_shopify/streams/streams.py,sha256=D70Ik1vU75NKlmJMnS7W2-5gApA2ANq9eRnKligMTNw,14555
65
65
  source_shopify/transform.py,sha256=mn0htL812_90zc_YszGQa0hHcIZQpYYdmk8IqpZm5TI,4685
66
- source_shopify/utils.py,sha256=sjiBSh7Ygtg1RTsFvGY3oDjAhqEaodFiLmQd2aurgSk,16609
67
- airbyte_source_shopify-3.0.6.dev202505192339.dist-info/METADATA,sha256=AjuciXe4zunbOM2FEdv0qQFUhLeKIQlErtCpVoQiRO0,5325
68
- airbyte_source_shopify-3.0.6.dev202505192339.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
69
- airbyte_source_shopify-3.0.6.dev202505192339.dist-info/entry_points.txt,sha256=SyTwKSsPk9MCdPf01saWpnp8hcmZOgBssVcSIvMbBeQ,57
70
- airbyte_source_shopify-3.0.6.dev202505192339.dist-info/RECORD,,
66
+ source_shopify/utils.py,sha256=DSqEchu-MQJ7zust7CNfqOkGIv9OSR-5UUsuD-bsDa8,16224
67
+ airbyte_source_shopify-3.0.8.dev202507031652.dist-info/METADATA,sha256=IWFlmQxyJV77pb_SwLFclF38YlEhIIF2H2IyWnwGzcs,5325
68
+ airbyte_source_shopify-3.0.8.dev202507031652.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
69
+ airbyte_source_shopify-3.0.8.dev202507031652.dist-info/entry_points.txt,sha256=SyTwKSsPk9MCdPf01saWpnp8hcmZOgBssVcSIvMbBeQ,57
70
+ airbyte_source_shopify-3.0.8.dev202507031652.dist-info/RECORD,,
@@ -5,6 +5,7 @@
5
5
  from dataclasses import dataclass, field
6
6
  from datetime import datetime
7
7
  from enum import Enum
8
+ from json import loads
8
9
  from time import sleep, time
9
10
  from typing import Any, Final, Iterable, List, Mapping, Optional
10
11
 
@@ -65,7 +66,7 @@ class ShopifyBulkManager:
65
66
 
66
67
  # currents: _job_id, _job_state, _job_created_at, _job_self_canceled
67
68
  _job_id: Optional[str] = field(init=False, default=None)
68
- _job_state: str | None = field(init=False, default=None) # this string is based on ShopifyBulkJobStatus
69
+ _job_state: Optional[str] = field(init=False, default=None) # this string is based on ShopifyBulkJobStatus
69
70
  # completed and saved Bulk Job result filename
70
71
  _job_result_filename: Optional[str] = field(init=False, default=None)
71
72
  # date-time when the Bulk Job was created on the server
@@ -83,7 +84,9 @@ class ShopifyBulkManager:
83
84
  # the flag to adjust the next slice from the checkpointed cursor vaue
84
85
  _job_adjust_slice_from_checkpoint: bool = field(init=False, default=False)
85
86
  # keeps the last checkpointed cursor value for supported streams
86
- _job_last_checkpoint_cursor_value: str | None = field(init=False, default=None)
87
+ _job_last_checkpoint_cursor_value: Optional[str] = field(init=False, default=None)
88
+ # stores extracted cursor from INTERNAL_SERVER_ERROR recovery (temporary storage)
89
+ _job_extracted_checkpoint_cursor: Optional[str] = field(init=False, default=None)
87
90
 
88
91
  # expand slice factor
89
92
  _job_size_expand_factor: int = field(init=False, default=2)
@@ -214,6 +217,8 @@ class ShopifyBulkManager:
214
217
  self._log_job_msg_count = 0
215
218
  # set the running job object count to default
216
219
  self._job_last_rec_count = 0
220
+ # clear any extracted cursor from INTERNAL_SERVER_ERROR recovery
221
+ self._job_extracted_checkpoint_cursor = None
217
222
 
218
223
  def _set_checkpointing(self) -> None:
219
224
  # set the flag to adjust the next slice from the checkpointed cursor value
@@ -313,6 +318,24 @@ class ShopifyBulkManager:
313
318
  # fetch the collected records from CANCELED Job on checkpointing
314
319
  self._job_result_filename = self._job_get_result(response)
315
320
 
321
+ # Special handling: For FAILED jobs with INTERNAL_SERVER_ERROR, extract the last processed cursor
322
+ if response:
323
+ parsed_response = response.json().get("data", {}).get("node", {}) if response else {}
324
+ error_code = parsed_response.get("errorCode")
325
+ if error_code == "INTERNAL_SERVER_ERROR":
326
+ last_cursor = self._extract_last_cursor_from_partial_data(response)
327
+ if last_cursor:
328
+ # Check if this cursor would cause a collision before storing it
329
+ if self._checkpoint_cursor_has_collision(last_cursor):
330
+ # Skip cursor extraction to avoid collision
331
+ pass
332
+ else:
333
+ # Store the extracted cursor for later use (don't set it yet to avoid collision)
334
+ self._job_extracted_checkpoint_cursor = last_cursor
335
+ else:
336
+ # Not processing data due to insufficient records or checkpointing disabled
337
+ pass
338
+
316
339
  def _job_update_state(self, response: Optional[requests.Response] = None) -> None:
317
340
  if response:
318
341
  self._job_state = response.json().get("data", {}).get("node", {}).get("status")
@@ -363,7 +386,26 @@ class ShopifyBulkManager:
363
386
  def _on_completed_job(self, response: Optional[requests.Response] = None) -> None:
364
387
  self._job_result_filename = self._job_get_result(response)
365
388
 
366
- def _on_failed_job(self, response: requests.Response) -> AirbyteTracedException | None:
389
+ def _on_failed_job(self, response: requests.Response) -> Optional[AirbyteTracedException]:
390
+ # Special handling for FAILED jobs with INTERNAL_SERVER_ERROR that support checkpointing
391
+ parsed_response = response.json().get("data", {}).get("node", {}) if response else {}
392
+ error_code = parsed_response.get("errorCode")
393
+
394
+ if error_code == "INTERNAL_SERVER_ERROR" and self._supports_checkpointing:
395
+ LOGGER.info(
396
+ f"Stream: `{self.http_client.name}`, BULK Job: `{self._job_id}` failed with INTERNAL_SERVER_ERROR. Waiting for partial data availability..."
397
+ )
398
+ # For INTERNAL_SERVER_ERROR specifically, wait and retry to check if partial data becomes available
399
+ partial_response = self._wait_for_partial_data_on_failure()
400
+ if partial_response:
401
+ # Use the updated response that may contain partialDataUrl
402
+ response = partial_response
403
+ # Update the job state with the new response to ensure _job_last_rec_count is set correctly
404
+ self._job_update_state(response)
405
+ # For INTERNAL_SERVER_ERROR with partial data, extract cursor and treat as checkpointable
406
+ self._job_get_checkpointed_result(response)
407
+ return None # Don't raise exception, we recovered the data
408
+
367
409
  if not self._supports_checkpointing:
368
410
  raise ShopifyBulkExceptions.BulkJobFailed(
369
411
  f"The BULK Job: `{self._job_id}` exited with {self._job_state}, details: {response.text}",
@@ -373,6 +415,102 @@ class ShopifyBulkManager:
373
415
  # we leverage the checkpointing in this case.
374
416
  self._job_get_checkpointed_result(response)
375
417
 
418
+ def _wait_for_partial_data_on_failure(self) -> Optional[requests.Response]:
419
+ """
420
+ Wait for partial data to become available when a BULK job fails with INTERNAL_SERVER_ERROR.
421
+
422
+ This method is specifically designed for INTERNAL_SERVER_ERROR cases where
423
+ Shopify's BULK API may make partial data available (via partialDataUrl)
424
+ after a short wait, even though the job initially failed.
425
+
426
+ Returns:
427
+ Optional[requests.Response]: Updated response with potential partialDataUrl, or None if no data
428
+ """
429
+ max_wait_attempts = 10 # Maximum number of wait attempts
430
+ wait_interval = 10 # Wait 10 seconds between checks
431
+
432
+ for attempt in range(max_wait_attempts):
433
+ sleep(wait_interval)
434
+
435
+ # Check job status again to see if partial data is now available
436
+ try:
437
+ _, response = self.http_client.send_request(
438
+ http_method="POST",
439
+ url=self.base_url,
440
+ json={"query": ShopifyBulkTemplates.status(self._job_id)},
441
+ request_kwargs={},
442
+ )
443
+
444
+ parsed_response = response.json().get("data", {}).get("node", {}) if response else {}
445
+ partial_data_url = parsed_response.get("partialDataUrl")
446
+ object_count = parsed_response.get("objectCount", "0")
447
+
448
+ # Only stop waiting if we actually have a partialDataUrl - objectCount alone is not sufficient
449
+ if partial_data_url and int(object_count) > 0:
450
+ LOGGER.info(f"Stream: `{self.http_client.name}`, partial data available after wait. Object count: {object_count}")
451
+ return response
452
+ elif int(object_count) > 0:
453
+ # objectCount available but no partialDataUrl yet - continue waiting
454
+ continue
455
+
456
+ except Exception as e:
457
+ # Error during partial data check - continue waiting
458
+ continue
459
+
460
+ LOGGER.warning(f"Stream: `{self.http_client.name}`, no partial data became available after {max_wait_attempts} attempts")
461
+ return None
462
+
463
+ def _extract_last_cursor_from_partial_data(self, response: Optional[requests.Response]) -> Optional[str]:
464
+ """
465
+ Extract the last processed cursor value from partial data for INTERNAL_SERVER_ERROR recovery.
466
+
467
+ This method retrieves partial data from a failed INTERNAL_SERVER_ERROR job and extracts
468
+ the updatedAt value of the last record, which can be used to resume processing from that point.
469
+ Only used in INTERNAL_SERVER_ERROR scenarios with checkpointing support.
470
+
471
+ Args:
472
+ response: The response containing partial data information
473
+
474
+ Returns:
475
+ Optional[str]: The cursor value of the last processed record, or None if unavailable
476
+ """
477
+ if not response:
478
+ return None
479
+
480
+ try:
481
+ parsed_response = response.json().get("data", {}).get("node", {})
482
+ partial_data_url = parsed_response.get("partialDataUrl")
483
+
484
+ if not partial_data_url:
485
+ return None
486
+
487
+ # Download the partial data
488
+ _, partial_response = self.http_client.send_request(http_method="GET", url=partial_data_url, request_kwargs={"stream": True})
489
+ partial_response.raise_for_status()
490
+
491
+ last_record = None
492
+ # Read through the JSONL data to find the last record
493
+ for line in partial_response.iter_lines(decode_unicode=True):
494
+ if line and line.strip() and line.strip() != END_OF_FILE:
495
+ try:
496
+ record = loads(line)
497
+ # Look for the main record types (Order, Product, etc.)
498
+ if record.get("__typename") in ["Order", "Product", "Customer", "FulfillmentOrder"]:
499
+ last_record = record
500
+ except Exception:
501
+ continue
502
+
503
+ # Extract the updatedAt cursor from the last record
504
+ if last_record and "updatedAt" in last_record:
505
+ cursor_value = last_record["updatedAt"]
506
+ return cursor_value
507
+
508
+ except Exception as e:
509
+ # Failed to extract cursor from partial data
510
+ pass
511
+
512
+ return None
513
+
376
514
  def _on_timeout_job(self, **kwargs) -> AirbyteTracedException:
377
515
  raise ShopifyBulkExceptions.BulkJobTimout(
378
516
  f"The BULK Job: `{self._job_id}` exited with {self._job_state}, please reduce the `GraphQL BULK Date Range in Days` in SOURCES > Your Shopify Source > SETTINGS.",
@@ -535,9 +673,15 @@ class ShopifyBulkManager:
535
673
  """
536
674
 
537
675
  if checkpointed_cursor:
676
+ # Check for collision and provide more context in the error
538
677
  if self._checkpoint_cursor_has_collision(checkpointed_cursor):
678
+ # For INTERNAL_SERVER_ERROR recovery, if the cursor is the same, we might need to skip ahead slightly
679
+ # This can happen if the failure occurred right at the boundary of what was already processed
680
+ if hasattr(self, "_job_extracted_checkpoint_cursor") and self._job_extracted_checkpoint_cursor == checkpointed_cursor:
681
+ pass # Collision from INTERNAL_SERVER_ERROR recovery at boundary
682
+
539
683
  raise ShopifyBulkExceptions.BulkJobCheckpointCollisionError(
540
- f"The stream: `{self.http_client.name}` checkpoint collision is detected. Try to increase the `BULK Job checkpoint (rows collected)` to the bigger value. The stream will be synced again during the next sync attempt."
684
+ f"The stream: `{self.http_client.name}` checkpoint collision is detected. Current cursor: {self._job_last_checkpoint_cursor_value}, New cursor: {checkpointed_cursor}. Try to increase the `BULK Job checkpoint (rows collected)` to the bigger value. The stream will be synced again during the next sync attempt."
541
685
  )
542
686
  # set the checkpointed cursor value
543
687
  self._set_last_checkpoint_cursor_value(checkpointed_cursor)
@@ -549,7 +693,14 @@ class ShopifyBulkManager:
549
693
  if self._job_adjust_slice_from_checkpoint:
550
694
  # set the checkpointing to default, before the next slice is emitted, to avoid inf.loop
551
695
  self._reset_checkpointing()
552
- return self._adjust_slice_end(slice_end, checkpointed_cursor)
696
+ # Clear the extracted cursor after use to avoid reusing it
697
+ if self._job_extracted_checkpoint_cursor:
698
+ extracted_cursor = self._job_extracted_checkpoint_cursor
699
+ self._job_extracted_checkpoint_cursor = None
700
+ cursor_to_use = extracted_cursor
701
+ else:
702
+ cursor_to_use = checkpointed_cursor or self._job_last_checkpoint_cursor_value
703
+ return self._adjust_slice_end(slice_end, cursor_to_use)
553
704
 
554
705
  if self._is_long_running_job:
555
706
  self._job_size_reduce_next()
@@ -45,7 +45,6 @@ class ShopifyStream(HttpStream, ABC):
45
45
  super().__init__(authenticator=config["authenticator"])
46
46
  self._transformer = DataTypeEnforcer(self.get_json_schema())
47
47
  self.config = config
48
- self._current_limit = self.limit # Dynamic limit initialized to default
49
48
 
50
49
  @property
51
50
  @abstractmethod
@@ -74,37 +73,24 @@ class ShopifyStream(HttpStream, ABC):
74
73
  return None
75
74
 
76
75
  def request_params(self, next_page_token: Optional[Mapping[str, Any]] = None, **kwargs) -> MutableMapping[str, Any]:
77
- params = {"limit": self._current_limit}
76
+ params = {"limit": self.limit}
78
77
  if next_page_token:
79
- temp_params = dict(next_page_token)
80
- temp_params.pop("limit", None)
81
- params.update(temp_params)
78
+ params.update(**next_page_token)
82
79
  else:
83
80
  params["order"] = f"{self.order_field} asc"
84
- stream_state = kwargs.get("stream_state")
85
- if stream_state is not None:
86
- params[self.filter_field] = stream_state.get(self.filter_field, self.default_filter_field_value)
87
- else:
88
- params[self.filter_field] = self.default_filter_field_value
81
+ params[self.filter_field] = self.default_filter_field_value
89
82
  return params
90
83
 
84
+ @limiter.balance_rate_limit()
91
85
  def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]:
92
- if response.status_code == requests.codes.OK:
93
- self._current_limit = self.limit
86
+ if response.status_code is requests.codes.OK:
94
87
  try:
95
88
  json_response = response.json()
96
- records = json_response.get(self.data_field, []) if self.data_field else json_response
89
+ records = json_response.get(self.data_field, []) if self.data_field is not None else json_response
97
90
  yield from self.produce_records(records)
98
- except requests.exceptions.JSONDecodeError as e:
99
- error_msg = (
100
- f"Failed to decode JSON from response (status code: {response.status_code}, "
101
- f"content length: {len(response.content)}). JSONDecodeError at position {e.pos}: {e.msg}"
102
- )
103
- self.logger.warning(error_msg)
91
+ except RequestException as e:
92
+ self.logger.warning(f"Unexpected error in `parse_response`: {e}, the actual response data: {response.text}")
104
93
  yield {}
105
- else:
106
- self.logger.warning(f"Non-OK response: {response.status_code}")
107
- yield from []
108
94
 
109
95
  def produce_records(
110
96
  self, records: Optional[Union[Iterable[Mapping[str, Any]], Mapping[str, Any]]] = None
@@ -3,6 +3,7 @@
3
3
  #
4
4
 
5
5
 
6
+ import logging
6
7
  from typing import Any, Iterable, Mapping, MutableMapping, Optional
7
8
 
8
9
  import requests
@@ -31,11 +32,12 @@ from source_shopify.shopify_graphql.bulk.query import (
31
32
  ProfileLocationGroups,
32
33
  Transaction,
33
34
  )
34
- from source_shopify.utils import LimitReducingErrorHandler
35
+ from source_shopify.utils import LimitReducingErrorHandler, ShopifyNonRetryableErrors
35
36
 
36
37
  from airbyte_cdk import HttpSubStream
37
38
  from airbyte_cdk.sources.streams.core import package_name_from_class
38
39
  from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
40
+ from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping import DEFAULT_ERROR_MAPPING
39
41
  from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader
40
42
 
41
43
  from .base_streams import (
@@ -86,18 +88,24 @@ class MetafieldCustomers(IncrementalShopifyGraphQlBulkStream):
86
88
  class Orders(IncrementalShopifyStreamWithDeletedEvents):
87
89
  data_field = "orders"
88
90
  deleted_events_api_name = "Order"
91
+ initial_limit = 250
89
92
 
90
- def request_params(
91
- self, stream_state: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None, **kwargs
92
- ) -> MutableMapping[str, Any]:
93
+ def __init__(self, config: Mapping[str, Any]):
94
+ self._error_handler = LimitReducingErrorHandler(
95
+ max_retries=5,
96
+ error_mapping=DEFAULT_ERROR_MAPPING | ShopifyNonRetryableErrors("orders"),
97
+ )
98
+ super().__init__(config)
99
+
100
+ def request_params(self, stream_state=None, next_page_token=None, **kwargs):
93
101
  params = super().request_params(stream_state=stream_state, next_page_token=next_page_token, **kwargs)
102
+ params["limit"] = self.initial_limit # Always start with the default limit; error handler will mutate on retry
94
103
  if not next_page_token:
95
104
  params["status"] = "any"
96
105
  return params
97
106
 
98
- def get_error_handler(self) -> Optional[ErrorHandler]:
99
- default_handler = super().get_error_handler()
100
- return LimitReducingErrorHandler(stream=self, default_handler=default_handler)
107
+ def get_error_handler(self):
108
+ return self._error_handler
101
109
 
102
110
 
103
111
  class Disputes(IncrementalShopifyStream):
source_shopify/utils.py CHANGED
@@ -13,7 +13,7 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
13
13
  import requests
14
14
 
15
15
  from airbyte_cdk.models import FailureType
16
- from airbyte_cdk.sources.streams.http.error_handlers import ErrorHandler
16
+ from airbyte_cdk.sources.streams.http.error_handlers import HttpStatusErrorHandler
17
17
  from airbyte_cdk.sources.streams.http.error_handlers.response_models import ErrorResolution, ResponseAction
18
18
  from airbyte_cdk.utils import AirbyteTracedException
19
19
 
@@ -327,49 +327,36 @@ class EagerlyCachedStreamState:
327
327
  return decorator
328
328
 
329
329
 
330
- class LimitReducingErrorHandler(ErrorHandler):
331
- """Custom error handler that reduces the request limit (of items/page) on 500 errors and retries.
332
- limit is halved on each retry until it reaches 1. We still exponentially backoff on retries in addition to this.
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.
333
334
  """
334
335
 
335
- def __init__(self, stream: "ShopifyStream", default_handler: ErrorHandler):
336
- self.stream = stream
337
- self.default_handler = default_handler
338
-
339
- @property
340
- def max_retries(self) -> Optional[int]:
341
- return self.default_handler.max_retries
342
-
343
- @property
344
- def max_time(self) -> Optional[int]:
345
- return self.default_handler.max_time
336
+ def __init__(self, max_retries: int, error_mapping: dict):
337
+ super().__init__(logger=None, max_retries=max_retries, error_mapping=error_mapping)
346
338
 
347
- def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]]) -> ErrorResolution:
339
+ def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> ErrorResolution:
348
340
  if isinstance(response_or_exception, requests.Response):
349
341
  response = response_or_exception
350
342
  if response.status_code == 500:
351
- current_limit = self.stream._current_limit
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])
352
347
  if current_limit > 1:
353
348
  new_limit = max(1, current_limit // 2)
354
- self.stream._current_limit = new_limit
355
- new_url = self.update_limit_in_url(response.request.url, new_limit)
356
- response.request.url = new_url
349
+ query["limit"] = [str(new_limit)]
350
+ new_query = urlencode(query, doseq=True)
351
+ response.request.url = urlunparse(parsed._replace(query=new_query))
357
352
  return ErrorResolution(
358
353
  response_action=ResponseAction.RETRY,
359
354
  failure_type=FailureType.transient_error,
360
- error_message=f"Server error 500: Reduced limit to {new_limit}",
355
+ error_message=f"Server error 500: Reduced limit to {new_limit} and updating request URL",
361
356
  )
362
357
  return ErrorResolution(
363
358
  response_action=ResponseAction.FAIL,
364
359
  failure_type=FailureType.transient_error,
365
360
  error_message="Persistent 500 error after reducing limit to 1",
366
361
  )
367
- return self.default_handler.interpret_response(response_or_exception)
368
-
369
- def update_limit_in_url(self, url: str, new_limit: int) -> str:
370
- """Update the 'limit' parameter in the URL query string."""
371
- parsed = urlparse(url)
372
- query = parse_qs(parsed.query)
373
- query["limit"] = [str(new_limit)]
374
- new_query = urlencode(query, doseq=True)
375
- return urlunparse(parsed._replace(query=new_query))
362
+ return super().interpret_response(response_or_exception)