1
0
mirror of synced 2026-01-13 09:03:08 -05:00
Files
airbyte/airbyte-cdk/python/airbyte_cdk/sql/exceptions.py
Aaron ("AJ") Steers 8d6a7aa220 Python-CDK: Add CDK sql module for new MotherDuck destination (#47260)
Co-authored-by: Guen Prawiroatmodjo <guen@motherduck.com>
2024-10-22 22:09:08 -07:00

223 lines
7.4 KiB
Python

# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
"""All exceptions used in Airbyte.
This design is modeled after structlog's exceptions, in that we bias towards auto-generated
property prints rather than sentence-like string concatenation.
E.g. Instead of this:
> `Subprocess failed with exit code '1'`
We do this:
> `Subprocess failed. (exit_code=1)`
The benefit of this approach is that we can easily support structured logging, and we can
easily add new properties to exceptions without having to update all the places where they
are raised. We can also support any arbitrary number of properties in exceptions, without spending
time on building sentence-like string constructions with optional inputs.
In addition, the following principles are applied for exception class design:
- All exceptions inherit from a common base class.
- All exceptions have a message attribute.
- The first line of the docstring is used as the default message.
- The default message can be overridden by explicitly setting the message attribute.
- Exceptions may optionally have a guidance attribute.
- Exceptions may optionally have a help_url attribute.
- Rendering is automatically handled by the base class.
- Any helpful context not defined by the exception class can be passed in the `context` dict arg.
- Within reason, avoid sending PII to the exception constructor.
- Exceptions are dataclasses, so they can be instantiated with keyword arguments.
- Use the 'from' syntax to chain exceptions when it is helpful to do so.
E.g. `raise AirbyteConnectorNotFoundError(...) from FileNotFoundError(connector_path)`
- Any exception that adds a new property should also be decorated as `@dataclass`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from textwrap import indent
from typing import Any
NEW_ISSUE_URL = "https://github.com/airbytehq/airbyte/issues/new/choose"
DOCS_URL_BASE = "https://https://docs.airbyte.com/"
DOCS_URL = f"{DOCS_URL_BASE}/airbyte.html"
VERTICAL_SEPARATOR = "\n" + "-" * 60
# Base error class
@dataclass
class AirbyteError(Exception):
"""Base class for exceptions in Airbyte."""
guidance: str | None = None
help_url: str | None = None
log_text: str | list[str] | None = None
log_file: Path | None = None
context: dict[str, Any] | None = None
message: str | None = None
original_exception: Exception | None = None
def get_message(self) -> str:
"""Return the best description for the exception.
We resolve the following in order:
1. The message sent to the exception constructor (if provided).
2. The first line of the class's docstring.
"""
if self.message:
return self.message
return self.__doc__.split("\n")[0] if self.__doc__ else ""
def __str__(self) -> str:
"""Return a string representation of the exception."""
special_properties = [
"message",
"guidance",
"help_url",
"log_text",
"context",
"log_file",
"original_exception",
]
display_properties = {
k: v for k, v in self.__dict__.items() if k not in special_properties and not k.startswith("_") and v is not None
}
display_properties.update(self.context or {})
context_str = "\n ".join(f"{str(k).replace('_', ' ').title()}: {v!r}" for k, v in display_properties.items())
exception_str = (
f"{self.get_message()} ({self.__class__.__name__})" + VERTICAL_SEPARATOR + f"\n{self.__class__.__name__}: {self.get_message()}"
)
if self.guidance:
exception_str += f"\n {self.guidance}"
if self.help_url:
exception_str += f"\n More info: {self.help_url}"
if context_str:
exception_str += "\n " + context_str
if self.log_file:
exception_str += f"\n Log file: {self.log_file.absolute()!s}"
if self.log_text:
if isinstance(self.log_text, list):
self.log_text = "\n".join(self.log_text)
exception_str += f"\n Log output: \n {indent(self.log_text, ' ')}"
if self.original_exception:
exception_str += VERTICAL_SEPARATOR + f"\nCaused by: {self.original_exception!s}"
return exception_str
def __repr__(self) -> str:
"""Return a string representation of the exception."""
class_name = self.__class__.__name__
properties_str = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items() if not k.startswith("_"))
return f"{class_name}({properties_str})"
def safe_logging_dict(self) -> dict[str, Any]:
"""Return a dictionary of the exception's properties which is safe for logging.
We avoid any properties which could potentially contain PII.
"""
result = {
# The class name is safe to log:
"class": self.__class__.__name__,
# We discourage interpolated strings in 'message' so that this should never contain PII:
"message": self.get_message(),
}
safe_attrs = ["connector_name", "stream_name", "violation", "exit_code"]
for attr in safe_attrs:
if hasattr(self, attr):
result[attr] = getattr(self, attr)
return result
# Airbyte Internal Errors (these are probably bugs)
@dataclass
class AirbyteInternalError(AirbyteError):
"""An internal error occurred in Airbyte."""
guidance = "Please consider reporting this error to the Airbyte team."
help_url = NEW_ISSUE_URL
# Airbyte Input Errors (replaces ValueError for user input)
@dataclass
class AirbyteInputError(AirbyteError, ValueError):
"""The input provided to Airbyte did not match expected validation rules.
This inherits from ValueError so that it can be used as a drop-in replacement for
ValueError in the Airbyte API.
"""
guidance = "Please check the provided value and try again."
help_url = DOCS_URL
input_value: str | None = None
# Normalization Errors
@dataclass
class AirbyteNameNormalizationError(AirbyteError, ValueError):
"""Error occurred while normalizing a table or column name."""
guidance = "Please consider renaming the source object if possible, or " "raise an issue in GitHub if not."
help_url = NEW_ISSUE_URL
raw_name: str | None = None
normalization_result: str | None = None
@dataclass
class AirbyteConnectorError(AirbyteError):
"""Error when running the connector."""
connector_name: str | None = None
def __post_init__(self) -> None:
"""Set the log file path for the connector."""
self.log_file = self._get_log_file()
if not self.guidance and self.log_file:
self.guidance = "Please review the log file for more information."
def _get_log_file(self) -> Path | None:
"""Return the log file path for the connector."""
if self.connector_name:
logger = logging.getLogger(f"airbyte.{self.connector_name}")
log_paths: list[Path] = [
Path(handler.baseFilename).absolute() for handler in logger.handlers if isinstance(handler, logging.FileHandler)
]
if log_paths:
return log_paths[0]
return None
@dataclass
class AirbyteStreamNotFoundError(AirbyteConnectorError):
"""Connector stream not found."""
stream_name: str | None = None
available_streams: list[str] | None = None