Ad Platform Connections
ads/ad-platform-connections
| Kind | backend |
|---|---|
| Domain | ads |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi-0.118, httpx-0.28, pydantic-2.12, sqlalchemy-2.0-sync, postgres, cryptography-46 |
| Depends on | _shared/fastapi-project-layout, _shared/workspace-scoping, _shared/auth-dependency-pattern, _shared/alembic-migrations |
| Frontends | ads/ui/ads-manager |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
A workspace connects Google Ads, Meta and LinkedIn Ads once; every other ads capability in Labs (reporting,
bidding, audience sync, the multi-platform campaign-setup orchestrator) then borrows that connection instead of
asking the user to re-authenticate. The two capabilities this recipe actually covers do that borrowing safely for
Google and LinkedIn: an access token is cached and refreshed per connection (never per process), keyed off the
tenant's own refresh token, so two workspaces holding different refresh tokens can never be served each other's
access token, and a request that hits a stale cached token is retried exactly once after a forced refresh, never
looped (google_ads_manage.py module docstring, 9 to 13; linkedin_ads_service.py module docstring, 22 to 27). The
invariant that makes the rest of the ads surface trustworthy is narrower than it sounds: it holds for the Google
Ads client (GoogleAdsClient, per-connection cache keyed by a hash of the refresh token) and for the LinkedIn
service (LinkedInAdsService, tokens passed per call, no cache at all); it does not hold for the older Google
Ads conversions client also inside this recipe's assigned source (google_ads_service.py), which is a documented
global singleton, and there is no equivalent refresh/retry mechanism for Meta anywhere in the assigned files. Both
gaps are real and are written up under Gotchas rather than folded into the invariant.
When to use
Read it before touching GoogleAdsClient._get_access_token/_post (google_ads_manage.py), before touching
LinkedInAdsService._refresh_access_token/_request (linkedin_ads_service.py), before adding a fourth ad
platform to services/campaign_setup/adapters/, and when someone asks "why did workspace B get workspace A's
Google Ads data", "why does this request 401 forever", "is Meta's token refreshed automatically" (no), or "where
do these OAuth tokens actually live". Trigger phrases: "ad platform connection", "per-tenant token cache",
"refresh token", "retry once on 401", "google_ads_connections", "tracking settings", "workspace_settings.tracking".
Not this recipe: the GAQL builder and its field/segment/metric whitelists (build_gaql, google_ads_manage.py
499 to 616) and the bidding-scheme mapper (services/google_ads_campaign_service.build_bidding_update, imported by
tests/test_google_ads_manage.py 30 to 36 but not itself one of this recipe's six source files); both consume a
GoogleAdsClient this recipe documents, neither is part of how the client authenticates; the OAuth connect flow
that first writes a token into a workspace (api/routers/google_ads_oauth_router.py,
api/routers/linkedin_oauth_router.py, api/routers/meta_oauth_router.py, none in the assigned source set):
this recipe covers what happens to a token after it is already stored, not the authorization-code exchange that
put it there; the campaign-creation engine MetaAdapter.create() delegates to
(services.retargeting_deployer.MetaRetargetingDeployer.deploy_flat_campaign, referenced from
meta_adapter.py 131 to 146 but not opened here); the read-only "list every connected account" and "list every
campaign" endpoints (GET /api/ads/status, GET /api/ads/accounts, api/routers/ads_router.py 1051 to 1148), real,
mounted, and the concrete mechanism the Purpose above means by "expose connected status to every other ads
capability", cited here for grounding, but they live in a router file outside the six assigned source files and
are not walked line-by-line the way the three services and three adapters are.
Stack assumptions
At 62ba4fc: fastapi==0.118.2, httpx==0.28.1 for every outbound call (token exchange, Google Ads REST, LinkedIn
RestLi), pydantic==2.12.0 for request/response schemas the tests import (schemas/google_ads_management_schemas.py,
schemas/linkedin_ads_schemas.py), SQLAlchemy==2.0.43 sync sessions for reading workspace_settings,
cryptography==46.0.0 (Fernet) for at-rest encryption of the stored tokens (services/security/oauth_token_crypto.py,
adjacent to but not one of the six assigned files, read for grounding since Data model depends on it). Foundations
assumed: S4 (fastapi-project-layout) for the app and router mounting (app_fastapi.py 863 to 868 mounts
ads_router, google_ads_management_router, linkedin_ads_router, campaign_setup_router all under /api/ads);
S6 (workspace-scoping) for the X-Workspace-ID header resolution every adapter's context["tracking"] dict is
built from; S5 (auth-dependency-pattern) for the CurrentUser dependency the one real HTTP route in scope
(GET /api/ads/status) sits behind; S8 (alembic-migrations) for the one migration in this recipe's Deploy & ops.
Environment, from the calls each client makes (core.config.settings, read but not opened as a file here):
GOOGLE_ADS_CLIENT_ID/GOOGLE_ADS_CLIENT_SECRET (falls back to GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET,
google_ads_manage.py 119 to 129), GOOGLE_ADS_DEVELOPER_TOKEN (132 to 136, raises GoogleAdsApiError(500, ...) if
unset), LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET (linkedin_ads_service.py 267 to 277, raises
LinkedInAdsError(status_code=500, ...) if either is missing), and (outside the six assigned files but load-
bearing for Data model below) CALENDAR_ENCRYPTION_KEY, reused as the Fernet key for ad-platform tokens
(oauth_token_crypto.py 66 to 104). Fail behaviour: both clients fail closed on missing OAuth app credentials (a
typed 500 before any network call), and both fail closed on a refresh that doesn't return 200 (Google:
GoogleAdsApiError(401, ...), google_ads_manage.py 226 to 229; LinkedIn: LinkedInAdsError(..., status_code=401),
linkedin_ads_service.py 291 to 301).
Data model
No dedicated table. Every OAuth/refresh token for every platform, for every workspace, lives inside one JSON
column: workspace_settings.settings['tracking'] (table workspace_settings, columns id uuid PK,
workspace_id uuid UNIQUE REFERENCES workspaces(id) ON DELETE CASCADE, settings JSON NOT NULL DEFAULT '{}',
created_at, updated_at; services/workspace/models.py 82 to 96, Column(JSON, ...), not JSONB). The
shape of tracking, reconstructed from every place these three recipes' adapters and tracking_settings_service.py
read or write it (no single schema file defines it):
{
// scalar per-platform connection flags + tokens (Fernet-encrypted at rest, see below)
"meta_connected": true,
"meta_access_token": "gAAAAA...", // long-lived (~60 days), no refresh_token stored alongside it
"meta_account_name": "Fictional Client GmbH",
"meta_ad_account_id": "act_0000000000",
"meta_page_id": "0000000000",
"meta_pixel_id": "0000000000",
"linkedin_connected": true,
"linkedin_access_token": "gAAAAA...",
"linkedin_refresh_token": "gAAAAA...",
"linkedin_ad_account_id": "000000000",
"linkedin_account_name": "Fictional Client GmbH",
"google_ads_connected": true, // legacy single-connection flag
"google_ads_refresh_token": "gAAAAA...", // legacy single-connection token
"google_ads_customer_id": "0000000000",
// multi-connection form (google_adapter.py 57 to 92 reads this; a workspace can have >1 Google Ads login)
"google_ads_connections": [
{ "refresh_token": "gAAAAA...", "customer_ids": ["0000000000"], "mcc_id": null, "label": "Fictional Client" }
]
}
Encrypted fields, centralized in oauth_token_crypto.SCALAR_TOKEN_FIELDS (45 to 50): meta_access_token,
linkedin_access_token, linkedin_refresh_token, google_ads_refresh_token; plus every refresh_token inside
google_ads_connections[] (CONNECTION_LIST_FIELD/CONNECTION_TOKEN_KEY, 52 to 54). Encryption is Fernet
(cryptography.fernet.Fernet) using CALENDAR_ENCRYPTION_KEY, a key shared with an unrelated feature (calendar
OAuth), not a key of its own (66 to 104, module comment: "reuses the same key infrastructure as calendar OAuth
tokens"). Write path always encrypts; read path is dual-read: a value starting with the Fernet prefix gAAAAA is
decrypted, anything else (legacy plaintext, or ciphertext under a since-rotated key) is returned unchanged
(decrypt_token, 134 to 153). What is NOT stored anywhere in the six assigned files: no separate google_ads_id/
linkedin_ads_id table row per connection with its own primary key; a "connection" is a dict inside a JSON array,
addressed only by matching customer_ids, never by a stable connection id; no Meta refresh token of any kind (Meta
has no meta_refresh_token field in SCALAR_TOKEN_FIELDS, see Gotchas); no plaintext-vs-ciphertext marker column,
the gAAAAA prefix check is the only way to tell which a stored value is.
API surface
None of the six assigned files defines an HTTP route: google_ads_manage.py, linkedin_ads_service.py and
google_ads_service.py are clients, google_adapter.py/linkedin_adapter.py/meta_adapter.py are called by
campaign_setup_service, not mounted themselves. The real surface a rebuild reimplements is these Python entry
points:
| Entry point | File:lines | Never raises | Returns / side effect |
|---|---|---|---|
GoogleAdsClient._get_access_token(force_refresh=False) | google_ads_manage.py 211 to 233 | no (raises GoogleAdsApiError(401, ...) on a failed exchange) | access token string; writes the per-connection cache on success |
GoogleAdsClient._post(path, payload) | google_ads_manage.py 247 to 260 | no (raises parse_google_error(resp) on non-200) | parsed JSON; on a 401 invalidates the cache entry and retries the POST exactly once with a forced-refresh token |
GoogleAdsClient.mutate/.search/.call | google_ads_manage.py 264 to 310 | no | all three funnel through _post, so every write/read/report call on this client gets the same cache-then-retry-once behaviour for free |
LinkedInAdsService._refresh_access_token(refresh_token) | linkedin_ads_service.py 265 to 306 | no (raises LinkedInAdsError(status_code=401, ...)) | {access_token, refresh_token?, expires_in, ...} from LinkedIn's token endpoint |
LinkedInAdsService._request(...) | linkedin_ads_service.py 310 to 380 | no (raises LinkedInAdsError on any >=400) | on a 401 with a refresh_token given and _retried still False: refreshes once, calls the caller-supplied save_tokens callback (sync or async) with the rotated tokens, recurses exactly once with _retried=True |
GoogleAdapter._is_connected(context) / .validate(brief, context) | google_adapter.py 94 to 113 | yes | bool / list of human-readable error strings; the "is this workspace's connection usable" check every other Google Ads capability in campaign_setup runs before spending an API call |
LinkedInAdapter.validate(brief, context) | linkedin_adapter.py 55 to 65 | yes | same shape, checks tracking.linkedin_connected and tracking.linkedin_access_token are both truthy |
MetaAdapter.validate(brief, context) | meta_adapter.py 43 to 54 | yes | same shape; checks tracking.meta_access_token only, there is no "connected" flag check here, only "is a token present" |
GET /api/ads/status | api/routers/ads_router.py 1051 to 1081 (outside the six assigned files, cited for grounding) | n/a | 200 {platforms: [{platform, connected, account_name}]} for meta/google/linkedin, read from stored flags only, no outbound API call |
Error-code mapping is defined only at the client layer in the assigned files, not at any router: GoogleAdsApiError.http_status
maps Google's 400/401/403/404/409/429 straight through and anything else to 502 (google_ads_manage.py 69 to 74);
LinkedInAdsError.status_code is whatever LinkedIn returned, with .details carrying LinkedIn's JSON body verbatim
(linkedin_ads_service.py 366 to 378, proven by test_error_surfaces_linkedin_body_verbatim). How either typed
error becomes an HTTP response at a router is not determinable from the six assigned files; no router file is
among them, and neither exception name appears in app_fastapi.py.
Application logic
Google (GoogleAdsClient, the correct, per-connection pattern):
- Cache lookup keyed by
sha256(refresh_token), not by workspace id or customer id (_token_cache_key, 149 to 150;_cache_get, 153 to 157): a hit younger thanexpires_in - 300sis returned without any network call. - On a miss (or
force_refresh=True), exchange the refresh token athttps://oauth2.googleapis.com/tokenwithgrant_type=refresh_token(211 to 233); a non-200 raisesGoogleAdsApiError(401, ...)immediately, nothing is cached. - Every
mutate/search/callfunnels through_post(247 to 260), which fetches (and implicitly caches) the access token, sends the request, and, only on a 401, invalidates that one cache entry (_cache_invalidate, 165 to 166) and retries with a forced refresh exactly once; a second 401 propagates asparse_google_error(resp)rather than looping. resource_type(mutate) andmethod_path(call) are checked against fixed regexes before being interpolated into the request URL (_RESOURCE_TYPE_RE/_METHOD_PATH_RE, 45 to 46, enforced at 272 to 273 and 308 to 309), the one piece of attacker-facing validation inside the client itself, not something the connection layer trusts the caller to have already done.
LinkedIn (LinkedInAdsService, stateless, no cache):
- Every method takes
access_token/refresh_token/save_tokensas**authkwargs per call (module docstring 22 to 27); there is no module-level cache to key or invalidate; the caller (an adapter, a router) is the one holding the per-workspace state. _requestsends the call with the givenaccess_token(332 to 343). On a 401, only if arefresh_tokenwas given and this is not already a retried call (not _retried), it calls_refresh_access_tokenonce (346 to 364), invokessave_tokens(new_access, new_refresh_or_old, expires_in)if supplied (awaiting it if it returned an awaitable,inspect.isawaitable, 353), and recurses into itself with_retried=Trueand the new tokens. A second 401 after that recursion raisesLinkedInAdsError(status_code=401, ...)without a third attempt (proven bytest_401_after_refresh_does_not_loop, consuming exactly three queued responses).- LinkedIn's refresh response may or may not rotate the refresh token; when it doesn't,
new_refresh = ... or refresh_tokenkeeps the old one (350) sosave_tokensis always called with a usable pair.
Meta (MetaAdapter, no refresh engine in the assigned source):
_creds(30 to 38) readsmeta_access_tokenstraight out ofcontext["tracking"]and hands it toretargeting_deployer.meta_deployer.deploy_flat_campaign(133 to 146) with no expiry check, no 401 handling, no retry. Whatever token is stored is used as-is.
Connected-status surfaced to other capabilities:
- Every adapter's
validate(brief, context)is the actual "is this connection usable" gate the multi-platformcampaign_setuporchestrator runs per platform before building a payload (google_adapter.py104 to 113,linkedin_adapter.py55 to 65,meta_adapter.py43 to 54); each returns a list of plain-English error strings rather than raising, so one platform's missing connection never blocks the others. GoogleAdapter._is_connected(94 to 99) andads_router._google_is_connected(outside scope, cited for grounding) both implement "connected" the same way: either the legacygoogle_ads_connected+google_ads_refresh_tokenpair is set, orgoogle_ads_connectionsis a non-empty list; there is no single boolean of record, two independent call sites reimplement the same OR.
Invariants
- Two Google Ads connections with different refresh tokens never share an access token, because the cache key is
a hash of the refresh token itself, not of the workspace or customer id
(
test_token_cache_is_per_connection,google_ads_manage.py149 to 150). - An expired Google Ads cache entry triggers exactly one token exchange before the next call proceeds
(
test_expired_cache_entry_triggers_refresh). - A Google Ads 401 is retried exactly once, with the stale cache entry invalidated first
(
_post,google_ads_manage.py253 to 257; no dedicated "never loops" test exists for this client; the LinkedIn client's equivalent guarantee does have one, see below). - A LinkedIn 401 is retried exactly once and never loops, proven by consuming exactly three queued HTTP responses
and asserting the third 401 propagates (
test_401_after_refresh_does_not_loop). - A LinkedIn token rotation is always handed to the caller's
save_tokenscallback, synchronous or async, before the retried call returns (test_401_refreshes_once_and_persists_rotated_tokens). resource_typeandmethod_pathon the Google Ads client are validated against a closed regex before touching the request URL; a path-traversal-shaped value is rejected before any HTTP call is made (test_mutate_rejects_bad_resource_type,test_call_rejects_bad_method_path).- None of the above three invariants is proven, or claimed, for Meta: no test in the two assigned test files
imports
meta_adapter.py, and none exercises a Meta 401.
Frontend
The module that would render this, by id:
ads/ui/ads-manager the workspace-level surface a person uses to see "Google Ads: connected (customer
0000000000)", "Meta: not connected", "LinkedIn: connected", and to trigger a
connect flow; its data comes from GET /api/ads/status (ads_router.py
1051 to 1081, outside the six assigned files) and, once a campaign is being set
up, from each adapter's validate() error list (see Application logic); at
62ba4fc nothing in the six assigned files itself renders anything, this
recipe is the engine a status/connect panel is built on top of, same relationship
the auto-topup recipe's engine has to its subscription-manager panel
Minimum admin/debug surface (no dedicated route exists among the six assigned files): reading token presence
without ever reading a token value is awkward against this column, because workspace_settings.settings is
JSON, not JSONB (services/workspace/models.py 92); there is no ->>/? JSONB operator available for a
plain SQL check; the only safe-at-a-glance query is at the Python layer, e.g. bool(get_workspace_tracking_settings(db, workspace_id) or {}).get("google_ads_connections"). No log line in the six assigned files prints a connection
status on a schedule (contrast the auto-topup recipe's scheduler log line) because there is no scheduler here:
tokens are refreshed inline, per request, never in the background (see Deploy & ops).
Security & tenancy
Auth dependency and workspace derivation are not inside the six assigned files (no router is among them); by
_shared/auth-dependency-pattern and _shared/workspace-scoping's own reference implementations, the one route
cited here for grounding (GET /api/ads/status) sits behind CurrentUser and derives its workspace from an
X-Workspace-ID header, failing closed with 400 when the header is missing (ads_router._require_workspace,
outside scope, cited only). Inside the six assigned files, tenancy is enforced structurally rather than checked:
GoogleAdsClient and LinkedInAdsService both take a refresh_token/access_token as a constructor or call
argument, never look one up themselves, so the caller (an adapter reading context["tracking"], itself built from
one workspace's row) is the only thing deciding which tenant's credential is used per call; there is no code path
in these six files where one workspace's request can read another's cached token, other than the singleton in
google_ads_service.py described under Gotchas, which is not tenant-scoped by any mechanism at all.
Fail direction: both clients fail closed on missing OAuth app credentials (typed 500 before any network call,
google_ads_manage.py 127 to 129, linkedin_ads_service.py 272 to 277) and on a failed refresh (typed 401, no token
cached, no request attempted with a bad token). Secrets at rest: Fernet-encrypted via a key shared with an
unrelated feature (CALENDAR_ENCRYPTION_KEY, see Data model), but encryption itself fails open, not closed:
when the key is unset outside development, _get_cipher() returns None and every token is written and read as
plaintext with only a warning logged (oauth_token_crypto.py 83 to 104); nothing in the assigned files, or in
oauth_token_crypto.py, refuses to store a token when no key is configured. Rate limits: none dedicated to token
refresh or to these two clients anywhere in the assigned files. Attacker-controlled inputs: customer_id is
regex-validated and dash-stripped before use (strip_customer_id, _CUSTOMER_ID_RE, google_ads_manage.py
43 to 44, 109 to 116); resource_type/method_path on the Google client are regex-validated before being interpolated
into a URL path (see Application logic); GAQL inputs (segments, metrics, statuses, dates) are whitelisted in
build_gaql (499 to 616, out of this recipe's scope but in the same file) rather than string-interpolated; none of
this validation is specific to the connection engine itself, all of it sits on top of an already-authenticated
client.
Tests
Run 2026-09-12 from alchemy_labs/backend at 62ba4fc, using the venv at alchemy_labs/backend/.venv, no database
and no network (httpx.MockTransport for Google, a hand-rolled FakeAsyncClient monkeypatch for LinkedIn):
.venv/bin/python -m pytest tests/test_google_ads_manage.py tests/test_linkedin_ads.py \
-q --no-cov -o addopts="" -p no:cacheprovider
prints 137 passed (32 Pydantic deprecation warnings, unrelated to this recipe). Split: tests/test_google_ads_manage.py
alone prints 61 passed (57 def test_ functions, one @pytest.mark.parametrize with 5 cases on
TestStripCustomerId.test_rejects_non_numeric, 62); tests/test_linkedin_ads.py alone prints 76 passed (76
def test_ functions, no parametrize).
Hermetic vs live: fully hermetic, both files. Google: TestGoogleAdsClient (test_google_ads_manage.py 389 to 509)
builds every GoogleAdsClient with a transport=httpx.MockTransport(handler) (378 to 386), no real HTTP; token-cache
tests seed manage._TOKEN_CACHE directly (_seed_token, 374 to 375) and setup_method clears it between tests
(390 to 391). LinkedIn: TestRequestHelper and TestDmpSegments (395 to 774) monkeypatch la.httpx.AsyncClient with
FakeAsyncClient, a class-level response queue (fake_httpx fixture, 379 to 384); linkedin_creds monkeypatches
la.settings.LINKEDIN_CLIENT_ID/LINKEDIN_CLIENT_SECRET (387 to 392).
Known-answer / behavioural vectors specific to this recipe's scope: test_token_cache_is_per_connection (473 to 487)
seeds two different refresh tokens to two different cached access tokens and asserts the Authorization header
sent for each connection matches only its own; test_expired_cache_entry_triggers_refresh (489 to 501) seeds a
cache entry already in the past and asserts the fresh token from the (mocked) OAuth endpoint is the one used;
test_401_refreshes_once_and_persists_rotated_tokens (421 to 450) queues exactly [401, 200 (token), 200 (retry)]
and asserts save_tokens was called once with the rotated pair; test_401_after_refresh_does_not_loop (452 to 464)
queues exactly [401, 200 (token), 401] and asserts the second 401 raises and the queue is fully drained (no
fourth call attempted).
What a rebuild adds and this branch lacks: no test in either file imports or exercises google_ads_service.py (the
singleton client) at all; its cross-tenant cache behaviour, described under Gotchas, has no regression test either
proving or disproving it; no test in either file exercises meta_adapter.py or any Meta token/401 path, because
there is no such path to exercise; no test asserts what happens to a GoogleAdsApiError/LinkedInAdsError once it
reaches a router (no router file is among the two test files' imports).
Deploy & ops
Config keys, all read from core.config.settings (file not opened here): GOOGLE_ADS_CLIENT_ID,
GOOGLE_ADS_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (Ads-specific pair preferred, generic OAuth
pair as fallback), GOOGLE_ADS_DEVELOPER_TOKEN, LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, and, for the
storage layer under Data model, not itself in the six assigned files, CALENDAR_ENCRYPTION_KEY. Migrations: one,
migrations/versions/20260610_0002_encrypt_ad_oauth_tokens.py (revision encrypt_ad_oauth_tokens_001, down-revision
tracking_contacts_ws_unique_001), a backfill, not a schema change (no ALTER TABLE, workspace_settings.settings
already existed as JSON): it walks every workspace_settings row with a non-empty tracking sub-dict and
Fernet-encrypts every field named in SCALAR_TOKEN_FIELDS plus every google_ads_connections[].refresh_token, in
place, via encrypt_tracking_tokens. Idempotent (encrypt_token is a no-op on values that already look like
ciphertext) and a no-op when no encryption key is configured (degrades to plaintext pass-through rather than
failing the migration). downgrade() calls the matching decrypt_tracking_tokens to reverse it.
Scheduler: none. Unlike the auto-topup recipe's run_auto_topup_retry job, there is no background process that
refreshes ad-platform tokens ahead of time; refresh happens inline, inside the request that hit a stale token
(Google: on the 401 inside _post; LinkedIn: on the 401 inside _request), which is also why there is no
"tenants needing a human" queue for this recipe the way there is for the billing engine: a token that has gone
fully invalid (revoked, not merely expired) simply fails the next request with a 401-after-retry, surfaced as
whatever error code that route maps GoogleAdsApiError/LinkedInAdsError to (not determinable here, see API
surface).
Argus-style checks: none dedicated to this recipe exist in the six assigned files. The natural one, given the
grounding route cited throughout (GET /api/ads/status with a valid X-Workspace-ID returns 200 with exactly
three PlatformStatus entries: meta, google, linkedin, and makes no outbound platform call) is not itself
part of the assigned source and is named here as a recommendation, not a measured fact. Rollback: the one migration
in scope has a working downgrade() (see above); neither client module has its own deployable state to roll back
beyond the code itself.
Gotchas
- 2026-09-12 (measured at 62ba4fc):
google_ads_service.py'sGoogleAdsServiceis instantiated exactly once at module scope (google_ads = GoogleAdsService(), line 530) and its access-token cache is two instance attributes,self._access_token/self._token_expires_at(76 to 78), checked in_get_access_tokenpurely by expiry time (196 to 199) with no check at all on whichrefresh_tokenargument produced the cached value. Two workspaces whose conversion uploads land on this shared singleton within the same cache window will silently reuse each other's access token. This is precisely the failure modegoogle_ads_manage.py's own module docstring names when explaining whyGoogleAdsClientexists (9 to 13: "avoids the global-singleton cross-tenant cache bug in services.google_ads_service"); the bug it warns about was not fixed in the older file, only avoided by writing a new one beside it.google_ads_service.pyis not dead code:services/forms/lead_integration.py(480) andapi/routers/tracking_router.py(927) both import the singleton and callsend_lead_event/upload_lead_conversionon it for Enhanced Conversions uploads, a capability outside this recipe's Purpose but very much live at this commit. Nothing in this recipe's assigned test files exercises this file at all. - 2026-09-12 (measured at 62ba4fc): Meta has no refresh-token flow, no cache, and no 401-retry anywhere in the
three assigned adapter files or in
oauth_token_crypto.SCALAR_TOKEN_FIELDS(45 to 50, which lists alinkedin_refresh_tokenand agoogle_ads_refresh_tokenbut nometa_refresh_token).meta_adapter.pytreatstracking.meta_access_tokenas a value that just works. This is consistent with Meta's OAuth model using a long-lived (~60-day) token exchanged once at connect time rather than a short-lived token backed by arefresh_tokengrant, but that exchange happens inapi/routers/meta_oauth_router.py, a file outside this recipe's six assigned sources, so this recipe can describe the absence of a Meta refresh engine but not, from its own sources, fully explain why one isn't needed. - 2026-09-12 (measured at 62ba4fc): encryption-at-rest for these tokens is fail-open.
oauth_token_crypto._get_cipher()(57 to 104) returnsNone(silently falling back to storing and reading plaintext) wheneverCALENDAR_ENCRYPTION_KEYis unset andENVIRONMENTis notdevelopment; only alogger.warningmarks the difference. A production deployment that forgets this one env var stores every ad-platform OAuth token in cleartext with no error, no startup check, and no test in the assigned files that would catch it (test_google_ads_manage.pyandtest_linkedin_ads.pynever importoauth_token_cryptoat all). - 2026-09-12 (measured at 62ba4fc): the Google Ads API version is pinned in two different places with two
different values for two different capabilities that share the same OAuth app credentials:
google_ads_manage.py(35 to 36, the connections/campaign-management client this recipe is mostly about) pinsv23;google_ads_service.py(73 to 74, the older Enhanced Conversions client) pinsv19. Not itself a bug in either file individually, but a real fact about this codebase's current state that a rebuild starting from "the Google Ads API version" as a single stack assumption would get wrong. - 2026-09-12 (measured at 62ba4fc): the default MCC (Manager Account) id used for delegated Google Ads access is a
hardcoded string literal, duplicated across three files:
google_ads_manage.py41,google_adapter.py33, andapi/routers/ads_router.py348 (outside scope, cited only), rather than read once fromcore.config.settings. The recipe does not reproduce that literal (house rule: no client-specific identifiers in prose); a rebuild should read it as "a single hardcoded MCC id, copy-pasted three times," not infer any particular digits from here.
Reference implementation
Paths relative to the repo root; all at main 62ba4fc41009b454e1c033e5036c7e8577db2061 unless noted.
Primary (the six assigned source files plus their two assigned test files):
alchemy_labs/backend/services/google_ads_manage.py(616 lines): module docstring (1 to 17); errors andparse_google_error(53 to 102);strip_customer_id/_oauth_client_credentials/default_developer_token(109 to 136); the per-connection token cache (145 to 167);GoogleAdsClient(173 to 310); partial-failure parsing (317 to 350); the GAQL builder and its whitelists (357 to 616, out of this recipe's Purpose, in the same file).alchemy_labs/backend/services/linkedin_ads_service.py(813 lines): module docstring, esp. the auth model (1 to 34); pure RestLi/URN helpers (109 to 250);LinkedInAdsService._refresh_access_token/_request(265 to 380); the rest of the file (391 to 810) is the campaign/DMP-segment surface built on top of this recipe's auth engine, out of Purpose but the concrete "every other ads capability" this recipe's Purpose refers to for LinkedIn.alchemy_labs/backend/services/google_ads_service.py(530 lines, whole file in scope as the documented anti-pattern): the singleton instantiation (530); the instance-attribute cache (76 to 78);_get_access_token(176 to 231);upload_lead_conversion/send_lead_event(290 to 526), the live capability this client actually serves, outside this recipe's Purpose.alchemy_labs/backend/services/campaign_setup/adapters/google_adapter.py(312 lines): credential resolution (43 to 99);validate/build_payload(104 to 175); thecreate()chain that callsGoogleAdsClient.mutatein commit order (budget, campaign, criteria, ad group, keywords, RSA), 180 to 313.alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py(203 lines): credential resolution (41 to 52);validate/build_payload(55 to 153);create()(155 to 203).alchemy_labs/backend/services/campaign_setup/adapters/meta_adapter.py(159 lines): credential resolution andvalidate(29 to 54);build_payload(56 to 124);create()(126 to 159), the one adapter with no per-tenant refresh engine to document.alchemy_labs/backend/tests/test_google_ads_manage.py(509 lines):TestGoogleAdsClient(389 to 509) is this recipe's subject; the rest of the file (55 to 368) tests the GAQL/bidding/schema layer out of Purpose.alchemy_labs/backend/tests/test_linkedin_ads.py(774 lines):TestRequestHelper(395 to 531) is this recipe's subject;TestDmpSegments/TestCompanyDmpSegments/TestTypeaheadAndCompanyResolve(538 to 774) and the pure RestLi builder tests (39 to 250) exercise the capability layer built on top, out of Purpose.
Supporting (not among the six assigned files; read to ground Data model, Security & tenancy and Deploy & ops, cited explicitly wherever used above rather than folded into the primary claims):
alchemy_labs/backend/services/workspace/models.py82 to 96 (WorkspaceSettingstable definition).alchemy_labs/backend/services/tracking_settings_service.py(340 lines; thetrackingdict's only typed reader).alchemy_labs/backend/services/security/oauth_token_crypto.py(211 lines; the Fernet encrypt/decrypt helper).alchemy_labs/backend/migrations/versions/20260610_0002_encrypt_ad_oauth_tokens.py(the one migration).alchemy_labs/backend/api/routers/ads_router.py1 to 63, 225 to 310, 1051 to 1148 (GET /status,GET /accounts, and the_google_is_connectedhelper).alchemy_labs/backend/api/routers/meta_oauth_router.pyline 10, 379 to 441 (the long-lived-token exchange comment andget_connection_status, named in Gotchas, not walked in detail).alchemy_labs/backend/app_fastapi.py111 to 116, 863 to 868 (router mounts, groundingmaturity: prod).
Deviations of the reference from an ideal implementation, all dated 2026-09-12: a second, cross-tenant-unsafe
Google token cache (google_ads_service.py) still live in production for a capability this recipe doesn't cover;
no Meta refresh/retry engine at all; encryption-at-rest that degrades to plaintext silently rather than refusing to
start; two Google Ads API versions pinned in the same backend; a hardcoded MCC id literal duplicated three times
instead of read from settings once; a "connection" identified only by matching a customer_ids list inside a JSON
array, with no stable id of its own.
Acceptance checks
Run from alchemy_labs/backend at 62ba4fc with the repo's own .venv. Expected observations are exact, as
measured 2026-09-12.
.venv/bin/python -m pytest tests/test_google_ads_manage.py tests/test_linkedin_ads.py -q --no-cov -o addopts="" -p no:cacheproviderprints137 passed(plus 32 unrelated Pydantic deprecation warnings), 0 failed..venv/bin/python -m pytest tests/test_google_ads_manage.py -q --no-cov -o addopts="" -p no:cacheproviderprints61 passed; the same fortests/test_linkedin_ads.pyalone prints76 passed.grep -c " def test_" tests/test_google_ads_manage.py tests/test_linkedin_ads.pyprints57and76;grep -c "@pytest.mark.parametrize" tests/test_google_ads_manage.py tests/test_linkedin_ads.pyprints1and0; the one parametrize (TestStripCustomerId.test_rejects_non_numeric, 5 cases) is what takes the Google file'sdefcount of 57 to a passed count of 61.grep -n "^_TOKEN_CACHE" services/google_ads_manage.pyshows it declared asDict[str, Tuple[str, float]](line 145);grep -n "async def _get_access_token" services/google_ads_service.pyshows it declared on theGoogleAdsServiceinstance (line 176), confirming the two files use structurally different caches (module-level dict keyed by token hash vs. instance attributes on a module-level singleton).grep -n "meta_refresh_token" services/security/oauth_token_crypto.pyreturns no match (exit code 1), confirming Meta has no refresh-token field in the one place all encrypted token field names are centralized.grep -c "meta_adapter" tests/test_google_ads_manage.py tests/test_linkedin_ads.pyprints0for both files, confirming no test in the two assigned test files exercises the Meta adapter.git log -1 --format=%H(run fromalchemy_labs) prints62ba4fc41009b454e1c033e5036c7e8577db2061, andgit branch --show-currentprintsmain, confirmingmeasured_onandmaturity: prod.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.