Coverage for atlas_ecommerce/clients/meta_capi.py: 92%
63 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 16:36 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 16:36 +0000
1from datetime import timedelta
3import httpx
5from atlas_ecommerce.clients.delivery import PermanentDeliveryError, TransientDeliveryError, raise_for_response
6from atlas_ecommerce.purchases import PurchaseEvent
8EVENTS_URL = "https://graph.facebook.com/{version}/{pixel_id}/events"
9MAX_EVENT_AGE = timedelta(days=7)
10# Graph API signals throttling with HTTP 400 and one of these codes, not with 429.
11THROTTLE_CODES = frozenset({1, 2, 4, 17, 32, 341, 613, *range(80000, 80015)})
12# Session/token errors: only a new system-user token fixes them, the event is fine.
13AUTH_CODES = frozenset({102, 190})
16def fbc_for(purchase: PurchaseEvent) -> str | None:
17 """The pixel's own _fbc cookie when the site captured it; otherwise Meta's documented synthesis
18 fb.<subdomain index>.<creation ms>.<fbclid>, stamped with the best observation time we have."""
19 attribution = purchase.attribution
20 if attribution.fbc:
21 return attribution.fbc
22 fbclid = attribution.click_ids.get("fbclid")
23 if not fbclid:
24 return None
25 return f"fb.1.{int(purchase.happened_at.timestamp() * 1000)}.{fbclid}"
28def graph_error(response: httpx.Response) -> dict:
29 try:
30 return response.json().get("error") or {}
31 except ValueError:
32 return {}
35def throttled(response: httpx.Response) -> bool:
36 if response.status_code != 400:
37 return False
38 error = graph_error(response)
39 return bool(error.get("is_transient")) or error.get("code") in THROTTLE_CODES
42def unauthenticated(response: httpx.Response) -> bool:
43 return response.status_code in (401, 403) or graph_error(response).get("code") in AUTH_CODES
46def purchase_event(purchase: PurchaseEvent, test_event_code: str | None = None) -> dict:
47 attribution = purchase.attribution
48 user_data: dict = {"client_user_agent": attribution.user_agent}
49 if attribution.fbp:
50 user_data["fbp"] = attribution.fbp
51 fbc = fbc_for(purchase)
52 if fbc:
53 user_data["fbc"] = fbc
54 custom_data: dict = {
55 "currency": purchase.currency,
56 "value": float(purchase.value) if purchase.value else 0.0,
57 "order_id": purchase.order_id,
58 "num_items": purchase.quantity,
59 "content_type": "product",
60 }
61 if purchase.variant_id is not None:
62 custom_data["content_ids"] = [str(purchase.variant_id)]
63 payload: dict = {
64 "data": [
65 {
66 "event_name": "Purchase",
67 "event_time": int(purchase.happened_at.timestamp()),
68 "event_id": purchase.order_id,
69 "action_source": "website",
70 "user_data": user_data,
71 "custom_data": custom_data,
72 }
73 ]
74 }
75 # Routes the event to Events Manager's Test events tab instead of reporting; verification only.
76 if test_event_code:
77 payload["test_event_code"] = test_event_code
78 return payload
81class MetaConversionsClient:
82 def __init__(
83 self, *, pixel_id: str, access_token: str, api_version: str, http: httpx.AsyncClient | None = None
84 ) -> None:
85 self._url = EVENTS_URL.format(version=api_version, pixel_id=pixel_id)
86 # A request parameter or a bearer header, never a field of the event document; the header keeps it out of URLs.
87 self._headers = {"Authorization": f"Bearer {access_token}"}
88 self._http = http or httpx.AsyncClient(timeout=15.0)
90 async def send(self, payload: dict) -> None:
91 try:
92 response = await self._http.post(self._url, json=payload, headers=self._headers)
93 except httpx.TransportError as exc:
94 raise TransientDeliveryError(f"Meta Graph API unreachable: {exc}") from exc
95 if throttled(response):
96 raise TransientDeliveryError(f"Meta throttled the request: {response.text[:300]}")
97 if not response.is_success and unauthenticated(response):
98 raise TransientDeliveryError(f"Meta refused the access token; rotate it: {response.text[:300]}")
99 raise_for_response(response)
100 body = response.json()
101 if not body.get("events_received"):
102 raise PermanentDeliveryError(f"Meta accepted the request but recorded no event: {body}")
104 async def close(self) -> None:
105 await self._http.aclose()