From b96aceaf7bf4161b5fcde9e0128317bef041494b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 6 Jul 2026 13:42:58 +0000 Subject: [PATCH] =?UTF-8?q?HubSpot=20calls=20share=20one=20retry=20policy?= =?UTF-8?q?=20behind=20a=20single=20guarded=20entry=20point=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/deal_contacts_client.py | 63 ++++++++++++++++------------- etl/hubspot/retry.py | 4 +- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/etl/hubspot/deal_contacts_client.py b/etl/hubspot/deal_contacts_client.py index c218b6e05..9609b1be8 100644 --- a/etl/hubspot/deal_contacts_client.py +++ b/etl/hubspot/deal_contacts_client.py @@ -1,5 +1,5 @@ import json -from typing import Dict, Optional +from typing import Any, Callable, Dict, Optional, cast from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs] @@ -24,10 +24,15 @@ def _scrubbed(error: Exception) -> HubspotRequestError: 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] + parsed: Any = json.loads(getattr(error, "body", None) or "") + if isinstance(parsed, dict): + details = cast(Dict[str, Any], parsed) + raw_category = details.get("category") + raw_correlation_id = details.get("correlationId") + category = raw_category if isinstance(raw_category, str) else None + correlation_id = ( + raw_correlation_id if isinstance(raw_correlation_id, str) else None + ) except ValueError: pass @@ -49,34 +54,36 @@ class HubspotDealContactsClient: self._client: Client = sdk_client def create_contact(self, properties: Dict[str, str]) -> str: - try: - contact = call_with_retry( - lambda: self._client.crm.contacts.basic_api.create( # type: ignore[reportUnknownMemberType] - simple_public_object_input_for_create=SimplePublicObjectInputForCreate( - properties=properties - ) + contact = self._call( + lambda: 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: - try: - call_with_retry( - lambda: 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, - ) - ], - ) + self._call( + lambda: 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, + ) + ], ) + ) + + @staticmethod + def _call(fn: Callable[[], Any]) -> Any: + """Run one SDK call under the shared retry policy, scrubbed on failure.""" + try: + return call_with_retry(fn) except Exception as error: + # `from None` keeps the body-carrying SDK error out of tracebacks. raise _scrubbed(error) from None diff --git a/etl/hubspot/retry.py b/etl/hubspot/retry.py index d1500600a..7bd50c8a1 100644 --- a/etl/hubspot/retry.py +++ b/etl/hubspot/retry.py @@ -1,7 +1,7 @@ import time from http import HTTPStatus from logging import Logger -from typing import Callable, Optional, TypeVar +from typing import Callable, Dict, Optional, TypeVar, cast from utils.logger import setup_logger @@ -38,7 +38,7 @@ def call_with_retry( status = getattr(e, "status", None) if status not in RETRYABLE_STATUSES or attempt == max_retries: raise - headers = getattr(e, "headers", None) or {} + headers = cast(Dict[str, str], getattr(e, "headers", None) or {}) interval_ms = int( headers.get("x-hubspot-ratelimit-interval-milliseconds", 10000) )