From 049475ec96ffcd127c65b4577f874b4cc4178d1d Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 6 Jul 2026 15:58:36 +0000 Subject: [PATCH] =?UTF-8?q?A=20failed=20HubSpot=20write=20raises=20a=20scr?= =?UTF-8?q?ubbed=20error=20carrying=20the=20orphaned=20job=5Fno=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../hubspot/deal_properties_client.py | 50 ++++++++++++++++--- orchestration/abri_log_job_orchestrator.py | 21 ++++++-- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/infrastructure/hubspot/deal_properties_client.py b/infrastructure/hubspot/deal_properties_client.py index 24cf5fca8..1564b22de 100644 --- a/infrastructure/hubspot/deal_properties_client.py +++ b/infrastructure/hubspot/deal_properties_client.py @@ -1,6 +1,40 @@ +import json +from typing import Any, Dict, Optional, cast + from hubspot.client import Client # type: ignore[reportMissingTypeStubs] from hubspot.crm.deals import SimplePublicObjectInput # type: ignore[reportMissingTypeStubs] +from infrastructure.hubspot.errors import HubspotRequestError + + +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: + 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 + + return HubspotRequestError( + status_code=status_code, category=category, correlation_id=correlation_id + ) + class HubspotDealPropertiesClient: """Focused HubSpot client for writing single deal properties. @@ -16,9 +50,13 @@ class HubspotDealPropertiesClient: def write_deal_property( self, deal_id: str, property_name: str, value: str ) -> None: - self._client.crm.deals.basic_api.update( # type: ignore[reportUnknownMemberType] - deal_id, - simple_public_object_input=SimplePublicObjectInput( - properties={property_name: value} - ), - ) + try: + self._client.crm.deals.basic_api.update( # type: ignore[reportUnknownMemberType] + deal_id, + simple_public_object_input=SimplePublicObjectInput( + properties={property_name: value} + ), + ) + except Exception as error: + # `from None` keeps the body-carrying SDK error out of tracebacks. + raise _scrubbed(error) from None diff --git a/orchestration/abri_log_job_orchestrator.py b/orchestration/abri_log_job_orchestrator.py index 811d008af..325d63be6 100644 --- a/orchestration/abri_log_job_orchestrator.py +++ b/orchestration/abri_log_job_orchestrator.py @@ -7,6 +7,7 @@ from domain.abri.models import AbriRequestRejected, LogJobRequest, PlaceRef from domain.abri.slots import slot_for_confirmed_time from infrastructure.abri.abri_client import AbriClient from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient +from infrastructure.hubspot.errors import HubspotRequestError # The HubSpot deal property that carries the OpenHousing job number; the # property ID is fixed externally, the domain and database call it job_no. @@ -98,11 +99,21 @@ class AbriLogJobOrchestrator: if isinstance(outcome, AbriRequestRejected): return outcome - self._hubspot.write_deal_property( - deal_id=booking.deal_id, - property_name=JOB_NO_DEAL_PROPERTY, - value=outcome.job_no, - ) + try: + self._hubspot.write_deal_property( + deal_id=booking.deal_id, + property_name=JOB_NO_DEAL_PROPERTY, + value=outcome.job_no, + ) + except HubspotRequestError as error: + # The job now exists in OpenHousing; never re-run log_job (it + # would log a duplicate). `error` is already scrubbed, so + # chaining it keeps tracebacks free of the response body. + raise LogJobWriteBackError( + message=str(error), + deal_id=booking.deal_id, + job_no=outcome.job_no, + ) from error self._deal_database.record_job_no( deal_id=booking.deal_id, job_no=outcome.job_no )