"""HTTP client for the Meta Graph / Marketing API. Deliberately hand-rolled over ``httpx`` rather than using the official ``facebook-business`` SDK: this service is a thin passthrough, the SDK is synchronous and imposes its own object model and error types that we would only have to translate back into our JSON shape. Staying on raw HTTP also means a new Graph version is a config change, not a dependency bump. What this module adds on top of a plain request: * auth - the access token as a Bearer header (never a query param, so tokens do not end up in upstream access logs), plus ``appsecret_proof`` when an app secret was supplied; * versioned URL building from the per-request Graph version; * cursor pagination with an explicit page cap; * error mapping into ``UpstreamError``; * capture of Meta's rate-limit headers so callers can pace themselves. Request and response bodies are otherwise forwarded as-is, so callers keep the full upstream API surface. """ from __future__ import annotations import json from contextvars import ContextVar from typing import Any import httpx from .. import config from ..credentials import MetaCredentials from ..errors import UpstreamError from ..logging_config import get_logger logger = get_logger(__name__) # Meta reports quota consumption in these response headers. A middleware in # main.py installs an empty dict per request and copies whatever lands in it # onto our own response, so callers can see how close to a throttle they are # without us threading a Response object through every route. # # The middleware must create the dict and we only ever MUTATE it: with # Starlette's BaseHTTPMiddleware the endpoint runs in a child task that gets a # *copy* of the context, so a `.set()` here would not be visible to the # middleware - mutating the shared dict is. USAGE_HEADERS = ( "X-App-Usage", "X-Ad-Account-Usage", "X-Business-Use-Case-Usage", ) current_usage: ContextVar[dict[str, str] | None] = ContextVar( "meta_usage", default=None ) def _record_usage(resp: httpx.Response) -> None: sink = current_usage.get() if sink is None: return for header in USAGE_HEADERS: if header in resp.headers: sink[header] = resp.headers[header] def _encode_form(data: dict[str, Any]) -> dict[str, str]: """Form-encode a body for Graph writes. The Graph API expects form fields; anything structured (lists, dicts) has to be a JSON string inside that form field, not a nested form structure. """ encoded: dict[str, str] = {} for key, value in data.items(): if value is None: continue if isinstance(value, (dict, list)): encoded[key] = json.dumps(value, ensure_ascii=False) elif isinstance(value, bool): encoded[key] = "true" if value else "false" else: encoded[key] = str(value) return encoded class MetaGraphClient: """Authenticated client for one Graph API request cycle.""" def __init__( self, creds: MetaCredentials, *, service_name: str = "Meta Graph API", ) -> None: self._creds = creds self._service_name = service_name # --- URL / params --------------------------------------------------------- def url(self, path: str) -> str: """Build a versioned Graph URL from a path like 'act_123/campaigns'.""" return f"{config.META_GRAPH_BASE_URL}/{self._creds.api_version}/{path.lstrip('/')}" def build_url(self, url: str | httpx.URL, params: dict[str, Any] | None) -> httpx.URL: """Merge params and appsecret_proof INTO the URL's existing query. Merging rather than passing httpx's ``params=`` matters: that argument replaces the whole query string, which would silently strip the cursor out of a ``paging.next`` URL and make pagination read page 1 forever. """ target = httpx.URL(url) if params: clean = {k: v for k, v in params.items() if v is not None} if clean: target = target.copy_merge_params(clean) proof = self._creds.appsecret_proof() if proof: target = target.copy_merge_params({"appsecret_proof": proof}) return target # --- requests ------------------------------------------------------------- async def request( self, method: str, url: str | httpx.URL, *, params: dict[str, Any] | None = None, form: dict[str, Any] | None = None, ) -> Any: headers = {"Authorization": f"Bearer {self._creds.access_token}"} target = self.build_url(url, params) try: async with httpx.AsyncClient(timeout=config.HTTP_TIMEOUT_SECONDS) as client: resp = await client.request( method, target, data=_encode_form(form) if form is not None else None, headers=headers, ) except httpx.TimeoutException as exc: raise UpstreamError( f"{self._service_name} request timed out.", status=504 ) from exc except httpx.HTTPError as exc: raise UpstreamError( f"{self._service_name} is unreachable: {exc}", status=502 ) from exc _record_usage(resp) return _parse_response(resp, self._service_name) async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: return await self.request("GET", self.url(path), params=params) async def post( self, path: str, form: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> Any: return await self.request("POST", self.url(path), params=params, form=form or {}) async def get_absolute(self, url: str) -> Any: """GET an already-built absolute URL (used to follow paging.next).""" return await self.request("GET", url) # --- pagination ----------------------------------------------------------- async def get_all_pages( self, path: str, params: dict[str, Any] | None = None, *, max_pages: int | None = None, ) -> dict[str, Any]: """Follow ``paging.next`` and concatenate ``data`` across pages. Stops at ``META_MAX_PAGES`` and marks the result ``truncated: true`` rather than looping unbounded or silently returning a partial list that looks complete. """ cap = max_pages or config.META_MAX_PAGES collected: list[Any] = [] page = await self.get(path, params) pages_read = 1 while True: if not isinstance(page, dict): # Non-collection response - hand it back untouched. return page collected.extend(page.get("data") or []) next_url = (page.get("paging") or {}).get("next") if not next_url: return {"data": collected, "pages_read": pages_read, "truncated": False} if pages_read >= cap: logger.warning( "Paging cap reached for %s after %s pages; result truncated.", path, pages_read, ) return { "data": collected, "pages_read": pages_read, "truncated": True, "next": next_url, } page = await self.get_absolute(next_url) pages_read += 1 def _parse_response(resp: httpx.Response, service_name: str) -> Any: try: payload = resp.json() except ValueError: payload = {"raw": resp.text} if resp.is_success: return payload # Graph errors look like {"error": {"message", "type", "code", # "error_subcode", "error_user_title", "error_user_msg", "fbtrace_id"}}. message = f"{service_name} error" if isinstance(payload, dict): err = payload.get("error") if isinstance(err, dict): # error_user_msg is the human-readable variant when Meta has one. message = err.get("error_user_msg") or err.get("message") or message raise UpstreamError( message, status=502 if resp.status_code >= 500 else resp.status_code, upstream_status=resp.status_code, body=payload, )