Prima Get an API key

Module kit / Ads

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
  • alchemy_labs/backend/services/google_ads_manage.py
  • alchemy_labs/backend/services/linkedin_ads_service.py
  • alchemy_labs/backend/services/google_ads_service.py
  • alchemy_labs/backend/services/campaign_setup/adapters/google_adapter.py
  • alchemy_labs/backend/services/campaign_setup/adapters/linkedin_adapter.py
  • alchemy_labs/backend/services/campaign_setup/adapters/meta_adapter.py
  • alchemy_labs/backend/tests/test_google_ads_manage.py
  • alchemy_labs/backend/tests/test_linkedin_ads.py
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 pointFile:linesNever raisesReturns / side effect
GoogleAdsClient._get_access_token(force_refresh=False)google_ads_manage.py 211 to 233no (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 260no (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/.callgoogle_ads_manage.py 264 to 310noall 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 306no (raises LinkedInAdsError(status_code=401, ...)){access_token, refresh_token?, expires_in, ...} from LinkedIn's token endpoint
LinkedInAdsService._request(...)linkedin_ads_service.py 310 to 380no (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 113yesbool / 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 65yessame shape, checks tracking.linkedin_connected and tracking.linkedin_access_token are both truthy
MetaAdapter.validate(brief, context)meta_adapter.py 43 to 54yessame shape; checks tracking.meta_access_token only, there is no "connected" flag check here, only "is a token present"
GET /api/ads/statusapi/routers/ads_router.py 1051 to 1081 (outside the six assigned files, cited for grounding)n/a200 {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):

  1. 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 than expires_in - 300s is returned without any network call.
  2. On a miss (or force_refresh=True), exchange the refresh token at https://oauth2.googleapis.com/token with grant_type=refresh_token (211 to 233); a non-200 raises GoogleAdsApiError(401, ...) immediately, nothing is cached.
  3. Every mutate/search/call funnels 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 as parse_google_error(resp) rather than looping.
  4. resource_type (mutate) and method_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):

  1. Every method takes access_token/refresh_token/save_tokens as **auth kwargs 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.
  2. _request sends the call with the given access_token (332 to 343). On a 401, only if a refresh_token was given and this is not already a retried call (not _retried), it calls _refresh_access_token once (346 to 364), invokes save_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=True and the new tokens. A second 401 after that recursion raises LinkedInAdsError(status_code=401, ...) without a third attempt (proven by test_401_after_refresh_does_not_loop, consuming exactly three queued responses).
  3. LinkedIn's refresh response may or may not rotate the refresh token; when it doesn't, new_refresh = ... or refresh_token keeps the old one (350) so save_tokens is always called with a usable pair.

Meta (MetaAdapter, no refresh engine in the assigned source):

  1. _creds (30 to 38) reads meta_access_token straight out of context["tracking"] and hands it to retargeting_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:

  1. Every adapter's validate(brief, context) is the actual "is this connection usable" gate the multi-platform campaign_setup orchestrator runs per platform before building a payload (google_adapter.py 104 to 113, linkedin_adapter.py 55 to 65, meta_adapter.py 43 to 54); each returns a list of plain-English error strings rather than raising, so one platform's missing connection never blocks the others.
  2. GoogleAdapter._is_connected (94 to 99) and ads_router._google_is_connected (outside scope, cited for grounding) both implement "connected" the same way: either the legacy google_ads_connected + google_ads_refresh_token pair is set, or google_ads_connections is 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.py 149 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.py 253 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_tokens callback, synchronous or async, before the retried call returns (test_401_refreshes_once_and_persists_rotated_tokens).
  • resource_type and method_path on 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's GoogleAdsService is 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_token purely by expiry time (196 to 199) with no check at all on which refresh_token argument 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 mode google_ads_manage.py's own module docstring names when explaining why GoogleAdsClient exists (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.py is not dead code: services/forms/lead_integration.py (480) and api/routers/tracking_router.py (927) both import the singleton and call send_lead_event/upload_lead_conversion on 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 a linkedin_refresh_token and a google_ads_refresh_token but no meta_refresh_token). meta_adapter.py treats tracking.meta_access_token as 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 a refresh_token grant, but that exchange happens in api/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) returns None (silently falling back to storing and reading plaintext) whenever CALENDAR_ENCRYPTION_KEY is unset and ENVIRONMENT is not development; only a logger.warning marks 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.py and test_linkedin_ads.py never import oauth_token_crypto at 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) pins v23; google_ads_service.py (73 to 74, the older Enhanced Conversions client) pins v19. 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.py 41, google_adapter.py 33, and api/routers/ads_router.py 348 (outside scope, cited only), rather than read once from core.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 and parse_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); the create() chain that calls GoogleAdsClient.mutate in 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 and validate (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.py 82 to 96 (WorkspaceSettings table definition).
  • alchemy_labs/backend/services/tracking_settings_service.py (340 lines; the tracking dict'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.py 1 to 63, 225 to 310, 1051 to 1148 (GET /status, GET /accounts, and the _google_is_connected helper).
  • alchemy_labs/backend/api/routers/meta_oauth_router.py line 10, 379 to 441 (the long-lived-token exchange comment and get_connection_status, named in Gotchas, not walked in detail).
  • alchemy_labs/backend/app_fastapi.py 111 to 116, 863 to 868 (router mounts, grounding maturity: 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.

  1. .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 (plus 32 unrelated Pydantic deprecation warnings), 0 failed.
  2. .venv/bin/python -m pytest tests/test_google_ads_manage.py -q --no-cov -o addopts="" -p no:cacheprovider prints 61 passed; the same for tests/test_linkedin_ads.py alone prints 76 passed.
  3. grep -c " def test_" tests/test_google_ads_manage.py tests/test_linkedin_ads.py prints 57 and 76; grep -c "@pytest.mark.parametrize" tests/test_google_ads_manage.py tests/test_linkedin_ads.py prints 1 and 0; the one parametrize (TestStripCustomerId.test_rejects_non_numeric, 5 cases) is what takes the Google file's def count of 57 to a passed count of 61.
  4. grep -n "^_TOKEN_CACHE" services/google_ads_manage.py shows it declared as Dict[str, Tuple[str, float]] (line 145); grep -n "async def _get_access_token" services/google_ads_service.py shows it declared on the GoogleAdsService instance (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).
  5. grep -n "meta_refresh_token" services/security/oauth_token_crypto.py returns no match (exit code 1), confirming Meta has no refresh-token field in the one place all encrypted token field names are centralized.
  6. grep -c "meta_adapter" tests/test_google_ads_manage.py tests/test_linkedin_ads.py prints 0 for both files, confirming no test in the two assigned test files exercises the Meta adapter.
  7. git log -1 --format=%H (run from alchemy_labs) prints 62ba4fc41009b454e1c033e5036c7e8577db2061, and git branch --show-current prints main, confirming measured_on and maturity: 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.