airbyte_cdk.sources.declarative.retrievers.page_size_reducer

  1# Copyright (c) 2026 Airbyte, Inc., all rights reserved.
  2
  3import logging
  4import time
  5from dataclasses import dataclass
  6from enum import Enum
  7from typing import Callable, Optional
  8
  9from airbyte_cdk.models import FailureType
 10from airbyte_cdk.utils.traced_exception import AirbyteTracedException
 11
 12LOGGER = logging.getLogger("airbyte")
 13
 14
 15class PageSizeResetPolicy(Enum):
 16    NEVER = "NEVER"
 17    AFTER_SUCCESSFUL_PAGE = "AFTER_SUCCESSFUL_PAGE"
 18
 19
 20@dataclass(frozen=True)
 21class PageSizeReduction:
 22    """
 23    How much to shrink the page size when an error handler resolves to `ResponseAction.REDUCE_PAGE_SIZE`.
 24
 25    This is immutable configuration: it is created once per stream and shared, while the page size in effect
 26    lives in a `PageSizeReducer` created per partition read.
 27    """
 28
 29    reduction_factor: float = 2.0
 30    minimum_page_size: int = 1
 31    max_attempts: int = 5
 32    reset_policy: PageSizeResetPolicy = PageSizeResetPolicy.NEVER
 33    # Base wait before a page is re-issued, multiplied by the number of attempts made in a row. A
 34    # `REDUCE_PAGE_SIZE` response never reaches the HTTP retry budget - the exception raised for it is
 35    # deliberately not a backoff exception - so this is the only thing spacing these requests out, and an
 36    # API whose 502 means "we are briefly unwell" rather than "your page is too big" needs it to be more
 37    # than a token pause.
 38    backoff_seconds: float = 0.5
 39    # How many times the same page is re-issued unchanged once the page size cannot be shrunk any further,
 40    # before the read gives up. Zero keeps the strict behaviour: the first response that cannot be answered
 41    # with a smaller page fails the stream. It is what gives an API whose error is transient a budget at the
 42    # floor, where reducing is no longer an option but waiting still is.
 43    retries_at_minimum_page_size: int = 0
 44    # Appended to the two messages raised once the page size cannot be reduced any further. Those are
 45    # `transient_error`s the CDK has no remediation for - it only knows that the API rejected every page size
 46    # asked for - while the connector knows what narrows a query down on this particular API.
 47    failure_message: Optional[str] = None
 48
 49    def __post_init__(self) -> None:
 50        if self.reduction_factor <= 1:
 51            raise ValueError(
 52                f"The page size reduction factor needs to be greater than 1. Got {self.reduction_factor}"
 53            )
 54        if self.minimum_page_size < 1:
 55            raise ValueError(
 56                f"The minimum page size needs to be strictly positive. Got {self.minimum_page_size}"
 57            )
 58        if self.max_attempts < 1:
 59            raise ValueError(
 60                f"The maximum number of page size reductions needs to be strictly positive. Got {self.max_attempts}"
 61            )
 62        if self.backoff_seconds < 0:
 63            raise ValueError(
 64                f"The wait between page size reductions cannot be negative. Got {self.backoff_seconds}"
 65            )
 66        if self.retries_at_minimum_page_size < 0:
 67            raise ValueError(
 68                f"The number of retries at the minimum page size cannot be negative. Got {self.retries_at_minimum_page_size}"
 69            )
 70
 71
 72class PageSizeReducer:
 73    """
 74    Tracks the page size to use while reading one partition when the API asks for smaller pages.
 75
 76    One instance is created per `SimpleRetriever._read_pages` call. This is deliberate: a retriever and its
 77    paginator are shared by every partition of a stream and partitions are read concurrently, so the reduced
 78    page size must not be stored on the paginator or on the retriever.
 79    """
 80
 81    def __init__(
 82        self,
 83        config: PageSizeReduction,
 84        configured_page_size: Optional[int],
 85        stream_name: str = "",
 86        sleep: Optional[Callable[[float], None]] = None,
 87    ) -> None:
 88        self._config = config
 89        self._configured_page_size = configured_page_size
 90        self._stream_name = stream_name
 91        # Resolved on each call rather than bound here: a default of `time.sleep` would capture
 92        # the function object, and a test that patches `time.sleep` to keep a run of reductions
 93        # from taking its two minutes for real would have no effect on an already-bound default.
 94        self._sleep_override = sleep
 95        self._current_page_size: Optional[int] = None
 96        self._attempts = 0
 97        self._total_reductions = 0
 98        self._retries_at_minimum_page_size = 0
 99
100    def _sleep(self, seconds: float) -> None:
101        if self._sleep_override is not None:
102            self._sleep_override(seconds)
103            return
104        time.sleep(seconds)
105
106    @property
107    def page_size_override(self) -> Optional[int]:
108        """
109        :return: the reduced page size to request, or None while the configured page size is in effect
110        """
111        return self._current_page_size
112
113    def reduce(self, error_message: Optional[str] = None) -> None:
114        """
115        Shrink the page size used for the next request. Raises once the page size cannot be shrunk any further
116        so that an endpoint that keeps failing does not loop forever. Those errors include `error_message`, the
117        error handler's message for the rejected page.
118        """
119        if self._configured_page_size is None:
120            raise AirbyteTracedException(
121                internal_message=f"Stream {self._stream_name} received a REDUCE_PAGE_SIZE response action but its paginator does not inject a page size",
122                message=f"Stream {self._stream_name} is set up to reduce its page size on error but does not define "
123                f"one. Set `page_size` on the pagination strategy and `page_size_option` on the paginator.",
124                failure_type=FailureType.config_error,
125            )
126
127        current_page_size = (
128            self._current_page_size
129            if self._current_page_size is not None
130            else self._configured_page_size
131        )
132        if not isinstance(current_page_size, int) or isinstance(current_page_size, bool):
133            # A custom pagination strategy can return anything from `get_page_size`. Reducing
134            # is arithmetic, so a non-integer would otherwise fail with a bare TypeError in
135            # the middle of a sync.
136            raise AirbyteTracedException(
137                internal_message=f"Stream {self._stream_name} has a page size of type {type(current_page_size).__name__}: {current_page_size!r}",
138                message=f"The page size of stream {self._stream_name} is not a whole number, so the connector cannot "
139                f"reduce it. Make sure the pagination strategy's `page_size` is a number.",
140                failure_type=FailureType.config_error,
141            )
142
143        rejection = f"; the API rejected the page: {error_message}" if error_message else ""
144        reduced_page_size = max(
145            self._config.minimum_page_size,
146            int(current_page_size // self._config.reduction_factor),
147        )
148        if reduced_page_size >= current_page_size:
149            # Nothing left to give up on the page size. Whether that is the end of the read is
150            # `retries_at_minimum_page_size`'s call, not this branch's: the response may still be transient.
151            self._retry_at_minimum_page_size(current_page_size, rejection)
152            return
153
154        self._attempts += 1
155        self._total_reductions += 1
156        if self._attempts > self._config.max_attempts:
157            # The budget counts the reductions that did *not* get a page through, which is what separates a
158            # partition that is stuck from one that is merely expensive. A partition where pages keep
159            # succeeding restarts this counter on each of them, under either reset policy, and reads to the
160            # end however many pages it has; a partition where nothing gets through burns the budget here.
161            raise AirbyteTracedException(
162                internal_message=f"Stream {self._stream_name} reduced its page size {self._attempts - 1} times in a row without a single page succeeding, which is the configured maximum of {self._config.max_attempts} ({self._total_reductions - 1} reductions so far while reading this partition){rejection}",
163                # `transient_error`, so the only remediation is the connector's own, if it defined one.
164                message=self._with_failure_message(
165                    f"The source keeps rejecting pages of stream {self._stream_name} at every page size the "
166                    f"connector requested, down to {current_page_size} records per page."
167                ),
168                failure_type=FailureType.transient_error,
169            )
170
171        backoff = self._config.backoff_seconds * self._attempts
172        LOGGER.info(
173            f"Reducing the page size of stream {self._stream_name} from {current_page_size} to {reduced_page_size} "
174            f"and retrying the same page in {backoff}s."
175        )
176        self._current_page_size = reduced_page_size
177        self._sleep(backoff)
178
179    def _retry_at_minimum_page_size(self, current_page_size: int, rejection: str) -> None:
180        """
181        Handle a `REDUCE_PAGE_SIZE` response that arrives when the page size is already as small as the
182        connector is allowed to request.
183
184        Reducing is out of options here, but re-issuing the page is not: an API that answers 502 to a page it
185        considers too heavy answers the same 502 when it is briefly unwell, and the error handler cannot tell
186        the two apart. `REDUCE_PAGE_SIZE` bypasses the HTTP retry budget, so without this budget the second
187        kind of 502 ends the stream on the first response once the floor is reached - fewer attempts than the
188        same connector got before it adopted the reduction.
189
190        The budget applies however the page size arrived at the floor, whether by reduction or because the
191        configured page size was already there. Only once it is spent does the failure depend on that: a page
192        size the connector's own floor blocks from ever being reduced is a configuration error, while a floor
193        the reduction walked down to means the API kept rejecting every size, which is transient.
194        """
195        # The retries come first, including on a stream whose page size started at the floor. How the page size
196        # arrived there says nothing about the response, so letting the misconfiguration branch below decide it
197        # would give the same manifest a retry budget or none depending on the user's `page_size`.
198        if self._retries_at_minimum_page_size < self._config.retries_at_minimum_page_size:
199            self._retries_at_minimum_page_size += 1
200            backoff = self._config.backoff_seconds * self._retries_at_minimum_page_size
201            LOGGER.info(
202                f"Stream {self._stream_name} cannot request a page smaller than {current_page_size} records, "
203                f"so the same page is retried unchanged in {backoff}s "
204                f"({self._retries_at_minimum_page_size} of {self._config.retries_at_minimum_page_size})."
205            )
206            self._sleep(backoff)
207            return
208
209        if self._current_page_size is None and self._config.minimum_page_size > 1:
210            # No reduction was ever applied and the connector's own floor is what blocks it, so the page size
211            # can never be reduced on this stream however the API behaves. That is a configuration error, and
212            # it is actionable: both numbers in the message are the connector's to change. It is reported once
213            # the retries above are spent, so a manifest that asked for them still gets them.
214            raise AirbyteTracedException(
215                internal_message=f"Stream {self._stream_name} has a configured page size of {current_page_size} which is not greater than the configured minimum page size of {self._config.minimum_page_size}, so it can never be reduced"
216                + (
217                    f" ({self._retries_at_minimum_page_size} retries at that size were spent first)"
218                    if self._retries_at_minimum_page_size
219                    else ""
220                )
221                + rejection,
222                message=f"The page size of stream {self._stream_name} ({current_page_size}) is already at or below "
223                f"the configured minimum of {self._config.minimum_page_size}, so the connector cannot reduce it. "
224                f"Raise the page size of the stream, or lower `minimum_page_size`.",
225                failure_type=FailureType.config_error,
226            )
227
228        raise AirbyteTracedException(
229            internal_message=f"Stream {self._stream_name} still fails with a page size of {current_page_size}, which is the smallest page size allowed by the configured minimum of {self._config.minimum_page_size}"
230            + (
231                f", after {self._retries_at_minimum_page_size} retries at that size"
232                if self._retries_at_minimum_page_size
233                else ""
234            )
235            + rejection,
236            # `transient_error`, so the only remediation is the connector's own, if it defined one.
237            message=self._with_failure_message(
238                f"The source keeps rejecting pages of stream {self._stream_name} at the smallest page size "
239                f"the connector is allowed to request ({current_page_size} records per page)."
240            ),
241            failure_type=FailureType.transient_error,
242        )
243
244    def _with_failure_message(self, message: str) -> str:
245        """
246        :return: the message followed by the connector's `failure_message`, when it defined one
247        """
248        failure_message = (self._config.failure_message or "").strip()
249        if not failure_message:
250            return message
251        return f"{message} {failure_message}"
252
253    def on_successful_page(self) -> None:
254        """
255        Called after each page that did not require a reduction.
256
257        The `max_attempts` budget restarts under both policies. It counts the reductions made *in a row*
258        without a single page succeeding, which is what separates a partition that is stuck from one that is
259        merely expensive: on a stream whose per-page cost varies - the GraphQL case this feature exists for -
260        a handful of heavy pages spread over a long partition is a healthy read, and a budget spanning the
261        whole partition would fail it at the `max_attempts + 1`-th heavy page while every reduction so far had
262        been followed by a successful page. The terminal message says the source rejected every page size the
263        connector asked for, so the budget has to mean exactly that.
264
265        The budget still terminates the read, because only a page that succeeded restarts it and only
266        `_read_pages` calls this, once per page it consumed. So between any two restarts the partition made one
267        page of progress, and the reductions that make no progress are bounded by `max_attempts`. Under `NEVER`
268        the reduced page size is never restored either, so it strictly decreases and `minimum_page_size` bounds
269        the reductions of the whole partition on its own.
270
271        Only `AFTER_SUCCESSFUL_PAGE` restores the page size. That policy is for an API that rejects the
272        configured page size on every page, so every page legitimately costs one reduction; `NEVER` keeps the
273        reduced size for the rest of the partition, which is the right behaviour when reductions are one-off.
274        """
275        self._attempts = 0
276        self._retries_at_minimum_page_size = 0
277
278        if self._config.reset_policy != PageSizeResetPolicy.AFTER_SUCCESSFUL_PAGE:
279            return
280
281        if self._current_page_size is not None:
282            LOGGER.info(
283                f"Restoring the page size of stream {self._stream_name} from {self._current_page_size} to {self._configured_page_size}."
284            )
285            self._current_page_size = None
LOGGER = <Logger airbyte (INFO)>
class PageSizeResetPolicy(enum.Enum):
16class PageSizeResetPolicy(Enum):
17    NEVER = "NEVER"
18    AFTER_SUCCESSFUL_PAGE = "AFTER_SUCCESSFUL_PAGE"
NEVER = <PageSizeResetPolicy.NEVER: 'NEVER'>
AFTER_SUCCESSFUL_PAGE = <PageSizeResetPolicy.AFTER_SUCCESSFUL_PAGE: 'AFTER_SUCCESSFUL_PAGE'>
@dataclass(frozen=True)
class PageSizeReduction:
21@dataclass(frozen=True)
22class PageSizeReduction:
23    """
24    How much to shrink the page size when an error handler resolves to `ResponseAction.REDUCE_PAGE_SIZE`.
25
26    This is immutable configuration: it is created once per stream and shared, while the page size in effect
27    lives in a `PageSizeReducer` created per partition read.
28    """
29
30    reduction_factor: float = 2.0
31    minimum_page_size: int = 1
32    max_attempts: int = 5
33    reset_policy: PageSizeResetPolicy = PageSizeResetPolicy.NEVER
34    # Base wait before a page is re-issued, multiplied by the number of attempts made in a row. A
35    # `REDUCE_PAGE_SIZE` response never reaches the HTTP retry budget - the exception raised for it is
36    # deliberately not a backoff exception - so this is the only thing spacing these requests out, and an
37    # API whose 502 means "we are briefly unwell" rather than "your page is too big" needs it to be more
38    # than a token pause.
39    backoff_seconds: float = 0.5
40    # How many times the same page is re-issued unchanged once the page size cannot be shrunk any further,
41    # before the read gives up. Zero keeps the strict behaviour: the first response that cannot be answered
42    # with a smaller page fails the stream. It is what gives an API whose error is transient a budget at the
43    # floor, where reducing is no longer an option but waiting still is.
44    retries_at_minimum_page_size: int = 0
45    # Appended to the two messages raised once the page size cannot be reduced any further. Those are
46    # `transient_error`s the CDK has no remediation for - it only knows that the API rejected every page size
47    # asked for - while the connector knows what narrows a query down on this particular API.
48    failure_message: Optional[str] = None
49
50    def __post_init__(self) -> None:
51        if self.reduction_factor <= 1:
52            raise ValueError(
53                f"The page size reduction factor needs to be greater than 1. Got {self.reduction_factor}"
54            )
55        if self.minimum_page_size < 1:
56            raise ValueError(
57                f"The minimum page size needs to be strictly positive. Got {self.minimum_page_size}"
58            )
59        if self.max_attempts < 1:
60            raise ValueError(
61                f"The maximum number of page size reductions needs to be strictly positive. Got {self.max_attempts}"
62            )
63        if self.backoff_seconds < 0:
64            raise ValueError(
65                f"The wait between page size reductions cannot be negative. Got {self.backoff_seconds}"
66            )
67        if self.retries_at_minimum_page_size < 0:
68            raise ValueError(
69                f"The number of retries at the minimum page size cannot be negative. Got {self.retries_at_minimum_page_size}"
70            )

How much to shrink the page size when an error handler resolves to ResponseAction.REDUCE_PAGE_SIZE.

This is immutable configuration: it is created once per stream and shared, while the page size in effect lives in a PageSizeReducer created per partition read.

PageSizeReduction( reduction_factor: float = 2.0, minimum_page_size: int = 1, max_attempts: int = 5, reset_policy: PageSizeResetPolicy = <PageSizeResetPolicy.NEVER: 'NEVER'>, backoff_seconds: float = 0.5, retries_at_minimum_page_size: int = 0, failure_message: Optional[str] = None)
reduction_factor: float = 2.0
minimum_page_size: int = 1
max_attempts: int = 5
reset_policy: PageSizeResetPolicy = <PageSizeResetPolicy.NEVER: 'NEVER'>
backoff_seconds: float = 0.5
retries_at_minimum_page_size: int = 0
failure_message: Optional[str] = None
class PageSizeReducer:
 73class PageSizeReducer:
 74    """
 75    Tracks the page size to use while reading one partition when the API asks for smaller pages.
 76
 77    One instance is created per `SimpleRetriever._read_pages` call. This is deliberate: a retriever and its
 78    paginator are shared by every partition of a stream and partitions are read concurrently, so the reduced
 79    page size must not be stored on the paginator or on the retriever.
 80    """
 81
 82    def __init__(
 83        self,
 84        config: PageSizeReduction,
 85        configured_page_size: Optional[int],
 86        stream_name: str = "",
 87        sleep: Optional[Callable[[float], None]] = None,
 88    ) -> None:
 89        self._config = config
 90        self._configured_page_size = configured_page_size
 91        self._stream_name = stream_name
 92        # Resolved on each call rather than bound here: a default of `time.sleep` would capture
 93        # the function object, and a test that patches `time.sleep` to keep a run of reductions
 94        # from taking its two minutes for real would have no effect on an already-bound default.
 95        self._sleep_override = sleep
 96        self._current_page_size: Optional[int] = None
 97        self._attempts = 0
 98        self._total_reductions = 0
 99        self._retries_at_minimum_page_size = 0
100
101    def _sleep(self, seconds: float) -> None:
102        if self._sleep_override is not None:
103            self._sleep_override(seconds)
104            return
105        time.sleep(seconds)
106
107    @property
108    def page_size_override(self) -> Optional[int]:
109        """
110        :return: the reduced page size to request, or None while the configured page size is in effect
111        """
112        return self._current_page_size
113
114    def reduce(self, error_message: Optional[str] = None) -> None:
115        """
116        Shrink the page size used for the next request. Raises once the page size cannot be shrunk any further
117        so that an endpoint that keeps failing does not loop forever. Those errors include `error_message`, the
118        error handler's message for the rejected page.
119        """
120        if self._configured_page_size is None:
121            raise AirbyteTracedException(
122                internal_message=f"Stream {self._stream_name} received a REDUCE_PAGE_SIZE response action but its paginator does not inject a page size",
123                message=f"Stream {self._stream_name} is set up to reduce its page size on error but does not define "
124                f"one. Set `page_size` on the pagination strategy and `page_size_option` on the paginator.",
125                failure_type=FailureType.config_error,
126            )
127
128        current_page_size = (
129            self._current_page_size
130            if self._current_page_size is not None
131            else self._configured_page_size
132        )
133        if not isinstance(current_page_size, int) or isinstance(current_page_size, bool):
134            # A custom pagination strategy can return anything from `get_page_size`. Reducing
135            # is arithmetic, so a non-integer would otherwise fail with a bare TypeError in
136            # the middle of a sync.
137            raise AirbyteTracedException(
138                internal_message=f"Stream {self._stream_name} has a page size of type {type(current_page_size).__name__}: {current_page_size!r}",
139                message=f"The page size of stream {self._stream_name} is not a whole number, so the connector cannot "
140                f"reduce it. Make sure the pagination strategy's `page_size` is a number.",
141                failure_type=FailureType.config_error,
142            )
143
144        rejection = f"; the API rejected the page: {error_message}" if error_message else ""
145        reduced_page_size = max(
146            self._config.minimum_page_size,
147            int(current_page_size // self._config.reduction_factor),
148        )
149        if reduced_page_size >= current_page_size:
150            # Nothing left to give up on the page size. Whether that is the end of the read is
151            # `retries_at_minimum_page_size`'s call, not this branch's: the response may still be transient.
152            self._retry_at_minimum_page_size(current_page_size, rejection)
153            return
154
155        self._attempts += 1
156        self._total_reductions += 1
157        if self._attempts > self._config.max_attempts:
158            # The budget counts the reductions that did *not* get a page through, which is what separates a
159            # partition that is stuck from one that is merely expensive. A partition where pages keep
160            # succeeding restarts this counter on each of them, under either reset policy, and reads to the
161            # end however many pages it has; a partition where nothing gets through burns the budget here.
162            raise AirbyteTracedException(
163                internal_message=f"Stream {self._stream_name} reduced its page size {self._attempts - 1} times in a row without a single page succeeding, which is the configured maximum of {self._config.max_attempts} ({self._total_reductions - 1} reductions so far while reading this partition){rejection}",
164                # `transient_error`, so the only remediation is the connector's own, if it defined one.
165                message=self._with_failure_message(
166                    f"The source keeps rejecting pages of stream {self._stream_name} at every page size the "
167                    f"connector requested, down to {current_page_size} records per page."
168                ),
169                failure_type=FailureType.transient_error,
170            )
171
172        backoff = self._config.backoff_seconds * self._attempts
173        LOGGER.info(
174            f"Reducing the page size of stream {self._stream_name} from {current_page_size} to {reduced_page_size} "
175            f"and retrying the same page in {backoff}s."
176        )
177        self._current_page_size = reduced_page_size
178        self._sleep(backoff)
179
180    def _retry_at_minimum_page_size(self, current_page_size: int, rejection: str) -> None:
181        """
182        Handle a `REDUCE_PAGE_SIZE` response that arrives when the page size is already as small as the
183        connector is allowed to request.
184
185        Reducing is out of options here, but re-issuing the page is not: an API that answers 502 to a page it
186        considers too heavy answers the same 502 when it is briefly unwell, and the error handler cannot tell
187        the two apart. `REDUCE_PAGE_SIZE` bypasses the HTTP retry budget, so without this budget the second
188        kind of 502 ends the stream on the first response once the floor is reached - fewer attempts than the
189        same connector got before it adopted the reduction.
190
191        The budget applies however the page size arrived at the floor, whether by reduction or because the
192        configured page size was already there. Only once it is spent does the failure depend on that: a page
193        size the connector's own floor blocks from ever being reduced is a configuration error, while a floor
194        the reduction walked down to means the API kept rejecting every size, which is transient.
195        """
196        # The retries come first, including on a stream whose page size started at the floor. How the page size
197        # arrived there says nothing about the response, so letting the misconfiguration branch below decide it
198        # would give the same manifest a retry budget or none depending on the user's `page_size`.
199        if self._retries_at_minimum_page_size < self._config.retries_at_minimum_page_size:
200            self._retries_at_minimum_page_size += 1
201            backoff = self._config.backoff_seconds * self._retries_at_minimum_page_size
202            LOGGER.info(
203                f"Stream {self._stream_name} cannot request a page smaller than {current_page_size} records, "
204                f"so the same page is retried unchanged in {backoff}s "
205                f"({self._retries_at_minimum_page_size} of {self._config.retries_at_minimum_page_size})."
206            )
207            self._sleep(backoff)
208            return
209
210        if self._current_page_size is None and self._config.minimum_page_size > 1:
211            # No reduction was ever applied and the connector's own floor is what blocks it, so the page size
212            # can never be reduced on this stream however the API behaves. That is a configuration error, and
213            # it is actionable: both numbers in the message are the connector's to change. It is reported once
214            # the retries above are spent, so a manifest that asked for them still gets them.
215            raise AirbyteTracedException(
216                internal_message=f"Stream {self._stream_name} has a configured page size of {current_page_size} which is not greater than the configured minimum page size of {self._config.minimum_page_size}, so it can never be reduced"
217                + (
218                    f" ({self._retries_at_minimum_page_size} retries at that size were spent first)"
219                    if self._retries_at_minimum_page_size
220                    else ""
221                )
222                + rejection,
223                message=f"The page size of stream {self._stream_name} ({current_page_size}) is already at or below "
224                f"the configured minimum of {self._config.minimum_page_size}, so the connector cannot reduce it. "
225                f"Raise the page size of the stream, or lower `minimum_page_size`.",
226                failure_type=FailureType.config_error,
227            )
228
229        raise AirbyteTracedException(
230            internal_message=f"Stream {self._stream_name} still fails with a page size of {current_page_size}, which is the smallest page size allowed by the configured minimum of {self._config.minimum_page_size}"
231            + (
232                f", after {self._retries_at_minimum_page_size} retries at that size"
233                if self._retries_at_minimum_page_size
234                else ""
235            )
236            + rejection,
237            # `transient_error`, so the only remediation is the connector's own, if it defined one.
238            message=self._with_failure_message(
239                f"The source keeps rejecting pages of stream {self._stream_name} at the smallest page size "
240                f"the connector is allowed to request ({current_page_size} records per page)."
241            ),
242            failure_type=FailureType.transient_error,
243        )
244
245    def _with_failure_message(self, message: str) -> str:
246        """
247        :return: the message followed by the connector's `failure_message`, when it defined one
248        """
249        failure_message = (self._config.failure_message or "").strip()
250        if not failure_message:
251            return message
252        return f"{message} {failure_message}"
253
254    def on_successful_page(self) -> None:
255        """
256        Called after each page that did not require a reduction.
257
258        The `max_attempts` budget restarts under both policies. It counts the reductions made *in a row*
259        without a single page succeeding, which is what separates a partition that is stuck from one that is
260        merely expensive: on a stream whose per-page cost varies - the GraphQL case this feature exists for -
261        a handful of heavy pages spread over a long partition is a healthy read, and a budget spanning the
262        whole partition would fail it at the `max_attempts + 1`-th heavy page while every reduction so far had
263        been followed by a successful page. The terminal message says the source rejected every page size the
264        connector asked for, so the budget has to mean exactly that.
265
266        The budget still terminates the read, because only a page that succeeded restarts it and only
267        `_read_pages` calls this, once per page it consumed. So between any two restarts the partition made one
268        page of progress, and the reductions that make no progress are bounded by `max_attempts`. Under `NEVER`
269        the reduced page size is never restored either, so it strictly decreases and `minimum_page_size` bounds
270        the reductions of the whole partition on its own.
271
272        Only `AFTER_SUCCESSFUL_PAGE` restores the page size. That policy is for an API that rejects the
273        configured page size on every page, so every page legitimately costs one reduction; `NEVER` keeps the
274        reduced size for the rest of the partition, which is the right behaviour when reductions are one-off.
275        """
276        self._attempts = 0
277        self._retries_at_minimum_page_size = 0
278
279        if self._config.reset_policy != PageSizeResetPolicy.AFTER_SUCCESSFUL_PAGE:
280            return
281
282        if self._current_page_size is not None:
283            LOGGER.info(
284                f"Restoring the page size of stream {self._stream_name} from {self._current_page_size} to {self._configured_page_size}."
285            )
286            self._current_page_size = None

Tracks the page size to use while reading one partition when the API asks for smaller pages.

One instance is created per SimpleRetriever._read_pages call. This is deliberate: a retriever and its paginator are shared by every partition of a stream and partitions are read concurrently, so the reduced page size must not be stored on the paginator or on the retriever.

PageSizeReducer( config: PageSizeReduction, configured_page_size: Optional[int], stream_name: str = '', sleep: Optional[Callable[[float], NoneType]] = None)
82    def __init__(
83        self,
84        config: PageSizeReduction,
85        configured_page_size: Optional[int],
86        stream_name: str = "",
87        sleep: Optional[Callable[[float], None]] = None,
88    ) -> None:
89        self._config = config
90        self._configured_page_size = configured_page_size
91        self._stream_name = stream_name
92        # Resolved on each call rather than bound here: a default of `time.sleep` would capture
93        # the function object, and a test that patches `time.sleep` to keep a run of reductions
94        # from taking its two minutes for real would have no effect on an already-bound default.
95        self._sleep_override = sleep
96        self._current_page_size: Optional[int] = None
97        self._attempts = 0
98        self._total_reductions = 0
99        self._retries_at_minimum_page_size = 0
page_size_override: Optional[int]
107    @property
108    def page_size_override(self) -> Optional[int]:
109        """
110        :return: the reduced page size to request, or None while the configured page size is in effect
111        """
112        return self._current_page_size
Returns

the reduced page size to request, or None while the configured page size is in effect

def reduce(self, error_message: Optional[str] = None) -> None:
114    def reduce(self, error_message: Optional[str] = None) -> None:
115        """
116        Shrink the page size used for the next request. Raises once the page size cannot be shrunk any further
117        so that an endpoint that keeps failing does not loop forever. Those errors include `error_message`, the
118        error handler's message for the rejected page.
119        """
120        if self._configured_page_size is None:
121            raise AirbyteTracedException(
122                internal_message=f"Stream {self._stream_name} received a REDUCE_PAGE_SIZE response action but its paginator does not inject a page size",
123                message=f"Stream {self._stream_name} is set up to reduce its page size on error but does not define "
124                f"one. Set `page_size` on the pagination strategy and `page_size_option` on the paginator.",
125                failure_type=FailureType.config_error,
126            )
127
128        current_page_size = (
129            self._current_page_size
130            if self._current_page_size is not None
131            else self._configured_page_size
132        )
133        if not isinstance(current_page_size, int) or isinstance(current_page_size, bool):
134            # A custom pagination strategy can return anything from `get_page_size`. Reducing
135            # is arithmetic, so a non-integer would otherwise fail with a bare TypeError in
136            # the middle of a sync.
137            raise AirbyteTracedException(
138                internal_message=f"Stream {self._stream_name} has a page size of type {type(current_page_size).__name__}: {current_page_size!r}",
139                message=f"The page size of stream {self._stream_name} is not a whole number, so the connector cannot "
140                f"reduce it. Make sure the pagination strategy's `page_size` is a number.",
141                failure_type=FailureType.config_error,
142            )
143
144        rejection = f"; the API rejected the page: {error_message}" if error_message else ""
145        reduced_page_size = max(
146            self._config.minimum_page_size,
147            int(current_page_size // self._config.reduction_factor),
148        )
149        if reduced_page_size >= current_page_size:
150            # Nothing left to give up on the page size. Whether that is the end of the read is
151            # `retries_at_minimum_page_size`'s call, not this branch's: the response may still be transient.
152            self._retry_at_minimum_page_size(current_page_size, rejection)
153            return
154
155        self._attempts += 1
156        self._total_reductions += 1
157        if self._attempts > self._config.max_attempts:
158            # The budget counts the reductions that did *not* get a page through, which is what separates a
159            # partition that is stuck from one that is merely expensive. A partition where pages keep
160            # succeeding restarts this counter on each of them, under either reset policy, and reads to the
161            # end however many pages it has; a partition where nothing gets through burns the budget here.
162            raise AirbyteTracedException(
163                internal_message=f"Stream {self._stream_name} reduced its page size {self._attempts - 1} times in a row without a single page succeeding, which is the configured maximum of {self._config.max_attempts} ({self._total_reductions - 1} reductions so far while reading this partition){rejection}",
164                # `transient_error`, so the only remediation is the connector's own, if it defined one.
165                message=self._with_failure_message(
166                    f"The source keeps rejecting pages of stream {self._stream_name} at every page size the "
167                    f"connector requested, down to {current_page_size} records per page."
168                ),
169                failure_type=FailureType.transient_error,
170            )
171
172        backoff = self._config.backoff_seconds * self._attempts
173        LOGGER.info(
174            f"Reducing the page size of stream {self._stream_name} from {current_page_size} to {reduced_page_size} "
175            f"and retrying the same page in {backoff}s."
176        )
177        self._current_page_size = reduced_page_size
178        self._sleep(backoff)

Shrink the page size used for the next request. Raises once the page size cannot be shrunk any further so that an endpoint that keeps failing does not loop forever. Those errors include error_message, the error handler's message for the rejected page.

def on_successful_page(self) -> None:
254    def on_successful_page(self) -> None:
255        """
256        Called after each page that did not require a reduction.
257
258        The `max_attempts` budget restarts under both policies. It counts the reductions made *in a row*
259        without a single page succeeding, which is what separates a partition that is stuck from one that is
260        merely expensive: on a stream whose per-page cost varies - the GraphQL case this feature exists for -
261        a handful of heavy pages spread over a long partition is a healthy read, and a budget spanning the
262        whole partition would fail it at the `max_attempts + 1`-th heavy page while every reduction so far had
263        been followed by a successful page. The terminal message says the source rejected every page size the
264        connector asked for, so the budget has to mean exactly that.
265
266        The budget still terminates the read, because only a page that succeeded restarts it and only
267        `_read_pages` calls this, once per page it consumed. So between any two restarts the partition made one
268        page of progress, and the reductions that make no progress are bounded by `max_attempts`. Under `NEVER`
269        the reduced page size is never restored either, so it strictly decreases and `minimum_page_size` bounds
270        the reductions of the whole partition on its own.
271
272        Only `AFTER_SUCCESSFUL_PAGE` restores the page size. That policy is for an API that rejects the
273        configured page size on every page, so every page legitimately costs one reduction; `NEVER` keeps the
274        reduced size for the rest of the partition, which is the right behaviour when reductions are one-off.
275        """
276        self._attempts = 0
277        self._retries_at_minimum_page_size = 0
278
279        if self._config.reset_policy != PageSizeResetPolicy.AFTER_SUCCESSFUL_PAGE:
280            return
281
282        if self._current_page_size is not None:
283            LOGGER.info(
284                f"Restoring the page size of stream {self._stream_name} from {self._current_page_size} to {self._configured_page_size}."
285            )
286            self._current_page_size = None

Called after each page that did not require a reduction.

The max_attempts budget restarts under both policies. It counts the reductions made in a row without a single page succeeding, which is what separates a partition that is stuck from one that is merely expensive: on a stream whose per-page cost varies - the GraphQL case this feature exists for - a handful of heavy pages spread over a long partition is a healthy read, and a budget spanning the whole partition would fail it at the max_attempts + 1-th heavy page while every reduction so far had been followed by a successful page. The terminal message says the source rejected every page size the connector asked for, so the budget has to mean exactly that.

The budget still terminates the read, because only a page that succeeded restarts it and only _read_pages calls this, once per page it consumed. So between any two restarts the partition made one page of progress, and the reductions that make no progress are bounded by max_attempts. Under NEVER the reduced page size is never restored either, so it strictly decreases and minimum_page_size bounds the reductions of the whole partition on its own.

Only AFTER_SUCCESSFUL_PAGE restores the page size. That policy is for an API that rejects the configured page size on every page, so every page legitimately costs one reduction; NEVER keeps the reduced size for the rest of the partition, which is the right behaviour when reductions are one-off.