Repository navigation
feat(cloud): name conflicting destination streams and point to table_prefix on connection create
#1406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(cloud): name conflicting destination streams and point to table_prefix on connection create
#1406
Changes from all commits
9257107
023f1ea
6b69534
2fc3786
6e816e6
680eb11
aaf4b2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -26,7 +26,11 @@ | |||||
| from airbyte_api import api, models | ||||||
| from airbyte_api.errors import SDKError | ||||||
|
|
||||||
| from airbyte._util.cloud_errors import CloudErrorInfo, parse_cloud_error | ||||||
| from airbyte._util.cloud_errors import ( | ||||||
| CloudErrorInfo, | ||||||
| parse_cloud_error, | ||||||
| parse_problem_payload, | ||||||
| ) | ||||||
| from airbyte._util.meta import ( | ||||||
| AIRBYTE_CLOUD_ANALYTIC_SOURCE_HEADER, | ||||||
| get_cloud_api_analytic_source, | ||||||
|
|
@@ -1989,6 +1993,80 @@ def build_connection_schedule( | |||||
| ) | ||||||
|
|
||||||
|
|
||||||
| _MAX_CONFLICTING_DESTINATION_STREAMS = 10 | ||||||
|
|
||||||
|
|
||||||
| def _format_conflicting_destination_stream(stream: object) -> str | None: | ||||||
| if not isinstance(stream, dict): | ||||||
| return None | ||||||
| name = stream.get("streamName") | ||||||
| namespace = stream.get("streamNamespace", "") | ||||||
| connection_ids = stream.get("connectionIds") | ||||||
| if not isinstance(name, str) or not name: | ||||||
| return None | ||||||
| if namespace is None: | ||||||
| namespace = "" | ||||||
| if not isinstance(namespace, str): | ||||||
| return None | ||||||
| if not isinstance(connection_ids, list) or not connection_ids: | ||||||
| return None | ||||||
| if any(not isinstance(connection_id, str) for connection_id in connection_ids): | ||||||
| return None | ||||||
|
|
||||||
| stream_name = f"{namespace}.{name}" if namespace else name | ||||||
| rendered_ids = ", ".join(f"`{connection_id}`" for connection_id in connection_ids) | ||||||
| return f"`{stream_name}` (connection {rendered_ids})" | ||||||
|
Comment on lines
+2016
to
+2018
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Malformed stream names break conflict errors When conflict metadata contains an escaped lone surrogate, Learn moreA Cloud error body can be valid UTF-8 JSON while containing an escaped lone surrogate inside Example: The body Recommended fix: Validate Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same point as the Copilot thread on this function.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 On it. I'm reversing my earlier decline here too, with the same fix as the Copilot thread on this function: each rendered entry gets a UTF-8 encode check, an entry that fails is skipped, the generic |
||||||
|
|
||||||
|
|
||||||
| def _format_conflicting_destination_streams(body: str) -> str | None: | ||||||
| payload = parse_problem_payload(body) | ||||||
| if payload is None: | ||||||
| return None | ||||||
| data = payload.get("data") | ||||||
| streams = data.get("streams") if isinstance(data, dict) else None | ||||||
| if not isinstance(streams, list) or not streams: | ||||||
| return None | ||||||
|
|
||||||
| formatted_streams: list[str] = [] | ||||||
| for stream in streams: | ||||||
| formatted_stream = _format_conflicting_destination_stream(stream) | ||||||
| if formatted_stream is not None: | ||||||
| formatted_streams.append(formatted_stream) | ||||||
| if not formatted_streams: | ||||||
| return None | ||||||
|
|
||||||
| stream_list = ", ".join(formatted_streams[:_MAX_CONFLICTING_DESTINATION_STREAMS]) | ||||||
| if len(formatted_streams) > _MAX_CONFLICTING_DESTINATION_STREAMS: | ||||||
| remaining_stream_count = len(formatted_streams) - _MAX_CONFLICTING_DESTINATION_STREAMS | ||||||
| stream_list = f"{stream_list}, and {remaining_stream_count} more" | ||||||
| return stream_list | ||||||
|
|
||||||
|
|
||||||
| def _conflicting_destination_stream_guidance(error: SDKError) -> str | None: | ||||||
| """Explain how to avoid destination table collisions for a known Cloud problem.""" | ||||||
| body = error_response_body(error) | ||||||
| problem = parse_cloud_error(error.status_code, body) | ||||||
| if problem.slug != "error:connection-conflicting-destination-stream": | ||||||
| return None | ||||||
|
|
||||||
| generic_guidance = ( | ||||||
| "Another connection on this destination may already write to the same tables. " | ||||||
| "Set `table_prefix` (for example `<source>_`) so this connection writes to " | ||||||
| "different table names." | ||||||
| ) | ||||||
| if body is None: | ||||||
| return generic_guidance | ||||||
| stream_list = _format_conflicting_destination_streams(body) | ||||||
| if stream_list is None: | ||||||
| return generic_guidance | ||||||
|
|
||||||
| return ( | ||||||
| "Another connection on this destination already writes to these tables: " | ||||||
| f"{stream_list}. Set `table_prefix` (for example `<source>_`) so this " | ||||||
| "connection writes to different table names." | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def create_connection( # noqa: PLR0913 # Too many arguments | ||||||
| name: str, | ||||||
| *, | ||||||
|
|
@@ -2029,6 +2107,7 @@ def create_connection( # noqa: PLR0913 # Too many arguments | |||||
| if e.status_code == HTTPStatus.BAD_REQUEST: | ||||||
| raise AirbyteLibInputError( | ||||||
| message=sdk_error_message(e), | ||||||
| guidance=_conflicting_destination_stream_guidance(e), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Conflict errors expose prohibited extra guidance For a conflicting-stream 400, Learn moreCloud problem responses supply the error text through Example: A 400 conflict response with Recommended fix: Remove
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed: as written, this breaks the "Agent-facing Cloud Error Messages" rule. I've proposed adding this case to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙋 Human Input Needed: may this become a third allowed guidance case? Agreed that, as written, it breaks the rule. My proposal is to add a |
||||||
| context={**base_context, **_get_sdk_error_context(e)}, | ||||||
| ) from e | ||||||
| raise _wrap_sdk_error(e, base_context) from e | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not changing this.
data.streamscomes from Airbyte Cloud's own validation response, which is built from stream names and connection UUIDs that Cloud has already stored. We have no evidence of lone surrogates turning up there, so a guard would be speculative. Thedetail/titlepre-check exists because those are free-text fields that our general Cloud error path renders for every tool. If this ever happens, the error will surface loudly instead of being silently skipped.Written by Devin
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 On it. I'm reversing my earlier "not changing this". The guard is small and handles the nested fields the same way
parse_problem_payloadalready handlesdetail/title. Each rendered entry gets a UTF-8 encode check, and an entry that fails is skipped. If no entries are left, the generictable_prefixguidance is used. I'll add a test. This ships with the conflict-guidance rework.Written by Devin