HubSpot failures surface without the response body that echoes tenant PII 🟩

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-06 13:34:33 +00:00
parent 2e19085875
commit e3894b0309
2 changed files with 76 additions and 17 deletions

View file

@ -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

25
etl/hubspot/errors.py Normal file
View file

@ -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})"
)