fix(meta): send a bid strategy Graph will accept without a bid amount

With the campaign-level field fixed, the live run got one object further
and failed on the ad set: "Bid amount or bid constraints required for bid
strategy" (code 100, subcode 2490487). We were sending no bid_strategy at
all, and Graph requires one on every ad set that carries its own budget —
which, since MaskanX never uses campaign budget, is all of them.

LOWEST_COST_WITHOUT_CAP is the only strategy that needs nothing else from
us: COST_CAP and LOWEST_COST_WITH_BID_CAP need a bid amount, and
LOWEST_COST_WITH_MIN_ROAS needs bid constraints plus a VALUE optimisation
goal. CampaignSpec carries none of those, so the allowlist holds exactly
one value and build_ad_set_payload refuses the rest before any network
call — a rejection at sync time would land after the campaign object
already exists, leaving a half-built chain behind.

It also suits the guardrails: automatic bidding spends the daily budget
and never exceeds it, where the cap strategies bound unit price rather
than total spend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-07 15:33:26 +05:30
co-authored by Claude Opus 5
parent f752c4fdf3
commit 55a0fca21e
3 changed files with 81 additions and 0 deletions
+4
View File
@@ -430,6 +430,7 @@ class MetaClient:
targeting: dict[str, Any], targeting: dict[str, Any],
optimization_goal: str, optimization_goal: str,
billing_event: str, billing_event: str,
bid_strategy: str = "LOWEST_COST_WITHOUT_CAP",
status: str = CREATE_STATUS, status: str = CREATE_STATUS,
) -> str: ) -> str:
"""Create a paused ad set and return its id. """Create a paused ad set and return its id.
@@ -453,6 +454,9 @@ class MetaClient:
"targeting": json.dumps(targeting), "targeting": json.dumps(targeting),
"optimization_goal": optimization_goal, "optimization_goal": optimization_goal,
"billing_event": billing_event, "billing_event": billing_event,
# Required whenever the ad set carries its own budget, which
# is always here. Omitting it is code 100 / subcode 2490487.
"bid_strategy": bid_strategy,
"status": CREATE_STATUS, "status": CREATE_STATUS,
}, },
) )
+28
View File
@@ -52,6 +52,25 @@ from ..campaigns.models import CampaignSpec
DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION" DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION"
DEFAULT_BILLING_EVENT = "IMPRESSIONS" DEFAULT_BILLING_EVENT = "IMPRESSIONS"
# Graph requires a bid strategy on every ad set that carries its own budget,
# and every strategy except this one additionally requires a bid amount or
# bid constraints. Sending nothing is rejected with code 100 / subcode
# 2490487, which is what blocked the first live sync.
#
# LOWEST_COST_WITHOUT_CAP is automatic bidding: Meta spends the daily budget
# as efficiently as it can and never exceeds it. The alternatives set a
# per-result ceiling instead, which controls unit price but not total spend
# — the opposite of what the guardrails need.
DEFAULT_BID_STRATEGY = "LOWEST_COST_WITHOUT_CAP"
# Only one strategy needs nothing else from us. COST_CAP and
# LOWEST_COST_WITH_BID_CAP need a bid amount; LOWEST_COST_WITH_MIN_ROAS
# needs bid_constraints plus a VALUE optimisation goal. CampaignSpec
# carries none of those, so accepting any of them here would just recreate
# subcode 2490487 at sync time — after the campaign object already exists.
# Widen this only together with the fields the strategy requires.
ALLOWED_BID_STRATEGIES = frozenset({"LOWEST_COST_WITHOUT_CAP"})
# advanced.optimization_goal / advanced.billing_event are client-controlled # advanced.optimization_goal / advanced.billing_event are client-controlled
# and billing_event determines how the ad account is charged, so both are # and billing_event determines how the ad account is charged, so both are
# checked against an allowlist before being forwarded to a spending API. # checked against an allowlist before being forwarded to a spending API.
@@ -141,6 +160,14 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
f"{sorted(ALLOWED_BILLING_EVENTS)}", f"{sorted(ALLOWED_BILLING_EVENTS)}",
) )
bid_strategy = spec.advanced.get("bid_strategy", DEFAULT_BID_STRATEGY)
if bid_strategy not in ALLOWED_BID_STRATEGIES:
raise ValueError(
f"Unsupported bid_strategy {bid_strategy!r}. Allowed: "
f"{sorted(ALLOWED_BID_STRATEGIES)}. The others require a bid "
f"amount or bid constraints, which CampaignSpec does not carry.",
)
return { return {
"campaign_id": campaign_id, "campaign_id": campaign_id,
"name": f"{spec.name} - Ad Set", "name": f"{spec.name} - Ad Set",
@@ -148,6 +175,7 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
"targeting": targeting, "targeting": targeting,
"optimization_goal": optimization_goal, "optimization_goal": optimization_goal,
"billing_event": billing_event, "billing_event": billing_event,
"bid_strategy": bid_strategy,
} }
+49
View File
@@ -602,3 +602,52 @@ async def test_adset_budget_sharing_is_off_by_default_but_overridable():
assert transport.calls[0]["params"]["is_adset_budget_sharing_enabled"] == "false" assert transport.calls[0]["params"]["is_adset_budget_sharing_enabled"] == "false"
assert transport.calls[1]["params"]["is_adset_budget_sharing_enabled"] == "true" assert transport.calls[1]["params"]["is_adset_budget_sharing_enabled"] == "true"
# ---------------------------------------------------------------------------
# bid strategy: the field Graph requires on a budget-carrying ad set
# ---------------------------------------------------------------------------
def test_build_ad_set_payload_defaults_to_automatic_bidding():
"""Graph rejects an ad set that carries its own budget and no strategy.
Code 100 / subcode 2490487 — the failure that stopped the live sync
once the campaign-level one was fixed. LOWEST_COST_WITHOUT_CAP is the
only strategy needing no bid amount from us, and it never exceeds the
daily budget, which is what the guardrails assume.
"""
payload = build_ad_set_payload(_spec(), campaign_id="c1")
assert payload["bid_strategy"] == "LOWEST_COST_WITHOUT_CAP"
def test_build_ad_set_payload_refuses_a_strategy_that_needs_a_bid_amount():
"""These would be accepted here and rejected by Meta *after* the
campaign object exists, leaving a half-built chain in the account.
Refusing before the first network call keeps it clean."""
for strategy in (
"COST_CAP", "LOWEST_COST_WITH_BID_CAP", "LOWEST_COST_WITH_MIN_ROAS",
):
spec = _spec(advanced={"bid_strategy": strategy})
with pytest.raises(ValueError, match="bid_strategy"):
build_ad_set_payload(spec, campaign_id="c1")
@pytest.mark.asyncio
async def test_create_ad_set_sends_the_bid_strategy():
transport = FakeTransport([{"id": "set_1"}])
client = MetaClient(access_token="tok", transport=transport)
await client.create_ad_set(
"act_1",
campaign_id="c1",
name="Set",
daily_budget=5000,
targeting={"geo_locations": {"countries": ["IN"]}},
optimization_goal="LEAD_GENERATION",
billing_event="IMPRESSIONS",
)
sent = transport.calls[0]["params"]["bid_strategy"]
assert sent == "LOWEST_COST_WITHOUT_CAP"