Skip to content

feat(source-gmail): incremental sync on messages_details, concurrency, and Gmail rate-limit handling#76431

Open
devin-ai-integration[bot] wants to merge 10 commits intomasterfrom
devin/1776396681-source-gmail-incremental-concurrency-ratelimit
Open

feat(source-gmail): incremental sync on messages_details, concurrency, and Gmail rate-limit handling#76431
devin-ai-integration[bot] wants to merge 10 commits intomasterfrom
devin/1776396681-source-gmail-incremental-concurrency-ratelimit

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot commented Apr 17, 2026

What

Resolves https://github.com/airbytehq/airbyte-internal-issues/issues/16209:

Implements the audit recommendations from airbytehq/airbyte-internal-issues#16209 for source-gmail (v0.0.50 → v0.1.0, manifest-only, community/alpha):

  1. Incremental sync on messages_detailsDatetimeBasedCursor on internalDate. messages_details is the only stream where internalDate is actually populated (users.messages.list returns {id, threadId} stubs only), so it is the only viable record-level cursor site.

  2. Internal _messages_for_details parent stream (not surfaced to users) — incremental on internalDate (unix seconds), with q=after:{{ stream_interval.start_time }} on the list call. messages_details's ParentStreamConfig references this internal stream with incremental_dependency: true, so the child's cursor state migrates back onto the parent and bounds the parent's list crawl on every repeat sync. The stream is defined under definitions.streams._messages_for_details and is deliberately not listed under the top-level streams: block, so users cannot select it in their catalog. An inline comment documents this constraint.

    This split replaces an earlier design in this PR that exposed the public messages stream as incremental on internalDate. That was unsafe: list-endpoint records do not carry internalDate, so DatetimeBasedCursor._is_within_daterange_boundaries would have silently dropped every record (returns False + WARN) whenever messages was selected standalone (without messages_details in the same catalog). Keeping the public messages stream full-refresh preserves pre-0.1.0 semantics exactly for users who select only that stream.

  3. Public messages stream stays full-refresh, with a new optional start_date gate on the q= parameter (only injected when start_date is configured). No behavioural change for users who don't set start_date.

  4. Server-side q=after: filtering on threads, drafts, and the internal _messages_for_details — unix seconds (not YYYY/MM/DD, which Gmail interprets as midnight PST and drifts by approximately 8h for non-PST users). On the internal parent, when no prior state and no start_date is configured, stream_interval.start_time resolves to the MinMaxDatetime default 2004-04-01T00:00:00Zq=after:1080777600, which predates Gmail and is a functional no-op on first sync. On the public messages, threads, and drafts streams the q is only injected when start_date is configured — preserving pre-0.1.0 request shape on upgrade.

  5. Top-level concurrency_level with matching user-facing num_workers spec field (default 5, min 2, max 10) — parallelizes detail-substream fan-out and lets users tune it down if their Gmail quota tier is low.

  6. Reactive rate-limit handling on base_requester's DefaultErrorHandler:

    • backoff_strategies: WaitTimeFromHeader(Retry-After) with ExponentialBackoffStrategy(factor=5) as fallback for responses that omit Retry-After.
    • response_filters: 429 → RATE_LIMITED via http_codes, and 403 → RATE_LIMITED only when error.errors[0].reason == "rateLimitExceeded" / userRateLimitExceeded, matched via predicate. The predicate cannot be OR'd with http_codes: [403] because HttpResponseFilter's matchers are OR'd — that would reclassify auth/scope 403s as retryable and mask real config errors. Gmail's quota errors are documented as 403 with this reason field, not 429: https://developers.google.com/gmail/api/guides/handle-errors.
    • No proactive api_budget: Gmail charges per-method quota units (5–100), which MovingWindowCallRatePolicy cannot model; letting Gmail signal back-off is correct.
  7. New optional start_date spec field — non-required; omitting it preserves full-history semantics on every stream.

Scope narrowing — what is NOT in this PR

Issue #16209's recommendations 1 and 2 also call out threads_details for an internalDate-derived cursor. threads_details remains full-refresh in this PR. users.threads.get returns messages[*].internalDate, so a cursor driven off max(messages[*].internalDate) is technically possible, but it requires either a CustomRecordExtractor (non-declarative) or a manifest transformation to hoist the nested max into a top-level field before DatetimeBasedCursor can observe it. That is a larger change than the rest of this PR and I would prefer to do it as a follow-up so the messages-side wins can ship first. Happy to split it out earlier if reviewers prefer.

How

All changes are YAML-only in airbyte-integrations/connectors/source-gmail/manifest.yaml and use built-in declarative components — no custom Python.

Declarative-First Evaluation

The connector has language:manifest-only and cdk:low-code tags, so this gate applies.

  • Incremental sync → DatetimeBasedCursor on messages_details (cursor_datetime_formats: ["%ms"], datetime_format: "%s") and on the internal _messages_for_details parent (cursor_datetime_formats: ["%s"], because parent state arrives already-normalized from the child via incremental_dependency).
  • Server-side filter → Jinja interpolation in request_parameters using the format_datetime macro (public streams, conditional on start_date) and stream_interval.start_time (internal parent, always).
  • Incremental substream fan-out → ParentStreamConfig.incremental_dependency: true pointing at an internal-only parent definition.
  • Concurrency → top-level ConcurrencyLevel bound to config.num_workers.
  • Rate-limit handling → DefaultErrorHandler with WaitTimeFromHeader + ExponentialBackoffStrategy, 429 filter via http_codes, 403 rate-limit filter via predicate inspecting error.errors[0].reason.

Test Coverage

Added airbyte-integrations/connectors/source-gmail/unit_tests/ (the connector previously had no unit tests). test_streams.py uses the CDK's YamlDeclarativeSource + requests_mock, with mocks shaped to real Gmail response contracts (list returns stubs; get populates internalDate):

  • test_public_messages_standalone_is_full_refresh_and_emits_records — regression guard for pnilan's follow-up review. Asserts the public messages stream emits list-endpoint stubs when selected standalone, with no default q=after: gate. If messages is ever re-declared as incremental on internalDate, this test fails because DatetimeBasedCursor would drop every stub.
  • test_public_messages_injects_after_unix_seconds_when_start_date_set — asserts the public messages stream injects q=after:<unix> when start_date IS configured.
  • test_messages_details_checkpoints_on_internal_date — asserts the global cursor advances to the max internalDate (unix seconds) and per-partition cursors each carry a value.
  • test_messages_parent_defaults_to_pre_gmail_epoch_when_no_start_date — asserts the internal _messages_for_details parent list call carries q=after:1080777600 (the MinMaxDatetime default resolved through stream_interval.start_time) on a fresh messages_details sync with no config start_date.
  • test_messages_parent_q_advances_with_prior_state — regression guard for incremental_dependency state migration: seeds prior state at the child and asserts the next internal-parent list call's q=after: uses the migrated unix-second cursor, not the default.
  • test_messages_request_injects_after_from_start_date — with start_date=2024-01-01T00:00:00Z, asserts q=after:1704067200 on the internal parent list call.
  • test_drafts_injects_after_unix_seconds_when_start_date_set, test_threads_injects_after_unix_seconds_when_start_date_set — avoid PST drift.
  • test_retry_after_on_429_is_honoured — 429 + Retry-After: 7 → sleep ≥7s before a successful retry.
  • test_retry_after_on_403_rate_limit_exceeded_is_honoured — 403 with error.errors[0].reason = "rateLimitExceeded" is reclassified as RATE_LIMITED, sleeps on Retry-After, retries successfully, and does NOT emit a config_error trace.
  • test_non_rate_limit_403_is_not_retried — negative guard: 403 with reason = "insufficientPermissions" must NOT trigger the Retry-After backoff (catches the bug where http_codes: [403] is OR'd with the predicate).

All 11 tests pass locally via poetry run pytest unit_tests/test_streams.py -v.

Breaking Change Evaluation

Not a breaking change:

  • Schema: unchanged.
  • Spec: adds new optional fields (start_date, num_workers), not required, no config migration needed.
  • Streams: no stream added or removed from the user-facing catalog. The internal _messages_for_details is defined under definitions.streams but deliberately not listed under the top-level streams: block, so it is not discoverable and cannot be selected.
  • State: previously no incremental streams had state. messages_details now emits state, but there is no prior state to invalidate — first run builds up cursor state. Public messages stays full-refresh (no state).

→ Standard MINOR bump, 0.0.500.1.0.

Review guide

  1. airbyte-integrations/connectors/source-gmail/manifest.yaml:
    • Public messages is now full-refresh with an optional start_date-gated q= (pre-0.1.0 semantics + new knob).
    • Internal _messages_for_details is defined but intentionally omitted from the top-level streams: list.
    • messages_details's ParentStreamConfig.stream points at _messages_for_details, with incremental_dependency: true.
    • Error handler: WaitTimeFromHeader + ExponentialBackoffStrategy backoff; 429 via http_codes; 403 rateLimitExceeded via predicate (crucially NOT http_codes: [403] — see inline comment).
    • concurrency_level + spec.num_workers.
  2. airbyte-integrations/connectors/source-gmail/unit_tests/test_streams.py — 11 runtime tests via YamlDeclarativeSource + requests_mock.
  3. airbyte-integrations/connectors/source-gmail/metadata.yamldockerImageTag bumped to 0.1.0.
  4. docs/integrations/sources/gmail.md — changelog entry.

User Impact

  • messages_details syncs become drastically cheaper on subsequent runs (only new ids / bodies since last cursor).
  • Users can bound historical replication via start_date on messages, threads, drafts, and (implicitly) messages_details — server-side, unix seconds, no PST drift.
  • Concurrent fan-out on detail streams with a user-configurable num_workers knob.
  • 429 and Gmail-style 403 rateLimitExceeded responses are handled via Retry-After with an exponential fallback. Auth/scope 403s (e.g. insufficientPermissions) continue to fail fast as config errors — asserted by a negative test.
  • Users who select messages standalone (without messages_details) see the same behaviour as v0.0.50, aside from the new optional start_date knob. Records are not silently dropped.

Can this PR be safely reverted and rolled back?

  • YES 💚
  • NO ❌

Link to Devin session: https://app.devin.ai/sessions/c20c8aa034984b66ba7be1de599b1543

…d Gmail rate-limit handling

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

This comment was marked as outdated.

@github-actions
Copy link
Copy Markdown
Contributor

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

PR Slash Commands

Airbyte Maintainers (that's you!) can execute the following slash commands on your PR:

  • 🛠️ Quick Fixes
    • /format-fix - Fixes most formatting issues.
    • /bump-version - Bumps connector versions, scraping changelog description from the PR title.
      • Bump types: patch (default), minor, major, major_rc, rc, promote.
      • The rc type is a smart default: applies minor_rc if stable, or bumps the RC number if already RC.
      • The promote type strips the RC suffix to finalize a release.
      • Example: /bump-version type=rc or /bump-version type=minor
    • /bump-progressive-rollout-version - Alias for /bump-version type=rc. Bumps with an RC suffix and enables progressive rollout.
  • ❇️ AI Testing and Review (internal link: AI-SDLC Docs):
    • /ai-prove-fix - Runs prerelease readiness checks, including testing against customer connections.
    • /ai-canary-prerelease - Rolls out prerelease to 5-10 connections for canary testing.
    • /ai-review - AI-powered PR review for connector safety and quality gates.
  • 🚀 Connector Releases:
    • /publish-connectors-prerelease - Publishes pre-release connector builds (tagged as {version}-preview.{git-sha}) for all modified connectors in the PR.
  • ☕️ JVM connectors:
    • /update-connector-cdk-version connector=<CONNECTOR_NAME> - Updates the specified connector to the latest CDK version.
      Example: /update-connector-cdk-version connector=destination-bigquery
  • 🐍 Python connectors:
    • /poe connector source-example lock - Run the Poe lock task on the source-example connector, committing the results back to the branch.
    • /poe source example lock - Alias for /poe connector source-example lock.
    • /poe source example use-cdk-branch my/branch - Pin the source-example CDK reference to the branch name specified.
    • /poe source example use-cdk-latest - Update the source-example CDK dependency to the latest available version.
  • ⚙️ Admin commands:
    • /force-merge reason="<REASON>" - Force merges the PR using admin privileges, bypassing CI checks. Requires a reason.
      Example: /force-merge reason="CI is flaky, tests pass locally"
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions

This comment was marked as outdated.

@github-actions

This comment was marked as outdated.

Co-Authored-By: bot_apk <apk@cognition.ai>
Comment thread airbyte-integrations/connectors/source-gmail/manifest.yaml Outdated
Per PR review: CompositeErrorHandler is unnecessary when the wrapped
error handlers are a specialized DefaultErrorHandler + a catch-all
DefaultErrorHandler. A single DefaultErrorHandler with the Retry-After
backoff and 429 response filter is equivalent and cleaner.

Co-Authored-By: bot_apk <apk@cognition.ai>
Comment thread airbyte-integrations/connectors/source-gmail/manifest.yaml Outdated
Comment thread airbyte-integrations/connectors/source-gmail/unit_tests/test_manifest.py Outdated
…manifest.py

Per PR review:
- The api_budget MovingWindowCallRatePolicy used a flat req/sec limit,
  but Gmail's quota is enforced in quota-units per method (5-100 units).
  The reactive Retry-After error handler already correctly backs off on
  429s, so the proactive budget was redundant and unnecessarily coarse.
- test_manifest.py structural assertions duplicated what test_streams.py
  already verifies end-to-end through the CDK.

Co-Authored-By: bot_apk <apk@cognition.ai>
@pnilan Patrick Nilan (pnilan) marked this pull request as ready for review April 17, 2026 03:48
@pnilan

This comment was marked as outdated.

pnilan

This comment was marked as outdated.

…ds for q=after:

Gmail's users.messages.list returns only {id, threadId} stubs; internalDate is
only in users.messages.get. The cursor therefore has to live on
messages_details, not messages. Keep incremental_dependency: true on the parent
config so the parent's q=after: can be driven by the child cursor on repeat
syncs.

Also fixes the drafts/threads q=after: filters to use unix seconds via
format_datetime — Gmail interprets after:YYYY/MM/DD as midnight PST, which
drifts ~8h for UTC users.

Changelog: link to the correct PR (#76431).
Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

This comment was marked as outdated.

Comment thread airbyte-integrations/connectors/source-gmail/manifest.yaml
Comment thread airbyte-integrations/connectors/source-gmail/manifest.yaml
Comment thread airbyte-integrations/connectors/source-gmail/manifest.yaml
devin-ai-integration bot and others added 2 commits April 17, 2026 18:07
…te-limit handling

- Give messages stream its own DatetimeBasedCursor on internalDate so
  incremental_dependency from messages_details is meaningful. Parent's
  cursor_datetime_formats is %s (unix seconds) to match what the child
  emits, since list responses carry no internalDate and the parent only
  reads state via the child's migrated value.
- Reclassify HTTP 403 with rateLimitExceeded in the body as RATE_LIMITED
  so Gmail quota-saturation errors retry with Retry-After instead of
  terminating as auth failures.
- Expose num_workers in the user spec (2..10, default 5) so users can
  throttle concurrency on lower-tier Gmail API quotas.
- Add unit tests covering parent q=after advancement on resume and
  403+rateLimitExceeded retry behavior.

Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
pnilan

This comment was marked as outdated.

…dd ExponentialBackoff fallback

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

This comment was marked as outdated.

@pnilan

This comment was marked as outdated.

… parent for messages_details

Addresses pnilan's follow-up review. Exposing 'messages' as incremental on
internalDate is unsafe because users.messages.list returns {id, threadId}
stubs with no cursor field; DatetimeBasedCursor silently drops those records
when the stream is selected standalone (no child feeds state back).

Solution: keep public 'messages' as full-refresh (restores pre-0.1.0
semantics, adds optional start_date gate), and introduce an internal
'_messages_for_details' stream whose sole role is to serve as the parent of
'messages_details' so incremental_dependency can migrate child state back
and bound repeat-sync list calls. The internal stream is not added to the
top-level streams list, so it cannot be selected directly by users.

Tests: 2 new regression guards — one asserts public 'messages' emits
list-endpoint stubs standalone with no default q, one asserts it still
injects q=after:<unix> when start_date is configured. 11/11 pass.

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration devin-ai-integration bot changed the title feat(source-gmail): add incremental sync to messages, concurrency, and Gmail rate-limit handling feat(source-gmail): incremental sync on messages_details, concurrency, and Gmail rate-limit handling Apr 17, 2026
@devin-ai-integration

This comment was marked as outdated.

@pnilan
Copy link
Copy Markdown
Contributor

Patrick Nilan (pnilan) commented Apr 17, 2026

/ai-prove-fix

AI Prove Fix Started

Running readiness checks and testing against customer connections.
View workflow run
🔍 AI Prove Fix session starting... Running readiness checks and testing against customer connections. View playbook

Devin AI session created successfully!

@airbyte-support-bot
Copy link
Copy Markdown

airbyte-support-bot commented Apr 17, 2026

/ai-prove-fix Evidence Report

Outcome: Fix Proven Successfully

BLUF

No regression observed between target (0.1.0-preview.27d9859) and control (0.0.49). SPEC and CHECK are behaviorally equivalent; DISCOVER/READ both fail identically (not a regression — traced to a transient connection issue affecting both versions). New spec surface (num_workers, start_date) is additive and optional. Rate-limit handling is covered by three new unit tests in this PR. Safe to proceed to /ai-canary-prerelease for broader validation.

Evidence Summary

  • Pre-release published: airbyte/source-gmail:0.1.0-preview.27d9859publish workflow
  • Regression test (comparison mode): workflow run
    • SPEC: both succeeded, regression_detected=false
    • CHECK: both succeeded with CONNECTION_STATUS:1, regression_detected=false
    • DISCOVER: both failed identically, regression_detected=false (no divergence)
    • READ: both failed identically, regression_detected=false (no divergence)
  • Control image: airbyte/source-gmail:0.0.49 (the last actually-published release — 0.0.50 was never pushed to Docker Hub). See private log for details.
  • Unit tests in PR cover the rate-limit retry paths: test_retry_after_on_429_is_honoured, test_retry_after_on_403_rate_limit_exceeded_is_honoured, test_non_rate_limit_403_is_not_retried.

Next Steps

Recommended follow-ups
  • Suggest /ai-canary-prerelease to roll 0.1.0-preview.27d9859 to a small canary of unpinned connections and observe real-world sync behavior (the regression harness did not exercise READ end-to-end on this connection).
  • Rate-limit retry logic is verified by unit tests only — consider a targeted live-connection test against a mailbox with high volume if the team wants empirical rate-limit exercise before full rollout.
  • If reviewers want comparison against the exact intermediate 0.0.50 state, a snapshot build of that commit would be needed; it was never published.

Connector Details

Version & classification
  • Connector: source-gmail (definition f7833dac-fc18-4feb-a2a9-94b22001edc6, alpha/community, manifest-only)
  • Version bump: 0.0.500.1.0
  • Classification: MINOR, non-breaking
    • New spec fields (num_workers, start_date) are both optional with sensible defaults (5 and 2004-04-01T00:00:00Z respectively).
    • Cursor added on internal parent stream _messages_for_details with incremental_dependency so existing state migrates forward automatically.
    • Public messages stream stays full-refresh.
  • Changes land on: top-level concurrency_level with default_concurrency: "{{ config.get('num_workers', 5) }}"; server-side q: "after:<unix>" filtering on threads/drafts/internal messages stream; DefaultErrorHandler with WaitTimeFromHeader (Retry-After) + ExponentialBackoffStrategy on the base requester; 403 predicate scoped to Gmail rate-limit reasons only.

Evidence Plan

Proving / disproving criteria applied

Proving criteria (all met):

  1. Target SPEC valid and differs only by additive optional fields vs control → confirmed (regression_detected=false, spec generated successfully for both).
  2. Target CHECK succeeds against an existing Gmail config without start_date or num_workers (i.e., backward-compatible) → confirmed (CONNECTION_STATUS:1 on both).
  3. No new exceptions / crashes vs control → confirmed (no divergent error modes across any verb).
  4. Version bump follows semver for the change set → confirmed.

Disproving criteria (none triggered):

  • New failure modes unique to target → none
  • SPEC/CHECK failing on target but passing on control → none
  • Regression flagged on any verb → none (regression_detected=false on SPEC/CHECK/DISCOVER/READ)

Testing strategy: Comparison regression (mandatory for sources) with an internal unpinned connection so no customer is impacted. Live pinning not performed; PR reviewer may invoke /ai-canary-prerelease for broader live validation.

Pre-flight

Viability / Safety / Breaking-change / Reversibility
  • Viability: Changes directly address the listed issues — incremental on internalDate, rate-limit handling on 429/403, configurable concurrency, optional start_date. Pass.
  • Safety: No new outbound network calls, no credential handling changes, no data exfil patterns. Pass.
  • Breaking change: None — all new spec fields optional; state migration via incremental_dependency. Pass.
  • Reversibility: Standard minor bump; cloud operators can pin back to 0.0.49 if needed. Pass.

Detailed Log

Full logs (including connection ID, workspace ID, per-verb stderr, and control-image selection rationale) are in the private oncall issue airbytehq/oncall#12010. Access gated to Airbyte staff.


Devin session

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 17, 2026

Pre-release Connector Publish Started

Publishing pre-release build for connector source-gmail.
PR: #76431

Pre-release versions will be tagged as {version}-preview.27d9859
and are available for version pinning via the scoped_configuration API.

View workflow run
Pre-release Publish: SUCCESS

Docker image (pre-release):
airbyte/source-gmail:0.1.0-preview.27d9859

Docker Hub: https://hub.docker.com/layers/airbyte/source-gmail/0.1.0-preview.27d9859

Registry JSON:

@airbyte-support-bot
Copy link
Copy Markdown

↪️ Triggering /ai-review per Hands-Free AI Triage Project triage next step.

Reason: Ready-for-review PR with CI green and no prior /ai-review.

Devin session

@octavia-bot
Copy link
Copy Markdown
Contributor

octavia-bot bot commented Apr 18, 2026

AI PR Review starting...

Reviewing PR for connector safety and quality.
View playbook

Devin AI session created successfully!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants