first
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user