This commit is contained in:
JiriUhlir
2026-07-20 07:36:55 +02:00
parent 496104f65a
commit a7cb53e0e6
16 changed files with 1856 additions and 19 deletions
View File
+231
View File
@@ -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,
)