diff --git a/etl/hubspot/deal_contacts_client.py b/etl/hubspot/deal_contacts_client.py index 6765b5da5..fe2516b84 100644 --- a/etl/hubspot/deal_contacts_client.py +++ b/etl/hubspot/deal_contacts_client.py @@ -1,13 +1,40 @@ -from typing import Dict +import json +from typing import Dict, Optional from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs] from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs] +from etl.hubspot.errors import HubspotRequestError + # HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms). DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3 +def _scrubbed(error: Exception) -> HubspotRequestError: + """Reduce an SDK error to non-PII diagnostics, dropping the body. + + Each HubSpot sub-module ships its own ApiException class with no shared + base beyond Exception, so the fields are read by duck-typing. + """ + status = getattr(error, "status", None) + status_code = status if isinstance(status, int) else None + + category: Optional[str] = None + correlation_id: Optional[str] = None + try: + details = json.loads(getattr(error, "body", None) or "") + if isinstance(details, dict): + category = details.get("category") # type: ignore[reportUnknownMemberType] + correlation_id = details.get("correlationId") # type: ignore[reportUnknownMemberType] + except ValueError: + pass + + return HubspotRequestError( + status_code=status_code, category=category, correlation_id=correlation_id + ) + + class HubspotDealContactsClient: """Focused HubSpot client for the tenant-data sync flow. @@ -21,23 +48,30 @@ class HubspotDealContactsClient: self._client: Client = sdk_client def create_contact(self, properties: Dict[str, str]) -> str: - contact = self._client.crm.contacts.basic_api.create( # type: ignore[reportUnknownMemberType] - simple_public_object_input_for_create=SimplePublicObjectInputForCreate( - properties=properties + try: + contact = self._client.crm.contacts.basic_api.create( # type: ignore[reportUnknownMemberType] + simple_public_object_input_for_create=SimplePublicObjectInputForCreate( + properties=properties + ) ) - ) + except Exception as error: + # `from None` keeps the body-carrying SDK error out of tracebacks. + raise _scrubbed(error) from None return str(contact.id) # type: ignore[reportUnknownMemberType] def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None: - self._client.crm.associations.v4.basic_api.create( # type: ignore[reportUnknownMemberType] - object_type="deals", - object_id=deal_id, - to_object_type="contacts", - to_object_id=contact_id, - association_spec=[ - AssociationSpec( - association_category="HUBSPOT_DEFINED", - association_type_id=DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID, - ) - ], - ) + try: + self._client.crm.associations.v4.basic_api.create( # type: ignore[reportUnknownMemberType] + object_type="deals", + object_id=deal_id, + to_object_type="contacts", + to_object_id=contact_id, + association_spec=[ + AssociationSpec( + association_category="HUBSPOT_DEFINED", + association_type_id=DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID, + ) + ], + ) + except Exception as error: + raise _scrubbed(error) from None diff --git a/etl/hubspot/errors.py b/etl/hubspot/errors.py new file mode 100644 index 000000000..057e4d968 --- /dev/null +++ b/etl/hubspot/errors.py @@ -0,0 +1,25 @@ +from typing import Optional + + +class HubspotRequestError(Exception): + """A HubSpot API failure, scrubbed of the response body. + + HubSpot validation errors echo submitted property values back in the + body, so carrying (or exception-chaining) the SDK error would leak the + submitted PII into tracebacks and logs. Only non-PII diagnostics are + kept: status code, HubSpot error category and correlation id. + """ + + def __init__( + self, + status_code: Optional[int], + category: Optional[str], + correlation_id: Optional[str], + ) -> None: + self.status_code = status_code + self.category = category + self.correlation_id = correlation_id + super().__init__( + f"HubSpot request failed (status={status_code}, " + f"category={category}, correlation_id={correlation_id})" + )