fix(oauth): emit CONNECTOR_CONFIG once per single-use refresh token rotation - #1145
fix(oauth): emit CONNECTOR_CONFIG once per single-use refresh token rotation#1145devin-ai-integration[bot] wants to merge 4 commits into
Conversation
…otation Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@devin/1788470527-single-connector-config-emission#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch devin/1788470527-single-connector-config-emissionPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
|
/ai-review |
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is narrowly scoped and is backed by targeted unit tests; remaining feedback is limited to minor doc/test maintainability adjustments.
Pull request overview
This PR fixes duplicate CONNECTOR_CONFIG control-message emission during single-use refresh token rotation by making stdout emission unconditional while gating message-repository emission behind an explicit opt-in (used for in-process consumers like Connector Builder).
Changes:
- Add
emit_control_message_to_message_repository: bool = FalsetoSingleUseRefreshTokenOauth2Authenticatorand only emit to the repository when enabled. - Wire the new flag from
ModelToComponentFactory.create_oauth_authenticatorusing the existing Connector Builder mode toggle. - Expand unit test coverage to assert exactly one
CONNECTOR_CONFIGon stdout (and optionally one in the repository when enabled), including concurrent declarative source scenarios.
File summaries
| File | Description |
|---|---|
airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py |
Adds the opt-in flag and gates repository emission to prevent duplicate stdout control messages. |
airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py |
Passes the new flag when creating OAuth authenticators in Connector Builder mode. |
unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py |
Updates/adds tests covering stdout vs repository emission behavior under different repository/flag combinations. |
unit_tests/sources/declarative/test_concurrent_declarative_source.py |
Adds concurrent declarative read/check tests ensuring exactly one stdout config update (and correct behavior in builder mode). |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| The message is always printed to stdout, which is the delivery the platform relies on: it | ||
| is immediate and it works for every command, including `check` and `discover`, where the | ||
| message repository is never drained. The message repository is only used in addition when | ||
| explicitly requested, for in-process consumers such as the Connector Builder; repository | ||
| messages are otherwise printed by the entrypoint too and would duplicate the stdout emission. |
There was a problem hiding this comment.
👍 On it. Agreed — AirbyteEntrypoint._emit_queued_messages does drain source.message_repository on check/discover for sources that expose one. Qualifying the docstring: repository delivery depends on the source exposing/draining its repository and on when the drain happens, whereas stdout is immediate for every source and command.
There was a problem hiding this comment.
☑️ Resolved in 1e32a90. Docstring now says stdout is immediate and independent of whether/when the source's repository is drained (noting ConcurrentDeclarativeSource only drains during read), instead of claiming the repository is never drained on check/discover.
| return [ | ||
| json.loads(line) | ||
| for line in output.splitlines() | ||
| if line.strip() and json.loads(line).get("type") == "CONTROL" | ||
| ] |
There was a problem hiding this comment.
👍 On it. Will parse each line once in _connector_config_lines.
There was a problem hiding this comment.
☑️ Resolved in 1e32a90. _connector_config_lines parses each line once and filters the parsed messages.
Co-Authored-By: bot_apk <apk@cognition.ai>
PyTest Results (Fast)4 368 tests +5 4 356 ✅ +5 9m 27s ⏱️ -20s Results for commit 73c0ae3. ± Comparison against base commit 64bdfc9. This pull request removes 1 and adds 6 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
PyTest Results (Full)4 371 tests 4 359 ✅ 11m 31s ⏱️ Results for commit 73c0ae3. ♻️ This comment has been updated with latest results. |
This comment was marked as outdated.
This comment was marked as outdated.
The surviving stdout path is not interleave-safe, and it just lost its backupThe reasoning for keeping the stdout print is sound — The concern is the form of the print that this now depends on exclusively.
(
Honest read: order 1 in 10⁴–10⁵ per rotation under record-write contention, with high run-to-run variance. Rare — but the consequence is what this PR changes. Today a mangled print costs the record fused onto that line while the The CDK has fixed this exact bug class before — RecommendationOne line, and worth doing regardless of this PR since it also stops the record loss that exists on # airbyte_cdk/config_observation.py
- print(orjson.dumps(AirbyteMessageSerializer.dump(airbyte_message)).decode())
+ print(f"{orjson.dumps(AirbyteMessageSerializer.dump(airbyte_message)).decode()}\n", end="", flush=True)It just becomes load-bearing once the redundant copy is gone. Worth noting too that Note that no test in this PR can catch this: |
|
👍 On it. Re the interleave-safety comment: agreed the bare |
Co-Authored-By: bot_apk <apk@cognition.ai>
|
☑️ Resolved in 73c0ae3. |
|
/prerelease
|
Summary
Resolves https://github.com/airbytehq/airbyte-internal-issues/issues/17172:
Since #877 (first shipped in v7.6.4),
SingleUseRefreshTokenOauth2Authenticator._emit_control_message()both prints theCONNECTOR_CONFIGcontrol message to stdout and emits it to the message repository. Every declarative connector with arefresh_token_updatergets a realConcurrentMessageRepositoryfromModelToComponentFactory, and the concurrent read loop prints everything in that repository to stdout too — so each token refresh puts two identicalCONNECTOR_CONFIGmessages on stdout (observed in Cloud logs for a source-linear sync as twoEagerly persisting source connector config from CONTROL message.lines per refresh).This PR makes a refresh emit exactly one platform-facing
CONNECTOR_CONFIGmessage:def _emit_control_message(self) -> None: emit_configuration_as_airbyte_control_message(self._connector_config) # always: stdout - if not isinstance(self._message_repository, NoopMessageRepository): + if self._emit_control_message_to_message_repository and not isinstance( + self._message_repository, NoopMessageRepository + ): self._message_repository.emit_message(create_connector_config_control_message(...))SingleUseRefreshTokenOauth2Authenticator(..., emit_control_message_to_message_repository: bool = False)(appended last, so existing positional callers are unaffected).ModelToComponentFactory.create_oauth_authenticatorpassesemit_control_message_to_message_repository=self._emit_connector_builder_messages.Why keep the stdout print rather than restore the pre-#877 either/or
The direct stdout print is the delivery the platform relies on, and it is the only one that works everywhere:
ConcurrentDeclarativeSourcehas nomessage_repositoryproperty, soAirbyteEntrypoint._emit_queued_messagesnever drains it; outsideread, messages emitted into theConcurrentMessageRepositoryland on the concurrent queue and are never printed. That is whycheck/discoverlost the rotated token before fix(oauth): exclude client credentials from body when Authorization header is present and _emit_control_message #877 (the Gong case that motivated it). Restoring the either/or would reintroduce that regression.read, the repository path is only printed once the main thread reaches the message in the queue (behind already-queued records); if the sync aborts first, the rotated single-use token is lost. The worker-thread stdout print is immediate.The message-repository emission's in-process consumers are the Connector Builder test read (
connector_builder/test_reader/reader.py) and the manifest servertest_read, both of which consumeAirbyteEntrypoint.readin-process and surfacelatest_config_update; both run the factory withemit_connector_builder_messages=True. It is therefore kept as opt-in and enabled only in that mode. If you constructSingleUseRefreshTokenOauth2Authenticatordirectly and read theCONNECTOR_CONFIGmessage off your ownMessageRepository, passemit_control_message_to_message_repository=True.Hardening the stdout path
Since the direct print is now the sole default delivery,
emit_configuration_as_airbyte_control_messagewrites the payload and newline in a singlewrite()call (print(f"{line}\n", end="", flush=True), the form already used inhttp_client.pyandentrypoint.py). The bareprint(x)issued twowrite()calls andPrintBufferonly locks per call, so a record written from the main thread could split the JSON line emitted from a worker thread. The deprecation note on that function is replaced with an explanation of why token rotation relies on it.Caveat
The duplicate emission is a genuine CDK defect (it doubles
updateSourcewrites per refresh), but it is not proven to be what breaks source-linear token persistence: another connector with the same duplicate emission persists its rotated token fine (https://github.com/airbytehq/oncall/issues/13432). The platform side (each source CONTROL message also being published twice) is tracked separately in airbyte-platform-internal and is not touched here.Test coverage
unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py:NoopMessageRepository→ exactly oneCONNECTOR_CONFIGon stdoutInMemoryMessageRepository→ exactly one on stdout, none in the repository (fails onmain: oneCONTROLmessage remains queued)True+InMemoryMessageRepository→ one on stdout and one in the repositoryTrue+NoopMessageRepository→ one on stdout, no errorunit_tests/sources/declarative/test_concurrent_declarative_source.py, with a manifest declaringrefresh_token_updaterandHttpMocker:readyields noCONTROLmessage and stdout has exactly oneCONNECTOR_CONFIGcarrying the rotated refresh token (fails onmain: aCONTROLmessage is yielded, i.e. printed a second time by the entrypoint)readwithemit_connector_builder_messages=Trueyields exactly oneCONTROLmessage and stdout has exactly onechecksucceeds and stdout has exactly oneCONNECTOR_CONFIGRan
poetry run ruff format .,poetry run ruff check .,poetry run mypy --config-file mypy.ini airbyte_cdk, andpoetry run pytest unit_tests/ -x -q(4369 passed, 2 skipped).Declarative-First Evaluation
Not applicable: the fix is in the CDK itself; source-linear needs no manifest change.
Link to Devin session: https://app.devin.ai/sessions/c578c8af1b6441b78d57e3edc3a2f5b9
Open in Devin Desktop: https://app.devin.ai/desktop/session/c578c8af1b6441b78d57e3edc3a2f5b9?variant=devin