airbyte_cdk.sources.declarative.retrievers.simple_retriever

  1#
  2# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
  3#
  4
  5import datetime
  6import json
  7import logging
  8from collections import defaultdict
  9from dataclasses import InitVar, dataclass, field
 10from functools import partial
 11from typing import (
 12    Any,
 13    Callable,
 14    Iterable,
 15    List,
 16    Mapping,
 17    MutableMapping,
 18    Optional,
 19    Set,
 20    Tuple,
 21    Union,
 22)
 23
 24import requests
 25from typing_extensions import deprecated
 26
 27from airbyte_cdk.models import FailureType
 28from airbyte_cdk.sources.declarative.extractors.http_selector import HttpSelector
 29from airbyte_cdk.sources.declarative.extractors.record_filter import (
 30    ClientSideIncrementalRecordFilterDecorator,
 31)
 32from airbyte_cdk.sources.declarative.interpolation import InterpolatedString
 33from airbyte_cdk.sources.declarative.partition_routers.single_partition_router import (
 34    SinglePartitionRouter,
 35)
 36from airbyte_cdk.sources.declarative.requesters.paginators.no_pagination import NoPagination
 37from airbyte_cdk.sources.declarative.requesters.paginators.paginator import (
 38    Paginator,
 39    page_size_override_kwargs,
 40    stream_slice_kwargs,
 41)
 42from airbyte_cdk.sources.declarative.requesters.query_properties import QueryProperties
 43from airbyte_cdk.sources.declarative.requesters.request_options import (
 44    DefaultRequestOptionsProvider,
 45    RequestOptionsProvider,
 46)
 47from airbyte_cdk.sources.declarative.requesters.requester import Requester
 48from airbyte_cdk.sources.declarative.retrievers.page_size_reducer import (
 49    PageSizeReducer,
 50    PageSizeReduction,
 51)
 52from airbyte_cdk.sources.declarative.retrievers.pagination_tracker import PaginationTracker
 53from airbyte_cdk.sources.declarative.retrievers.request_window_splitting import (
 54    RequestWindowSplitting,
 55)
 56from airbyte_cdk.sources.declarative.retrievers.retriever import Retriever
 57from airbyte_cdk.sources.declarative.stream_slicers.stream_slicer import StreamSlicer
 58from airbyte_cdk.sources.source import ExperimentalClassWarning
 59from airbyte_cdk.sources.streams.core import StreamData
 60from airbyte_cdk.sources.streams.http.page_size_reduction_exception import (
 61    PageSizeReductionNotSupportedException,
 62    PageSizeReductionRequiredException,
 63)
 64from airbyte_cdk.sources.streams.http.pagination_reset_exception import (
 65    PaginationResetRequiredException,
 66)
 67from airbyte_cdk.sources.streams.http.request_window_split_exception import (
 68    RequestWindowSplitNotSupportedException,
 69    RequestWindowSplitRequiredException,
 70)
 71from airbyte_cdk.sources.types import Config, Record, StreamSlice
 72from airbyte_cdk.utils.mapping_helpers import combine_mappings
 73from airbyte_cdk.utils.traced_exception import AirbyteTracedException
 74
 75FULL_REFRESH_SYNC_COMPLETE_KEY = "__ab_full_refresh_sync_complete"
 76LOGGER = logging.getLogger("airbyte")
 77
 78# A defense-in-depth bound independent of any `request_window_splitter`'s own no-progress guard: protects
 79# against a misbehaving custom cursor whose children don't actually shrink the window. Not user-configurable.
 80_MAX_REQUEST_WINDOW_SPLIT_DEPTH = 10
 81
 82
 83@dataclass(frozen=True)
 84class PageCompleted:
 85    """Describe a completed response page observed by an incremental cursor."""
 86
 87    stream_slice: StreamSlice
 88    response: requests.Response
 89    pagination_exhausted: bool
 90
 91
 92@dataclass
 93class SimpleRetriever(Retriever):
 94    """
 95    Retrieves records by synchronously sending requests to fetch records.
 96
 97    The retriever acts as an orchestrator between the requester, the record selector, the paginator, and the stream slicer.
 98
 99    For each stream slice, submit requests until there are no more pages of records to fetch.
100
101    This retriever currently inherits from HttpStream to reuse the request submission and pagination machinery.
102    As a result, some of the parameters passed to some methods are unused.
103    The two will be decoupled in a future release.
104
105    Attributes:
106        stream_name (str): The stream's name
107        stream_primary_key (Optional[Union[str, List[str], List[List[str]]]]): The stream's primary key
108        requester (Requester): The HTTP requester
109        record_selector (HttpSelector): The record selector
110        paginator (Optional[Paginator]): The paginator
111        stream_slicer (Optional[StreamSlicer]): The stream slicer
112        parameters (Mapping[str, Any]): Additional runtime parameters to be used for string interpolation
113        post_pagination_filter (Optional[ClientSideIncrementalRecordFilterDecorator]): Set for data feed streams only.
114            Records the cursor considers already synced are dropped once pagination has observed them
115        page_size_reduction (Optional[PageSizeReduction]): How much to shrink the page size when an error handler
116            resolves to `ResponseAction.REDUCE_PAGE_SIZE`. `None` disables page size reduction entirely.
117            It is immutable configuration; the page size in effect lives in a `PageSizeReducer` that
118            `_read_pages` creates per call, so the retriever and its paginator - both shared by every
119            partition of the stream, read concurrently - stay stateless. When `page_size_reduction` is
120            `None` no reducer is created and `_read_pages` keeps its previous behaviour
121        request_window_splitting (Optional[RequestWindowSplitting]): Policy applied when an error handler
122            resolves to `ResponseAction.SPLIT_REQUEST_WINDOW`, or when `RequestWindowSplitRequiredException`
123            is raised directly by custom code. `None` disables request-window splitting entirely: `read_records`
124            converts the exception into `RequestWindowSplitNotSupportedException` instead of attempting a
125            split it cannot perform.
126        request_window_splitter: Bound method - normally the stream's own cursor's `split_request_window` -
127            asked to replace a failing `StreamSlice` with smaller children. `None` has the same effect as
128            `request_window_splitting` being `None`.
129        page_observer (Optional[Callable[[PageCompleted], None]]): Observes completed response pages; set
130            only on streams using `SyncTokenCursor` and `None` otherwise.
131    """
132
133    requester: Requester
134    record_selector: HttpSelector
135    config: Config
136    parameters: InitVar[Mapping[str, Any]]
137    name: str
138    _name: Union[InterpolatedString, str] = field(init=False, repr=False, default="")
139    primary_key: Optional[Union[str, List[str], List[List[str]]]]
140    _primary_key: str = field(init=False, repr=False, default="")
141    paginator: Optional[Paginator] = None
142    stream_slicer: StreamSlicer = field(
143        default_factory=lambda: SinglePartitionRouter(parameters={})
144    )
145    request_option_provider: RequestOptionsProvider = field(
146        default_factory=lambda: DefaultRequestOptionsProvider(parameters={})
147    )
148    ignore_stream_slicer_parameters_on_paginated_requests: bool = False
149    additional_query_properties: Optional[QueryProperties] = None
150    log_formatter: Optional[Callable[[requests.Response], Any]] = None
151    pagination_tracker_factory: Callable[[], PaginationTracker] = field(
152        default_factory=lambda: lambda: PaginationTracker()
153    )
154    post_pagination_filter: Optional[ClientSideIncrementalRecordFilterDecorator] = None
155    page_size_reduction: Optional[PageSizeReduction] = None
156    request_window_splitting: Optional[RequestWindowSplitting] = None
157    request_window_splitter: Optional[
158        Callable[[StreamSlice, Optional[datetime.timedelta]], Optional[List[StreamSlice]]]
159    ] = None
160    page_observer: Optional[Callable[["PageCompleted"], None]] = None
161
162    def __post_init__(self, parameters: Mapping[str, Any]) -> None:
163        self._paginator = self.paginator or NoPagination(parameters=parameters)
164        self._parameters = parameters
165        self._name = (
166            InterpolatedString(self._name, parameters=parameters)
167            if isinstance(self._name, str)
168            else self._name
169        )
170
171    @property  # type: ignore
172    def name(self) -> str:
173        """
174        :return: Stream name
175        """
176        return (
177            str(self._name.eval(self.config))
178            if isinstance(self._name, InterpolatedString)
179            else self._name
180        )
181
182    @name.setter
183    def name(self, value: str) -> None:
184        if not isinstance(value, property):
185            self._name = value
186
187    def _get_mapping(
188        self, method: Callable[..., Optional[Union[Mapping[str, Any], str]]], **kwargs: Any
189    ) -> Tuple[Union[Mapping[str, Any], str], Set[str]]:
190        """
191        Get mapping from the provided method, and get the keys of the mapping.
192        If the method returns a string, it will return the string and an empty set.
193        If the method returns a dict, it will return the dict and its keys.
194        """
195        mapping = method(**kwargs) or {}
196        keys = set(mapping.keys()) if not isinstance(mapping, str) else set()
197        return mapping, keys
198
199    def _get_request_options(
200        self,
201        stream_slice: Optional[StreamSlice],
202        next_page_token: Optional[Mapping[str, Any]],
203        paginator_method: Callable[..., Optional[Union[Mapping[str, Any], str]]],
204        stream_slicer_method: Callable[..., Optional[Union[Mapping[str, Any], str]]],
205        page_size_override: Optional[int] = None,
206    ) -> Union[Mapping[str, Any], str]:
207        """
208        Get the request_option from the paginator and the stream slicer.
209        Raise a ValueError if there's a key collision
210        Returned merged mapping otherwise
211        """
212        is_body_json = paginator_method.__name__ == "get_request_body_json"
213
214        mappings = [
215            paginator_method(
216                stream_slice=stream_slice,
217                next_page_token=next_page_token,
218                **page_size_override_kwargs(page_size_override),
219            ),
220        ]
221        if not next_page_token or not self.ignore_stream_slicer_parameters_on_paginated_requests:
222            mappings.append(
223                stream_slicer_method(
224                    stream_slice=stream_slice,
225                    next_page_token=next_page_token,
226                )
227            )
228        return combine_mappings(mappings, allow_same_value_merge=is_body_json)
229
230    def _request_headers(
231        self,
232        stream_slice: Optional[StreamSlice] = None,
233        next_page_token: Optional[Mapping[str, Any]] = None,
234        page_size_override: Optional[int] = None,
235    ) -> Mapping[str, Any]:
236        """
237        Specifies request headers.
238        Authentication headers will overwrite any overlapping headers returned from this method.
239        """
240        headers = self._get_request_options(
241            stream_slice,
242            next_page_token,
243            self._paginator.get_request_headers,
244            self.request_option_provider.get_request_headers,
245            **page_size_override_kwargs(page_size_override),
246        )
247        if isinstance(headers, str):
248            raise ValueError("Request headers cannot be a string")
249        return {str(k): str(v) for k, v in headers.items()}
250
251    def _request_params(
252        self,
253        stream_slice: Optional[StreamSlice] = None,
254        next_page_token: Optional[Mapping[str, Any]] = None,
255        page_size_override: Optional[int] = None,
256    ) -> Mapping[str, Any]:
257        """
258        Specifies the query parameters that should be set on an outgoing HTTP request given the inputs.
259
260        E.g: you might want to define query parameters for paging if next_page_token is not None.
261        """
262        params = self._get_request_options(
263            stream_slice,
264            next_page_token,
265            self._paginator.get_request_params,
266            self.request_option_provider.get_request_params,
267            **page_size_override_kwargs(page_size_override),
268        )
269        if isinstance(params, str):
270            raise ValueError("Request params cannot be a string")
271        return params
272
273    def _request_body_data(
274        self,
275        stream_slice: Optional[StreamSlice] = None,
276        next_page_token: Optional[Mapping[str, Any]] = None,
277        page_size_override: Optional[int] = None,
278    ) -> Union[Mapping[str, Any], str]:
279        """
280        Specifies how to populate the body of the request with a non-JSON payload.
281
282        If returns a ready text that it will be sent as is.
283        If returns a dict that it will be converted to a urlencoded form.
284        E.g. {"key1": "value1", "key2": "value2"} => "key1=value1&key2=value2"
285
286        At the same time only one of the 'request_body_data' and 'request_body_json' functions can be overridden.
287        """
288        return self._get_request_options(
289            stream_slice,
290            next_page_token,
291            self._paginator.get_request_body_data,
292            self.request_option_provider.get_request_body_data,
293            **page_size_override_kwargs(page_size_override),
294        )
295
296    def _request_body_json(
297        self,
298        stream_slice: Optional[StreamSlice] = None,
299        next_page_token: Optional[Mapping[str, Any]] = None,
300        page_size_override: Optional[int] = None,
301    ) -> Optional[Mapping[str, Any]]:
302        """
303        Specifies how to populate the body of the request with a JSON payload.
304
305        At the same time only one of the 'request_body_data' and 'request_body_json' functions can be overridden.
306        """
307        body_json = self._get_request_options(
308            stream_slice,
309            next_page_token,
310            self._paginator.get_request_body_json,
311            self.request_option_provider.get_request_body_json,
312            **page_size_override_kwargs(page_size_override),
313        )
314        if isinstance(body_json, str):
315            raise ValueError("Request body json cannot be a string")
316        return body_json
317
318    def _paginator_path(
319        self,
320        next_page_token: Optional[Mapping[str, Any]] = None,
321        stream_slice: Optional[StreamSlice] = None,
322    ) -> Optional[str]:
323        """
324        If the paginator points to a path, follow it, else return nothing so the requester is used.
325        :param next_page_token:
326        :return:
327        """
328        return self._paginator.path(
329            next_page_token=next_page_token,
330            stream_state={},  # stream_state as an interpolation context is deprecated
331            stream_slice=stream_slice,
332        )
333
334    def _parse_response(
335        self,
336        response: Optional[requests.Response],
337        records_schema: Mapping[str, Any],
338        stream_slice: Optional[StreamSlice] = None,
339        next_page_token: Optional[Mapping[str, Any]] = None,
340    ) -> Iterable[Record]:
341        if not response:
342            yield from []
343        else:
344            yield from self.record_selector.select_records(
345                response=response,
346                stream_state={},  # stream_state as an interpolation context is deprecated
347                records_schema=records_schema,
348                stream_slice=stream_slice,
349                next_page_token=next_page_token,
350            )
351
352    @property  # type: ignore
353    def primary_key(self) -> Optional[Union[str, List[str], List[List[str]]]]:
354        """The stream's primary key"""
355        return self._primary_key
356
357    @primary_key.setter
358    def primary_key(self, value: str) -> None:
359        if not isinstance(value, property):
360            self._primary_key = value
361
362    def _next_page_token(
363        self,
364        response: requests.Response,
365        last_page_size: int,
366        last_record: Optional[Record],
367        last_page_token_value: Optional[Any],
368        page_size_override: Optional[int] = None,
369        stream_slice: Optional[StreamSlice] = None,
370    ) -> Optional[Mapping[str, Any]]:
371        """
372        Specifies a pagination strategy.
373
374        The value returned from this method is passed to most other methods in this class. Use it to form a request e.g: set headers or query params.
375
376        :return: The token for the next page from the input response object. Returning None means there are no more pages to read in this response.
377        """
378        return self._paginator.next_page_token(
379            response=response,
380            last_page_size=last_page_size,
381            last_record=last_record,
382            last_page_token_value=last_page_token_value,
383            **page_size_override_kwargs(page_size_override),
384            **stream_slice_kwargs(self._paginator.next_page_token, stream_slice),
385        )
386
387    def _fetch_next_page(
388        self,
389        stream_slice: StreamSlice,
390        next_page_token: Optional[Mapping[str, Any]] = None,
391        page_size_override: Optional[int] = None,
392    ) -> Optional[requests.Response]:
393        return self.requester.send_request(
394            path=self._paginator_path(
395                next_page_token=next_page_token,
396                stream_slice=stream_slice,
397            ),
398            stream_state={},  # stream_state as an interpolation context is deprecated
399            stream_slice=stream_slice,
400            next_page_token=next_page_token,
401            request_headers=self._request_headers(
402                stream_slice=stream_slice,
403                next_page_token=next_page_token,
404                **page_size_override_kwargs(page_size_override),
405            ),
406            request_params=self._request_params(
407                stream_slice=stream_slice,
408                next_page_token=next_page_token,
409                **page_size_override_kwargs(page_size_override),
410            ),
411            request_body_data=self._request_body_data(
412                stream_slice=stream_slice,
413                next_page_token=next_page_token,
414                **page_size_override_kwargs(page_size_override),
415            ),
416            request_body_json=self._request_body_json(
417                stream_slice=stream_slice,
418                next_page_token=next_page_token,
419                **page_size_override_kwargs(page_size_override),
420            ),
421            log_formatter=self.log_formatter,
422        )
423
424    # This logic is similar to _read_pages in the HttpStream class. When making changes here, consider making changes there as well.
425    def _read_pages(
426        self,
427        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
428        stream_slice: StreamSlice,
429    ) -> Iterable[Record]:
430        original_stream_slice = stream_slice
431        pagination_tracker = self.pagination_tracker_factory()
432        page_size_reducer = (
433            PageSizeReducer(
434                self.page_size_reduction,
435                self._paginator.get_page_size(),
436                stream_name=self.name,
437            )
438            if self.page_size_reduction
439            else None
440        )
441        reset_pagination = False
442        reduce_page_size = False
443        next_page_token = self._get_initial_next_page_token()
444        while True:
445            page_size_override = page_size_reducer.page_size_override if page_size_reducer else None
446            merged_records: MutableMapping[str, Any] = defaultdict(dict)
447            last_page_size = 0
448            last_record: Optional[Record] = None
449
450            response = None
451            try:
452                if self.additional_query_properties:
453                    for (
454                        properties
455                    ) in self.additional_query_properties.get_request_property_chunks():
456                        stream_slice = StreamSlice(
457                            partition=stream_slice.partition or {},
458                            cursor_slice=stream_slice.cursor_slice or {},
459                            extra_fields={"query_properties": properties},
460                        )
461                        response = self._fetch_next_page(
462                            stream_slice,
463                            next_page_token,
464                            **page_size_override_kwargs(page_size_override),
465                        )
466
467                        for current_record in records_generator_fn(response):
468                            if self.additional_query_properties.property_chunking:
469                                merge_key = self.additional_query_properties.property_chunking.get_merge_key(
470                                    current_record
471                                )
472                                if merge_key:
473                                    _deep_merge(merged_records[merge_key], current_record)
474                                else:
475                                    # We should still emit records even if the record did not have a merge key
476                                    pagination_tracker.observe(current_record)
477                                    last_page_size += 1
478                                    last_record = current_record
479                                    yield current_record
480                            else:
481                                pagination_tracker.observe(current_record)
482                                last_page_size += 1
483                                last_record = current_record
484                                yield current_record
485
486                    for merged_record in merged_records.values():
487                        record = Record(
488                            data=merged_record, stream_name=self.name, associated_slice=stream_slice
489                        )
490                        pagination_tracker.observe(record)
491                        last_page_size += 1
492                        last_record = record
493                        yield record
494                else:
495                    response = self._fetch_next_page(
496                        stream_slice,
497                        next_page_token,
498                        **page_size_override_kwargs(page_size_override),
499                    )
500                    for current_record in records_generator_fn(response):
501                        pagination_tracker.observe(current_record)
502                        last_page_size += 1
503                        last_record = current_record
504                        yield current_record
505            except PaginationResetRequiredException:
506                reset_pagination = True
507            except PageSizeReductionRequiredException as exception:
508                if page_size_reducer is None:
509                    # The action can be attached to a requester we cannot validate at config time, such as one
510                    # built by a custom error handler or a CustomRequester. Re-raise as the misconfiguration it
511                    # is: the exception being handled is the neutral "the API asked for a smaller page" signal.
512                    raise PageSizeReductionNotSupportedException(stream_name=self.name)
513                if last_page_size:
514                    # The reduction is safe only because it re-issues a page whose records were not emitted.
515                    # Every in-CDK way of reaching this raises from `_fetch_next_page`, before the record loop,
516                    # and the factory rejects the manifest constructs that would not, but a custom extractor,
517                    # filter or transformation can issue its own request from inside the record generator.
518                    # Re-issuing the page then duplicates the records already yielded, so fail instead.
519                    raise AirbyteTracedException(
520                        internal_message=f"Stream {self.name} requested a page size reduction after {last_page_size} records of the page had already been emitted",
521                        message=f"Stream {self.name} asked for a smaller page size in the middle of a page. The page cannot be requested again without duplicating the records already read from it. Move the REDUCE_PAGE_SIZE action to the error handler of the stream's main requester.",
522                        failure_type=FailureType.config_error,
523                    )
524                # Raises once the page size cannot be reduced any further and the retries allowed at that
525                # floor are spent, which is what stops the loop when the API keeps failing.
526                page_size_reducer.reduce(exception.error_message)
527                reduce_page_size = True
528            else:
529                if page_size_reducer:
530                    page_size_reducer.on_successful_page()
531                if not response:
532                    break
533
534            if reduce_page_size:
535                # Retry the very same page: neither the token nor the slice change, only the page size does -
536                # and not even that once the reducer is at its floor and only waiting is left.
537                reduce_page_size = False
538                continue
539
540            if reset_pagination or pagination_tracker.has_reached_limit():
541                next_page_token = self._get_initial_next_page_token()
542                previous_slice = stream_slice
543                stream_slice = pagination_tracker.reduce_slice_range_if_possible(
544                    stream_slice, original_stream_slice
545                )
546                LOGGER.info(
547                    f"Hitting PaginationReset event. StreamSlice used will go from {previous_slice} to {stream_slice}"
548                )
549                reset_pagination = False
550            else:
551                last_page_token_value = (
552                    next_page_token.get("next_page_token") if next_page_token else None
553                )
554                next_page_token = self._next_page_token(
555                    response=response,  # type:ignore # we are breaking from the loop on the try/else if there are no response so this should be fine
556                    last_page_size=last_page_size,
557                    last_record=last_record,
558                    last_page_token_value=last_page_token_value,
559                    **page_size_override_kwargs(page_size_override),
560                    **stream_slice_kwargs(self._next_page_token, stream_slice),
561                )
562                if response is not None and self.page_observer:
563                    self.page_observer(
564                        PageCompleted(
565                            stream_slice=stream_slice,
566                            response=response,
567                            pagination_exhausted=not next_page_token,
568                        )
569                    )
570                if not next_page_token:
571                    break
572
573        # Always return an empty generator just in case no records were ever yielded
574        yield from []
575
576    def _get_initial_next_page_token(self) -> Optional[Mapping[str, Any]]:
577        initial_token = self._paginator.get_initial_token()
578        next_page_token = {"next_page_token": initial_token} if initial_token is not None else None
579        return next_page_token
580
581    def read_records(
582        self,
583        records_schema: Mapping[str, Any],
584        stream_slice: Optional[StreamSlice] = None,
585    ) -> Iterable[StreamData]:
586        """
587        Fetch a stream's records from an HTTP API source
588
589        :param records_schema: json schema to describe record
590        :param stream_slice: The stream slice to read data for
591        :return: The records read from the API source
592        """
593        _slice = stream_slice or StreamSlice(partition={}, cursor_slice={})  # None-check
594        yield from self._read_records_or_split_request_window(
595            records_schema, _slice, _slice, depth=0
596        )
597
598    def _read_records_or_split_request_window(
599        self,
600        records_schema: Mapping[str, Any],
601        stream_slice: StreamSlice,
602        original_slice: StreamSlice,
603        depth: int,
604    ) -> Iterable[StreamData]:
605        """
606        Read `stream_slice` to completion, replacing it with smaller children and recursing into each of them in
607        turn when a `RequestWindowSplitRequiredException` is raised while reading it.
608
609        Each recursive call re-enters this method - and, through it, `_read_pages` - from scratch for the child
610        slice it is given, so every child gets its own paginator token, `PaginationTracker`, and
611        `PageSizeReducer` for free: nothing here needs to reset that state explicitly. Because the whole
612        recursion lives inside the single generator `DeclarativePartition.read()` consumes,
613        `PartitionReader.process_partition()` only calls `cursor.close_partition()` once, after this generator is
614        fully exhausted - so a failure anywhere in the recursion (a child that cannot be read, or a window that
615        cannot be split any further) propagates out without ever checkpointing the original partition.
616
617        `original_slice` is threaded through the recursion unchanged so every record yielded, however deep the
618        recursion went to produce it, is re-stamped with the partition's own slice rather than the child slice it
619        was actually read against - see `_reassociate_with_original_slice`. `depth` bounds the recursion
620        independently of `request_window_splitter`'s own no-progress guard: it is enforced here so a
621        misbehaving custom cursor cannot recurse indefinitely regardless of what that guard does or does not
622        catch.
623        """
624        record_generator = partial(
625            self._parse_records,
626            stream_slice=stream_slice,
627            records_schema=records_schema,
628        )
629
630        emitted_count = 0
631        try:
632            records: Iterable[Mapping[str, Any]] = self._read_pages(record_generator, stream_slice)
633            if self.post_pagination_filter:
634                # A data feed paginates until it reaches a record older than the cursor, so the page that triggers the stop
635                # condition still holds already-synced records. Those are filtered here rather than in the record selector
636                # so that the paginator keeps seeing the whole page: the stop condition is evaluated on the last record of
637                # the page, which is precisely one of the records being dropped. Two consequences of filtering this late:
638                # the pagination tracker observes the dropped records, and a `file_uploader` on the record selector has
639                # already uploaded their files by the time they are dropped.
640                records = self.post_pagination_filter.filter_records(
641                    records,
642                    # the filter is only used for its cursor comparison, which does not read the stream state
643                    stream_state={},
644                    stream_slice=stream_slice,
645                )
646            for record in records:
647                emitted_count += 1
648                yield self._reassociate_with_original_slice(record, original_slice)
649        except RequestWindowSplitRequiredException as exception:
650            if self.request_window_splitting is None or self.request_window_splitter is None:
651                raise RequestWindowSplitNotSupportedException(stream_name=self.name) from exception
652
653            if emitted_count:
654                # Splitting and re-reading the window re-emits these records: a failed partition is never
655                # checkpointed, so the next attempt would re-emit them anyway. Always replaying makes forward
656                # progress instead of retrying the same oversized window forever; primary-key dedup downstream
657                # handles the duplicates either way.
658                LOGGER.warning(
659                    f"Stream {self.name} already emitted {emitted_count} record(s) from {stream_slice} before "
660                    f"it was rejected; splitting and re-reading the window may re-emit them."
661                )
662
663            rejection = f": {exception.error_message}" if exception.error_message else ""
664            if depth >= _MAX_REQUEST_WINDOW_SPLIT_DEPTH:
665                # Logged separately from the exception below so it stays visible even if the trace message's
666                # internal_message isn't surfaced by whatever catches it.
667                LOGGER.warning(
668                    f"Stream {self.name} hit the maximum request window split depth "
669                    f"({_MAX_REQUEST_WINDOW_SPLIT_DEPTH}) while splitting {original_slice}."
670                )
671                raise AirbyteTracedException(
672                    internal_message=f"Stream {self.name} exceeded the maximum request window split depth of {_MAX_REQUEST_WINDOW_SPLIT_DEPTH} while splitting {original_slice}; the API still rejected {stream_slice}{rejection}",
673                    # `transient_error`, so the only remediation is the connector's own, if it defined one.
674                    message=self._with_request_window_failure_message(
675                        f"Stream {self.name} could not split its request window to a size the API accepts "
676                        f"within {_MAX_REQUEST_WINDOW_SPLIT_DEPTH} splits."
677                    ),
678                    failure_type=FailureType.transient_error,
679                ) from exception
680
681            children = self.request_window_splitter(
682                stream_slice, self.request_window_splitting.min_split_window
683            )
684            if children is None:
685                min_split_window_note = (
686                    f", its configured `min_split_window` ({self.request_window_splitting.min_split_window})"
687                    if self.request_window_splitting.min_split_window
688                    else ""
689                )
690                raise AirbyteTracedException(
691                    internal_message=f"Stream {self.name} could not split its request window {stream_slice} any further; the API rejected it{rejection}",
692                    # `transient_error` regardless of how the triggering response was classified: exhaustion is
693                    # never the user's fault, matching `PageSizeReducer`'s equivalent exhaustion branch.
694                    message=self._with_request_window_failure_message(
695                        f"The API kept rejecting stream {self.name}'s request window even at the smallest window "
696                        f"its cursor allows{min_split_window_note}, or splitting the window further would not "
697                        f"make progress."
698                    ),
699                    failure_type=FailureType.transient_error,
700                ) from exception
701
702            LOGGER.info(
703                f"Stream {self.name}: the API rejected request window {stream_slice} (split depth {depth}); "
704                f"reducing it to {children} and reading each in turn."
705            )
706            for child in children:
707                yield from self._read_records_or_split_request_window(
708                    records_schema, child, original_slice, depth + 1
709                )
710
711    def _with_request_window_failure_message(self, message: str) -> str:
712        """
713        :return: the message followed by `request_window_splitting`'s configured `failure_message`, when it
714            defined one - mirroring `PageSizeReducer._with_failure_message`
715        """
716        assert self.request_window_splitting is not None
717        failure_message = (self.request_window_splitting.failure_message or "").strip()
718        if not failure_message:
719            return message
720        return f"{message} {failure_message}"
721
722    @staticmethod
723    def _reassociate_with_original_slice(
724        record: StreamData, original_slice: StreamSlice
725    ) -> StreamData:
726        """
727        Records read while recursing into a split child window are parsed against that child's `StreamSlice`,
728        so `RecordSelector` stamps them with the child as `associated_slice`. `DeclarativePartition.read()`
729        passes an already-built `Record` through unchanged rather than re-wrapping it, so left uncorrected the
730        child slice - not the partition's own slice - is what `ConcurrentCursor.observe()` would key its
731        per-partition bookkeeping by. `close_partition()` always looks that bookkeeping up by the partition's own
732        slice, so it would never find it, silently losing the precise most-recently-observed cursor value for a
733        split partition (recoverable in practice today only because callers of that lookup fall back to the
734        slice's end boundary when it is missing). Re-stamping every record with `original_slice` here restores
735        the same tracking a non-split read already gets, rather than relying on that fallback.
736        """
737        if isinstance(record, Record) and record.associated_slice is not original_slice:
738            return Record(
739                data=record.data,
740                stream_name=record.stream_name,
741                associated_slice=original_slice,
742                file_reference=record.file_reference,
743            )
744        return record
745
746    def _parse_records(
747        self,
748        response: Optional[requests.Response],
749        records_schema: Mapping[str, Any],
750        stream_slice: Optional[StreamSlice],
751    ) -> Iterable[Record]:
752        yield from self._parse_response(
753            response,
754            stream_slice=stream_slice,
755            records_schema=records_schema,
756        )
757
758    def must_deduplicate_query_params(self) -> bool:
759        return True
760
761    @staticmethod
762    def _to_partition_key(to_serialize: Any) -> str:
763        # separators have changed in Python 3.4. To avoid being impacted by further change, we explicitly specify our own value
764        return json.dumps(to_serialize, indent=None, separators=(",", ":"), sort_keys=True)
765
766
767def _deep_merge(
768    target: MutableMapping[str, Any], source: Union[Record, MutableMapping[str, Any]]
769) -> None:
770    """
771    Recursively merge two dictionaries, combining nested dictionaries instead of overwriting them.
772
773    :param target: The dictionary to merge into (modified in place)
774    :param source: The dictionary to merge from
775    """
776    for key, value in source.items():
777        if (
778            key in target
779            and isinstance(target[key], MutableMapping)
780            and isinstance(value, MutableMapping)
781        ):
782            _deep_merge(target[key], value)
783        else:
784            target[key] = value
785
786
787@deprecated(
788    "This class is experimental. Use at your own risk.",
789    category=ExperimentalClassWarning,
790)
791@dataclass
792class LazySimpleRetriever(SimpleRetriever):
793    """
794    A retriever that supports lazy loading from parent streams.
795    """
796
797    def _read_pages(
798        self,
799        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
800        stream_slice: StreamSlice,
801    ) -> Iterable[Record]:
802        response = stream_slice.extra_fields["child_response"]
803        if response:
804            last_page_size, last_record = 0, None
805            for record in records_generator_fn(response):  # type: ignore[call-arg] # only _parse_records expected as a func
806                last_page_size += 1
807                last_record = record
808                yield record
809
810            next_page_token = self._next_page_token(
811                response,
812                last_page_size,
813                last_record,
814                None,
815                **stream_slice_kwargs(self._next_page_token, stream_slice),
816            )
817            if next_page_token:
818                yield from self._paginate(
819                    next_page_token,
820                    records_generator_fn,
821                    stream_slice,
822                )
823
824            yield from []
825        else:
826            # coderabbit detected an interesting bug/gap where if we were to not get a child_response, we
827            # might recurse forever. This might not be the case, but it is worth noting that this code path
828            # isn't comprehensively tested.
829            yield from self._read_pages(records_generator_fn, stream_slice)
830
831    def _paginate(
832        self,
833        next_page_token: Any,
834        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
835        stream_slice: StreamSlice,
836    ) -> Iterable[Record]:
837        """Handle pagination by fetching subsequent pages."""
838        pagination_complete = False
839
840        while not pagination_complete:
841            response = self._fetch_next_page(stream_slice, next_page_token)
842            last_page_size, last_record = 0, None
843
844            for record in records_generator_fn(response):  # type: ignore[call-arg] # only _parse_records expected as a func
845                last_page_size += 1
846                last_record = record
847                yield record
848
849            if not response:
850                pagination_complete = True
851            else:
852                last_page_token_value = (
853                    next_page_token.get("next_page_token") if next_page_token else None
854                )
855                next_page_token = self._next_page_token(
856                    response,
857                    last_page_size,
858                    last_record,
859                    last_page_token_value,
860                    **stream_slice_kwargs(self._next_page_token, stream_slice),
861                )
862
863                if not next_page_token:
864                    pagination_complete = True
FULL_REFRESH_SYNC_COMPLETE_KEY = '__ab_full_refresh_sync_complete'
LOGGER = <Logger airbyte (INFO)>
@dataclass(frozen=True)
class PageCompleted:
84@dataclass(frozen=True)
85class PageCompleted:
86    """Describe a completed response page observed by an incremental cursor."""
87
88    stream_slice: StreamSlice
89    response: requests.Response
90    pagination_exhausted: bool

Describe a completed response page observed by an incremental cursor.

PageCompleted( stream_slice: airbyte_cdk.StreamSlice, response: requests.models.Response, pagination_exhausted: bool)
stream_slice: airbyte_cdk.StreamSlice
response: requests.models.Response
pagination_exhausted: bool
@dataclass
class SimpleRetriever(airbyte_cdk.sources.declarative.retrievers.retriever.Retriever):
 93@dataclass
 94class SimpleRetriever(Retriever):
 95    """
 96    Retrieves records by synchronously sending requests to fetch records.
 97
 98    The retriever acts as an orchestrator between the requester, the record selector, the paginator, and the stream slicer.
 99
100    For each stream slice, submit requests until there are no more pages of records to fetch.
101
102    This retriever currently inherits from HttpStream to reuse the request submission and pagination machinery.
103    As a result, some of the parameters passed to some methods are unused.
104    The two will be decoupled in a future release.
105
106    Attributes:
107        stream_name (str): The stream's name
108        stream_primary_key (Optional[Union[str, List[str], List[List[str]]]]): The stream's primary key
109        requester (Requester): The HTTP requester
110        record_selector (HttpSelector): The record selector
111        paginator (Optional[Paginator]): The paginator
112        stream_slicer (Optional[StreamSlicer]): The stream slicer
113        parameters (Mapping[str, Any]): Additional runtime parameters to be used for string interpolation
114        post_pagination_filter (Optional[ClientSideIncrementalRecordFilterDecorator]): Set for data feed streams only.
115            Records the cursor considers already synced are dropped once pagination has observed them
116        page_size_reduction (Optional[PageSizeReduction]): How much to shrink the page size when an error handler
117            resolves to `ResponseAction.REDUCE_PAGE_SIZE`. `None` disables page size reduction entirely.
118            It is immutable configuration; the page size in effect lives in a `PageSizeReducer` that
119            `_read_pages` creates per call, so the retriever and its paginator - both shared by every
120            partition of the stream, read concurrently - stay stateless. When `page_size_reduction` is
121            `None` no reducer is created and `_read_pages` keeps its previous behaviour
122        request_window_splitting (Optional[RequestWindowSplitting]): Policy applied when an error handler
123            resolves to `ResponseAction.SPLIT_REQUEST_WINDOW`, or when `RequestWindowSplitRequiredException`
124            is raised directly by custom code. `None` disables request-window splitting entirely: `read_records`
125            converts the exception into `RequestWindowSplitNotSupportedException` instead of attempting a
126            split it cannot perform.
127        request_window_splitter: Bound method - normally the stream's own cursor's `split_request_window` -
128            asked to replace a failing `StreamSlice` with smaller children. `None` has the same effect as
129            `request_window_splitting` being `None`.
130        page_observer (Optional[Callable[[PageCompleted], None]]): Observes completed response pages; set
131            only on streams using `SyncTokenCursor` and `None` otherwise.
132    """
133
134    requester: Requester
135    record_selector: HttpSelector
136    config: Config
137    parameters: InitVar[Mapping[str, Any]]
138    name: str
139    _name: Union[InterpolatedString, str] = field(init=False, repr=False, default="")
140    primary_key: Optional[Union[str, List[str], List[List[str]]]]
141    _primary_key: str = field(init=False, repr=False, default="")
142    paginator: Optional[Paginator] = None
143    stream_slicer: StreamSlicer = field(
144        default_factory=lambda: SinglePartitionRouter(parameters={})
145    )
146    request_option_provider: RequestOptionsProvider = field(
147        default_factory=lambda: DefaultRequestOptionsProvider(parameters={})
148    )
149    ignore_stream_slicer_parameters_on_paginated_requests: bool = False
150    additional_query_properties: Optional[QueryProperties] = None
151    log_formatter: Optional[Callable[[requests.Response], Any]] = None
152    pagination_tracker_factory: Callable[[], PaginationTracker] = field(
153        default_factory=lambda: lambda: PaginationTracker()
154    )
155    post_pagination_filter: Optional[ClientSideIncrementalRecordFilterDecorator] = None
156    page_size_reduction: Optional[PageSizeReduction] = None
157    request_window_splitting: Optional[RequestWindowSplitting] = None
158    request_window_splitter: Optional[
159        Callable[[StreamSlice, Optional[datetime.timedelta]], Optional[List[StreamSlice]]]
160    ] = None
161    page_observer: Optional[Callable[["PageCompleted"], None]] = None
162
163    def __post_init__(self, parameters: Mapping[str, Any]) -> None:
164        self._paginator = self.paginator or NoPagination(parameters=parameters)
165        self._parameters = parameters
166        self._name = (
167            InterpolatedString(self._name, parameters=parameters)
168            if isinstance(self._name, str)
169            else self._name
170        )
171
172    @property  # type: ignore
173    def name(self) -> str:
174        """
175        :return: Stream name
176        """
177        return (
178            str(self._name.eval(self.config))
179            if isinstance(self._name, InterpolatedString)
180            else self._name
181        )
182
183    @name.setter
184    def name(self, value: str) -> None:
185        if not isinstance(value, property):
186            self._name = value
187
188    def _get_mapping(
189        self, method: Callable[..., Optional[Union[Mapping[str, Any], str]]], **kwargs: Any
190    ) -> Tuple[Union[Mapping[str, Any], str], Set[str]]:
191        """
192        Get mapping from the provided method, and get the keys of the mapping.
193        If the method returns a string, it will return the string and an empty set.
194        If the method returns a dict, it will return the dict and its keys.
195        """
196        mapping = method(**kwargs) or {}
197        keys = set(mapping.keys()) if not isinstance(mapping, str) else set()
198        return mapping, keys
199
200    def _get_request_options(
201        self,
202        stream_slice: Optional[StreamSlice],
203        next_page_token: Optional[Mapping[str, Any]],
204        paginator_method: Callable[..., Optional[Union[Mapping[str, Any], str]]],
205        stream_slicer_method: Callable[..., Optional[Union[Mapping[str, Any], str]]],
206        page_size_override: Optional[int] = None,
207    ) -> Union[Mapping[str, Any], str]:
208        """
209        Get the request_option from the paginator and the stream slicer.
210        Raise a ValueError if there's a key collision
211        Returned merged mapping otherwise
212        """
213        is_body_json = paginator_method.__name__ == "get_request_body_json"
214
215        mappings = [
216            paginator_method(
217                stream_slice=stream_slice,
218                next_page_token=next_page_token,
219                **page_size_override_kwargs(page_size_override),
220            ),
221        ]
222        if not next_page_token or not self.ignore_stream_slicer_parameters_on_paginated_requests:
223            mappings.append(
224                stream_slicer_method(
225                    stream_slice=stream_slice,
226                    next_page_token=next_page_token,
227                )
228            )
229        return combine_mappings(mappings, allow_same_value_merge=is_body_json)
230
231    def _request_headers(
232        self,
233        stream_slice: Optional[StreamSlice] = None,
234        next_page_token: Optional[Mapping[str, Any]] = None,
235        page_size_override: Optional[int] = None,
236    ) -> Mapping[str, Any]:
237        """
238        Specifies request headers.
239        Authentication headers will overwrite any overlapping headers returned from this method.
240        """
241        headers = self._get_request_options(
242            stream_slice,
243            next_page_token,
244            self._paginator.get_request_headers,
245            self.request_option_provider.get_request_headers,
246            **page_size_override_kwargs(page_size_override),
247        )
248        if isinstance(headers, str):
249            raise ValueError("Request headers cannot be a string")
250        return {str(k): str(v) for k, v in headers.items()}
251
252    def _request_params(
253        self,
254        stream_slice: Optional[StreamSlice] = None,
255        next_page_token: Optional[Mapping[str, Any]] = None,
256        page_size_override: Optional[int] = None,
257    ) -> Mapping[str, Any]:
258        """
259        Specifies the query parameters that should be set on an outgoing HTTP request given the inputs.
260
261        E.g: you might want to define query parameters for paging if next_page_token is not None.
262        """
263        params = self._get_request_options(
264            stream_slice,
265            next_page_token,
266            self._paginator.get_request_params,
267            self.request_option_provider.get_request_params,
268            **page_size_override_kwargs(page_size_override),
269        )
270        if isinstance(params, str):
271            raise ValueError("Request params cannot be a string")
272        return params
273
274    def _request_body_data(
275        self,
276        stream_slice: Optional[StreamSlice] = None,
277        next_page_token: Optional[Mapping[str, Any]] = None,
278        page_size_override: Optional[int] = None,
279    ) -> Union[Mapping[str, Any], str]:
280        """
281        Specifies how to populate the body of the request with a non-JSON payload.
282
283        If returns a ready text that it will be sent as is.
284        If returns a dict that it will be converted to a urlencoded form.
285        E.g. {"key1": "value1", "key2": "value2"} => "key1=value1&key2=value2"
286
287        At the same time only one of the 'request_body_data' and 'request_body_json' functions can be overridden.
288        """
289        return self._get_request_options(
290            stream_slice,
291            next_page_token,
292            self._paginator.get_request_body_data,
293            self.request_option_provider.get_request_body_data,
294            **page_size_override_kwargs(page_size_override),
295        )
296
297    def _request_body_json(
298        self,
299        stream_slice: Optional[StreamSlice] = None,
300        next_page_token: Optional[Mapping[str, Any]] = None,
301        page_size_override: Optional[int] = None,
302    ) -> Optional[Mapping[str, Any]]:
303        """
304        Specifies how to populate the body of the request with a JSON payload.
305
306        At the same time only one of the 'request_body_data' and 'request_body_json' functions can be overridden.
307        """
308        body_json = self._get_request_options(
309            stream_slice,
310            next_page_token,
311            self._paginator.get_request_body_json,
312            self.request_option_provider.get_request_body_json,
313            **page_size_override_kwargs(page_size_override),
314        )
315        if isinstance(body_json, str):
316            raise ValueError("Request body json cannot be a string")
317        return body_json
318
319    def _paginator_path(
320        self,
321        next_page_token: Optional[Mapping[str, Any]] = None,
322        stream_slice: Optional[StreamSlice] = None,
323    ) -> Optional[str]:
324        """
325        If the paginator points to a path, follow it, else return nothing so the requester is used.
326        :param next_page_token:
327        :return:
328        """
329        return self._paginator.path(
330            next_page_token=next_page_token,
331            stream_state={},  # stream_state as an interpolation context is deprecated
332            stream_slice=stream_slice,
333        )
334
335    def _parse_response(
336        self,
337        response: Optional[requests.Response],
338        records_schema: Mapping[str, Any],
339        stream_slice: Optional[StreamSlice] = None,
340        next_page_token: Optional[Mapping[str, Any]] = None,
341    ) -> Iterable[Record]:
342        if not response:
343            yield from []
344        else:
345            yield from self.record_selector.select_records(
346                response=response,
347                stream_state={},  # stream_state as an interpolation context is deprecated
348                records_schema=records_schema,
349                stream_slice=stream_slice,
350                next_page_token=next_page_token,
351            )
352
353    @property  # type: ignore
354    def primary_key(self) -> Optional[Union[str, List[str], List[List[str]]]]:
355        """The stream's primary key"""
356        return self._primary_key
357
358    @primary_key.setter
359    def primary_key(self, value: str) -> None:
360        if not isinstance(value, property):
361            self._primary_key = value
362
363    def _next_page_token(
364        self,
365        response: requests.Response,
366        last_page_size: int,
367        last_record: Optional[Record],
368        last_page_token_value: Optional[Any],
369        page_size_override: Optional[int] = None,
370        stream_slice: Optional[StreamSlice] = None,
371    ) -> Optional[Mapping[str, Any]]:
372        """
373        Specifies a pagination strategy.
374
375        The value returned from this method is passed to most other methods in this class. Use it to form a request e.g: set headers or query params.
376
377        :return: The token for the next page from the input response object. Returning None means there are no more pages to read in this response.
378        """
379        return self._paginator.next_page_token(
380            response=response,
381            last_page_size=last_page_size,
382            last_record=last_record,
383            last_page_token_value=last_page_token_value,
384            **page_size_override_kwargs(page_size_override),
385            **stream_slice_kwargs(self._paginator.next_page_token, stream_slice),
386        )
387
388    def _fetch_next_page(
389        self,
390        stream_slice: StreamSlice,
391        next_page_token: Optional[Mapping[str, Any]] = None,
392        page_size_override: Optional[int] = None,
393    ) -> Optional[requests.Response]:
394        return self.requester.send_request(
395            path=self._paginator_path(
396                next_page_token=next_page_token,
397                stream_slice=stream_slice,
398            ),
399            stream_state={},  # stream_state as an interpolation context is deprecated
400            stream_slice=stream_slice,
401            next_page_token=next_page_token,
402            request_headers=self._request_headers(
403                stream_slice=stream_slice,
404                next_page_token=next_page_token,
405                **page_size_override_kwargs(page_size_override),
406            ),
407            request_params=self._request_params(
408                stream_slice=stream_slice,
409                next_page_token=next_page_token,
410                **page_size_override_kwargs(page_size_override),
411            ),
412            request_body_data=self._request_body_data(
413                stream_slice=stream_slice,
414                next_page_token=next_page_token,
415                **page_size_override_kwargs(page_size_override),
416            ),
417            request_body_json=self._request_body_json(
418                stream_slice=stream_slice,
419                next_page_token=next_page_token,
420                **page_size_override_kwargs(page_size_override),
421            ),
422            log_formatter=self.log_formatter,
423        )
424
425    # This logic is similar to _read_pages in the HttpStream class. When making changes here, consider making changes there as well.
426    def _read_pages(
427        self,
428        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
429        stream_slice: StreamSlice,
430    ) -> Iterable[Record]:
431        original_stream_slice = stream_slice
432        pagination_tracker = self.pagination_tracker_factory()
433        page_size_reducer = (
434            PageSizeReducer(
435                self.page_size_reduction,
436                self._paginator.get_page_size(),
437                stream_name=self.name,
438            )
439            if self.page_size_reduction
440            else None
441        )
442        reset_pagination = False
443        reduce_page_size = False
444        next_page_token = self._get_initial_next_page_token()
445        while True:
446            page_size_override = page_size_reducer.page_size_override if page_size_reducer else None
447            merged_records: MutableMapping[str, Any] = defaultdict(dict)
448            last_page_size = 0
449            last_record: Optional[Record] = None
450
451            response = None
452            try:
453                if self.additional_query_properties:
454                    for (
455                        properties
456                    ) in self.additional_query_properties.get_request_property_chunks():
457                        stream_slice = StreamSlice(
458                            partition=stream_slice.partition or {},
459                            cursor_slice=stream_slice.cursor_slice or {},
460                            extra_fields={"query_properties": properties},
461                        )
462                        response = self._fetch_next_page(
463                            stream_slice,
464                            next_page_token,
465                            **page_size_override_kwargs(page_size_override),
466                        )
467
468                        for current_record in records_generator_fn(response):
469                            if self.additional_query_properties.property_chunking:
470                                merge_key = self.additional_query_properties.property_chunking.get_merge_key(
471                                    current_record
472                                )
473                                if merge_key:
474                                    _deep_merge(merged_records[merge_key], current_record)
475                                else:
476                                    # We should still emit records even if the record did not have a merge key
477                                    pagination_tracker.observe(current_record)
478                                    last_page_size += 1
479                                    last_record = current_record
480                                    yield current_record
481                            else:
482                                pagination_tracker.observe(current_record)
483                                last_page_size += 1
484                                last_record = current_record
485                                yield current_record
486
487                    for merged_record in merged_records.values():
488                        record = Record(
489                            data=merged_record, stream_name=self.name, associated_slice=stream_slice
490                        )
491                        pagination_tracker.observe(record)
492                        last_page_size += 1
493                        last_record = record
494                        yield record
495                else:
496                    response = self._fetch_next_page(
497                        stream_slice,
498                        next_page_token,
499                        **page_size_override_kwargs(page_size_override),
500                    )
501                    for current_record in records_generator_fn(response):
502                        pagination_tracker.observe(current_record)
503                        last_page_size += 1
504                        last_record = current_record
505                        yield current_record
506            except PaginationResetRequiredException:
507                reset_pagination = True
508            except PageSizeReductionRequiredException as exception:
509                if page_size_reducer is None:
510                    # The action can be attached to a requester we cannot validate at config time, such as one
511                    # built by a custom error handler or a CustomRequester. Re-raise as the misconfiguration it
512                    # is: the exception being handled is the neutral "the API asked for a smaller page" signal.
513                    raise PageSizeReductionNotSupportedException(stream_name=self.name)
514                if last_page_size:
515                    # The reduction is safe only because it re-issues a page whose records were not emitted.
516                    # Every in-CDK way of reaching this raises from `_fetch_next_page`, before the record loop,
517                    # and the factory rejects the manifest constructs that would not, but a custom extractor,
518                    # filter or transformation can issue its own request from inside the record generator.
519                    # Re-issuing the page then duplicates the records already yielded, so fail instead.
520                    raise AirbyteTracedException(
521                        internal_message=f"Stream {self.name} requested a page size reduction after {last_page_size} records of the page had already been emitted",
522                        message=f"Stream {self.name} asked for a smaller page size in the middle of a page. The page cannot be requested again without duplicating the records already read from it. Move the REDUCE_PAGE_SIZE action to the error handler of the stream's main requester.",
523                        failure_type=FailureType.config_error,
524                    )
525                # Raises once the page size cannot be reduced any further and the retries allowed at that
526                # floor are spent, which is what stops the loop when the API keeps failing.
527                page_size_reducer.reduce(exception.error_message)
528                reduce_page_size = True
529            else:
530                if page_size_reducer:
531                    page_size_reducer.on_successful_page()
532                if not response:
533                    break
534
535            if reduce_page_size:
536                # Retry the very same page: neither the token nor the slice change, only the page size does -
537                # and not even that once the reducer is at its floor and only waiting is left.
538                reduce_page_size = False
539                continue
540
541            if reset_pagination or pagination_tracker.has_reached_limit():
542                next_page_token = self._get_initial_next_page_token()
543                previous_slice = stream_slice
544                stream_slice = pagination_tracker.reduce_slice_range_if_possible(
545                    stream_slice, original_stream_slice
546                )
547                LOGGER.info(
548                    f"Hitting PaginationReset event. StreamSlice used will go from {previous_slice} to {stream_slice}"
549                )
550                reset_pagination = False
551            else:
552                last_page_token_value = (
553                    next_page_token.get("next_page_token") if next_page_token else None
554                )
555                next_page_token = self._next_page_token(
556                    response=response,  # type:ignore # we are breaking from the loop on the try/else if there are no response so this should be fine
557                    last_page_size=last_page_size,
558                    last_record=last_record,
559                    last_page_token_value=last_page_token_value,
560                    **page_size_override_kwargs(page_size_override),
561                    **stream_slice_kwargs(self._next_page_token, stream_slice),
562                )
563                if response is not None and self.page_observer:
564                    self.page_observer(
565                        PageCompleted(
566                            stream_slice=stream_slice,
567                            response=response,
568                            pagination_exhausted=not next_page_token,
569                        )
570                    )
571                if not next_page_token:
572                    break
573
574        # Always return an empty generator just in case no records were ever yielded
575        yield from []
576
577    def _get_initial_next_page_token(self) -> Optional[Mapping[str, Any]]:
578        initial_token = self._paginator.get_initial_token()
579        next_page_token = {"next_page_token": initial_token} if initial_token is not None else None
580        return next_page_token
581
582    def read_records(
583        self,
584        records_schema: Mapping[str, Any],
585        stream_slice: Optional[StreamSlice] = None,
586    ) -> Iterable[StreamData]:
587        """
588        Fetch a stream's records from an HTTP API source
589
590        :param records_schema: json schema to describe record
591        :param stream_slice: The stream slice to read data for
592        :return: The records read from the API source
593        """
594        _slice = stream_slice or StreamSlice(partition={}, cursor_slice={})  # None-check
595        yield from self._read_records_or_split_request_window(
596            records_schema, _slice, _slice, depth=0
597        )
598
599    def _read_records_or_split_request_window(
600        self,
601        records_schema: Mapping[str, Any],
602        stream_slice: StreamSlice,
603        original_slice: StreamSlice,
604        depth: int,
605    ) -> Iterable[StreamData]:
606        """
607        Read `stream_slice` to completion, replacing it with smaller children and recursing into each of them in
608        turn when a `RequestWindowSplitRequiredException` is raised while reading it.
609
610        Each recursive call re-enters this method - and, through it, `_read_pages` - from scratch for the child
611        slice it is given, so every child gets its own paginator token, `PaginationTracker`, and
612        `PageSizeReducer` for free: nothing here needs to reset that state explicitly. Because the whole
613        recursion lives inside the single generator `DeclarativePartition.read()` consumes,
614        `PartitionReader.process_partition()` only calls `cursor.close_partition()` once, after this generator is
615        fully exhausted - so a failure anywhere in the recursion (a child that cannot be read, or a window that
616        cannot be split any further) propagates out without ever checkpointing the original partition.
617
618        `original_slice` is threaded through the recursion unchanged so every record yielded, however deep the
619        recursion went to produce it, is re-stamped with the partition's own slice rather than the child slice it
620        was actually read against - see `_reassociate_with_original_slice`. `depth` bounds the recursion
621        independently of `request_window_splitter`'s own no-progress guard: it is enforced here so a
622        misbehaving custom cursor cannot recurse indefinitely regardless of what that guard does or does not
623        catch.
624        """
625        record_generator = partial(
626            self._parse_records,
627            stream_slice=stream_slice,
628            records_schema=records_schema,
629        )
630
631        emitted_count = 0
632        try:
633            records: Iterable[Mapping[str, Any]] = self._read_pages(record_generator, stream_slice)
634            if self.post_pagination_filter:
635                # A data feed paginates until it reaches a record older than the cursor, so the page that triggers the stop
636                # condition still holds already-synced records. Those are filtered here rather than in the record selector
637                # so that the paginator keeps seeing the whole page: the stop condition is evaluated on the last record of
638                # the page, which is precisely one of the records being dropped. Two consequences of filtering this late:
639                # the pagination tracker observes the dropped records, and a `file_uploader` on the record selector has
640                # already uploaded their files by the time they are dropped.
641                records = self.post_pagination_filter.filter_records(
642                    records,
643                    # the filter is only used for its cursor comparison, which does not read the stream state
644                    stream_state={},
645                    stream_slice=stream_slice,
646                )
647            for record in records:
648                emitted_count += 1
649                yield self._reassociate_with_original_slice(record, original_slice)
650        except RequestWindowSplitRequiredException as exception:
651            if self.request_window_splitting is None or self.request_window_splitter is None:
652                raise RequestWindowSplitNotSupportedException(stream_name=self.name) from exception
653
654            if emitted_count:
655                # Splitting and re-reading the window re-emits these records: a failed partition is never
656                # checkpointed, so the next attempt would re-emit them anyway. Always replaying makes forward
657                # progress instead of retrying the same oversized window forever; primary-key dedup downstream
658                # handles the duplicates either way.
659                LOGGER.warning(
660                    f"Stream {self.name} already emitted {emitted_count} record(s) from {stream_slice} before "
661                    f"it was rejected; splitting and re-reading the window may re-emit them."
662                )
663
664            rejection = f": {exception.error_message}" if exception.error_message else ""
665            if depth >= _MAX_REQUEST_WINDOW_SPLIT_DEPTH:
666                # Logged separately from the exception below so it stays visible even if the trace message's
667                # internal_message isn't surfaced by whatever catches it.
668                LOGGER.warning(
669                    f"Stream {self.name} hit the maximum request window split depth "
670                    f"({_MAX_REQUEST_WINDOW_SPLIT_DEPTH}) while splitting {original_slice}."
671                )
672                raise AirbyteTracedException(
673                    internal_message=f"Stream {self.name} exceeded the maximum request window split depth of {_MAX_REQUEST_WINDOW_SPLIT_DEPTH} while splitting {original_slice}; the API still rejected {stream_slice}{rejection}",
674                    # `transient_error`, so the only remediation is the connector's own, if it defined one.
675                    message=self._with_request_window_failure_message(
676                        f"Stream {self.name} could not split its request window to a size the API accepts "
677                        f"within {_MAX_REQUEST_WINDOW_SPLIT_DEPTH} splits."
678                    ),
679                    failure_type=FailureType.transient_error,
680                ) from exception
681
682            children = self.request_window_splitter(
683                stream_slice, self.request_window_splitting.min_split_window
684            )
685            if children is None:
686                min_split_window_note = (
687                    f", its configured `min_split_window` ({self.request_window_splitting.min_split_window})"
688                    if self.request_window_splitting.min_split_window
689                    else ""
690                )
691                raise AirbyteTracedException(
692                    internal_message=f"Stream {self.name} could not split its request window {stream_slice} any further; the API rejected it{rejection}",
693                    # `transient_error` regardless of how the triggering response was classified: exhaustion is
694                    # never the user's fault, matching `PageSizeReducer`'s equivalent exhaustion branch.
695                    message=self._with_request_window_failure_message(
696                        f"The API kept rejecting stream {self.name}'s request window even at the smallest window "
697                        f"its cursor allows{min_split_window_note}, or splitting the window further would not "
698                        f"make progress."
699                    ),
700                    failure_type=FailureType.transient_error,
701                ) from exception
702
703            LOGGER.info(
704                f"Stream {self.name}: the API rejected request window {stream_slice} (split depth {depth}); "
705                f"reducing it to {children} and reading each in turn."
706            )
707            for child in children:
708                yield from self._read_records_or_split_request_window(
709                    records_schema, child, original_slice, depth + 1
710                )
711
712    def _with_request_window_failure_message(self, message: str) -> str:
713        """
714        :return: the message followed by `request_window_splitting`'s configured `failure_message`, when it
715            defined one - mirroring `PageSizeReducer._with_failure_message`
716        """
717        assert self.request_window_splitting is not None
718        failure_message = (self.request_window_splitting.failure_message or "").strip()
719        if not failure_message:
720            return message
721        return f"{message} {failure_message}"
722
723    @staticmethod
724    def _reassociate_with_original_slice(
725        record: StreamData, original_slice: StreamSlice
726    ) -> StreamData:
727        """
728        Records read while recursing into a split child window are parsed against that child's `StreamSlice`,
729        so `RecordSelector` stamps them with the child as `associated_slice`. `DeclarativePartition.read()`
730        passes an already-built `Record` through unchanged rather than re-wrapping it, so left uncorrected the
731        child slice - not the partition's own slice - is what `ConcurrentCursor.observe()` would key its
732        per-partition bookkeeping by. `close_partition()` always looks that bookkeeping up by the partition's own
733        slice, so it would never find it, silently losing the precise most-recently-observed cursor value for a
734        split partition (recoverable in practice today only because callers of that lookup fall back to the
735        slice's end boundary when it is missing). Re-stamping every record with `original_slice` here restores
736        the same tracking a non-split read already gets, rather than relying on that fallback.
737        """
738        if isinstance(record, Record) and record.associated_slice is not original_slice:
739            return Record(
740                data=record.data,
741                stream_name=record.stream_name,
742                associated_slice=original_slice,
743                file_reference=record.file_reference,
744            )
745        return record
746
747    def _parse_records(
748        self,
749        response: Optional[requests.Response],
750        records_schema: Mapping[str, Any],
751        stream_slice: Optional[StreamSlice],
752    ) -> Iterable[Record]:
753        yield from self._parse_response(
754            response,
755            stream_slice=stream_slice,
756            records_schema=records_schema,
757        )
758
759    def must_deduplicate_query_params(self) -> bool:
760        return True
761
762    @staticmethod
763    def _to_partition_key(to_serialize: Any) -> str:
764        # separators have changed in Python 3.4. To avoid being impacted by further change, we explicitly specify our own value
765        return json.dumps(to_serialize, indent=None, separators=(",", ":"), sort_keys=True)

Retrieves records by synchronously sending requests to fetch records.

The retriever acts as an orchestrator between the requester, the record selector, the paginator, and the stream slicer.

For each stream slice, submit requests until there are no more pages of records to fetch.

This retriever currently inherits from HttpStream to reuse the request submission and pagination machinery. As a result, some of the parameters passed to some methods are unused. The two will be decoupled in a future release.

Attributes:
  • stream_name (str): The stream's name
  • stream_primary_key (Optional[Union[str, List[str], List[List[str]]]]): The stream's primary key
  • requester (Requester): The HTTP requester
  • record_selector (HttpSelector): The record selector
  • paginator (Optional[Paginator]): The paginator
  • stream_slicer (Optional[StreamSlicer]): The stream slicer
  • parameters (Mapping[str, Any]): Additional runtime parameters to be used for string interpolation
  • post_pagination_filter (Optional[ClientSideIncrementalRecordFilterDecorator]): Set for data feed streams only. Records the cursor considers already synced are dropped once pagination has observed them
  • page_size_reduction (Optional[PageSizeReduction]): How much to shrink the page size when an error handler resolves to ResponseAction.REDUCE_PAGE_SIZE. None disables page size reduction entirely. It is immutable configuration; the page size in effect lives in a PageSizeReducer that _read_pages creates per call, so the retriever and its paginator - both shared by every partition of the stream, read concurrently - stay stateless. When page_size_reduction is None no reducer is created and _read_pages keeps its previous behaviour
  • request_window_splitting (Optional[RequestWindowSplitting]): Policy applied when an error handler resolves to ResponseAction.SPLIT_REQUEST_WINDOW, or when RequestWindowSplitRequiredException is raised directly by custom code. None disables request-window splitting entirely: read_records converts the exception into RequestWindowSplitNotSupportedException instead of attempting a split it cannot perform.
  • request_window_splitter: Bound method - normally the stream's own cursor's split_request_window - asked to replace a failing StreamSlice with smaller children. None has the same effect as request_window_splitting being None.
  • page_observer (Optional[Callable[[PageCompleted], None]]): Observes completed response pages; set only on streams using SyncTokenCursor and None otherwise.
SimpleRetriever( requester: airbyte_cdk.Requester, record_selector: airbyte_cdk.sources.declarative.extractors.HttpSelector, config: Mapping[str, Any], parameters: dataclasses.InitVar[typing.Mapping[str, typing.Any]], name: str = <property object>, primary_key: Union[str, List[str], List[List[str]], NoneType] = <property object>, paginator: Optional[airbyte_cdk.sources.declarative.requesters.paginators.Paginator] = None, stream_slicer: airbyte_cdk.sources.declarative.stream_slicers.StreamSlicer = <factory>, request_option_provider: airbyte_cdk.sources.declarative.requesters.request_options.RequestOptionsProvider = <factory>, ignore_stream_slicer_parameters_on_paginated_requests: bool = False, additional_query_properties: Optional[airbyte_cdk.sources.declarative.requesters.query_properties.QueryProperties] = None, log_formatter: Optional[Callable[[requests.models.Response], Any]] = None, pagination_tracker_factory: Callable[[], airbyte_cdk.sources.declarative.retrievers.pagination_tracker.PaginationTracker] = <factory>, post_pagination_filter: Optional[airbyte_cdk.sources.declarative.extractors.record_filter.ClientSideIncrementalRecordFilterDecorator] = None, page_size_reduction: Optional[airbyte_cdk.sources.declarative.retrievers.page_size_reducer.PageSizeReduction] = None, request_window_splitting: Optional[airbyte_cdk.sources.declarative.retrievers.request_window_splitting.RequestWindowSplitting] = None, request_window_splitter: Optional[Callable[[airbyte_cdk.StreamSlice, Optional[datetime.timedelta]], Optional[List[airbyte_cdk.StreamSlice]]]] = None, page_observer: Optional[Callable[[PageCompleted], NoneType]] = None)
config: Mapping[str, Any]
parameters: dataclasses.InitVar[typing.Mapping[str, typing.Any]]
name: str
172    @property  # type: ignore
173    def name(self) -> str:
174        """
175        :return: Stream name
176        """
177        return (
178            str(self._name.eval(self.config))
179            if isinstance(self._name, InterpolatedString)
180            else self._name
181        )
Returns

Stream name

primary_key: Union[str, List[str], List[List[str]], NoneType]
353    @property  # type: ignore
354    def primary_key(self) -> Optional[Union[str, List[str], List[List[str]]]]:
355        """The stream's primary key"""
356        return self._primary_key

The stream's primary key

ignore_stream_slicer_parameters_on_paginated_requests: bool = False
log_formatter: Optional[Callable[[requests.models.Response], Any]] = None
request_window_splitter: Optional[Callable[[airbyte_cdk.StreamSlice, Optional[datetime.timedelta]], Optional[List[airbyte_cdk.StreamSlice]]]] = None
page_observer: Optional[Callable[[PageCompleted], NoneType]] = None
def read_records( self, records_schema: Mapping[str, Any], stream_slice: Optional[airbyte_cdk.StreamSlice] = None) -> Iterable[Union[Mapping[str, Any], airbyte_cdk.AirbyteMessage]]:
582    def read_records(
583        self,
584        records_schema: Mapping[str, Any],
585        stream_slice: Optional[StreamSlice] = None,
586    ) -> Iterable[StreamData]:
587        """
588        Fetch a stream's records from an HTTP API source
589
590        :param records_schema: json schema to describe record
591        :param stream_slice: The stream slice to read data for
592        :return: The records read from the API source
593        """
594        _slice = stream_slice or StreamSlice(partition={}, cursor_slice={})  # None-check
595        yield from self._read_records_or_split_request_window(
596            records_schema, _slice, _slice, depth=0
597        )

Fetch a stream's records from an HTTP API source

Parameters
  • records_schema: json schema to describe record
  • stream_slice: The stream slice to read data for
Returns

The records read from the API source

def must_deduplicate_query_params(self) -> bool:
759    def must_deduplicate_query_params(self) -> bool:
760        return True
@deprecated('This class is experimental. Use at your own risk.', category=ExperimentalClassWarning)
@dataclass
class LazySimpleRetriever(SimpleRetriever):
788@deprecated(
789    "This class is experimental. Use at your own risk.",
790    category=ExperimentalClassWarning,
791)
792@dataclass
793class LazySimpleRetriever(SimpleRetriever):
794    """
795    A retriever that supports lazy loading from parent streams.
796    """
797
798    def _read_pages(
799        self,
800        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
801        stream_slice: StreamSlice,
802    ) -> Iterable[Record]:
803        response = stream_slice.extra_fields["child_response"]
804        if response:
805            last_page_size, last_record = 0, None
806            for record in records_generator_fn(response):  # type: ignore[call-arg] # only _parse_records expected as a func
807                last_page_size += 1
808                last_record = record
809                yield record
810
811            next_page_token = self._next_page_token(
812                response,
813                last_page_size,
814                last_record,
815                None,
816                **stream_slice_kwargs(self._next_page_token, stream_slice),
817            )
818            if next_page_token:
819                yield from self._paginate(
820                    next_page_token,
821                    records_generator_fn,
822                    stream_slice,
823                )
824
825            yield from []
826        else:
827            # coderabbit detected an interesting bug/gap where if we were to not get a child_response, we
828            # might recurse forever. This might not be the case, but it is worth noting that this code path
829            # isn't comprehensively tested.
830            yield from self._read_pages(records_generator_fn, stream_slice)
831
832    def _paginate(
833        self,
834        next_page_token: Any,
835        records_generator_fn: Callable[[Optional[requests.Response]], Iterable[Record]],
836        stream_slice: StreamSlice,
837    ) -> Iterable[Record]:
838        """Handle pagination by fetching subsequent pages."""
839        pagination_complete = False
840
841        while not pagination_complete:
842            response = self._fetch_next_page(stream_slice, next_page_token)
843            last_page_size, last_record = 0, None
844
845            for record in records_generator_fn(response):  # type: ignore[call-arg] # only _parse_records expected as a func
846                last_page_size += 1
847                last_record = record
848                yield record
849
850            if not response:
851                pagination_complete = True
852            else:
853                last_page_token_value = (
854                    next_page_token.get("next_page_token") if next_page_token else None
855                )
856                next_page_token = self._next_page_token(
857                    response,
858                    last_page_size,
859                    last_record,
860                    last_page_token_value,
861                    **stream_slice_kwargs(self._next_page_token, stream_slice),
862                )
863
864                if not next_page_token:
865                    pagination_complete = True

A retriever that supports lazy loading from parent streams.

LazySimpleRetriever( requester: airbyte_cdk.Requester, record_selector: airbyte_cdk.sources.declarative.extractors.HttpSelector, config: Mapping[str, Any], parameters: dataclasses.InitVar[typing.Mapping[str, typing.Any]], name: str = <property object>, primary_key: Union[str, List[str], List[List[str]], NoneType] = <property object>, paginator: Optional[airbyte_cdk.sources.declarative.requesters.paginators.Paginator] = None, stream_slicer: airbyte_cdk.sources.declarative.stream_slicers.StreamSlicer = <factory>, request_option_provider: airbyte_cdk.sources.declarative.requesters.request_options.RequestOptionsProvider = <factory>, ignore_stream_slicer_parameters_on_paginated_requests: bool = False, additional_query_properties: Optional[airbyte_cdk.sources.declarative.requesters.query_properties.QueryProperties] = None, log_formatter: Optional[Callable[[requests.models.Response], Any]] = None, pagination_tracker_factory: Callable[[], airbyte_cdk.sources.declarative.retrievers.pagination_tracker.PaginationTracker] = <factory>, post_pagination_filter: Optional[airbyte_cdk.sources.declarative.extractors.record_filter.ClientSideIncrementalRecordFilterDecorator] = None, page_size_reduction: Optional[airbyte_cdk.sources.declarative.retrievers.page_size_reducer.PageSizeReduction] = None, request_window_splitting: Optional[airbyte_cdk.sources.declarative.retrievers.request_window_splitting.RequestWindowSplitting] = None, request_window_splitter: Optional[Callable[[airbyte_cdk.StreamSlice, Optional[datetime.timedelta]], Optional[List[airbyte_cdk.StreamSlice]]]] = None, page_observer: Optional[Callable[[PageCompleted], NoneType]] = None)