fix(meta): declare Advantage+ Audience on every ad set's targeting

Fourth failure in the same chain, one object further each time: "Advantage
audience flag required ... setting the advantage_audience flag to either 1
or 0 within the targeting_automation field" (code 100, subcode 1870227).
We were never sending targeting_automation at all.

Defaulting to 0 (off) for the same reason bid_strategy defaults to
LOWEST_COST_WITHOUT_CAP: an ad set should reach the audience it was told
to — the countries/age/gender actually set on the campaign — not whatever
Meta's Advantage+ expansion additionally decides to include.
advanced.advantage_audience (0 or 1) overrides it per campaign.

Ordering note: targeting_automation is added unconditionally, so the
existing "no audience" guard (raises when targeting is empty) had to move
before it — otherwise targeting would never be empty and that guard would
go silently dead. A test pins this: an empty CampaignSpec.targeting must
still be refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-07 18:18:09 +05:30
co-authored by Claude Opus 5
parent 11a42e7afa
commit ddd07d5d16
2 changed files with 74 additions and 4 deletions
+21 -3
View File
@@ -159,15 +159,33 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
if genders:
targeting["genders"] = list(genders)
# Graph requires targeting on every ad set. Catching it here names the
# missing field; letting it through produces an opaque Meta rejection
# part-way into building the object chain.
# Graph requires targeting on every ad set. Checked before
# targeting_automation is added below, which is unconditional and
# would otherwise make `targeting` always non-empty and this guard
# dead code. Catching it here names the missing field; letting it
# through produces an opaque Meta rejection part-way into building the
# object chain.
if not targeting:
raise ValueError(
"CampaignSpec.targeting is empty, so the ad set has no audience. "
"Set at least one of countries, age_min, age_max or genders.",
)
# Graph requires this on every ad set — code 100 / subcode 1870227,
# which is what stopped the connection test once optimisation_goal was
# fixed. Advantage+ Audience lets Meta expand delivery beyond the
# countries/age/gender actually specified. Defaulting to 0 (off) is the
# same call already made for bid_strategy: an ad set matches the
# audience it was told to, not whatever Meta additionally guesses.
# advanced.advantage_audience (0 or 1) overrides this per campaign.
advantage_audience = spec.advanced.get("advantage_audience", 0)
if advantage_audience not in (0, 1):
raise ValueError(
f"advanced.advantage_audience must be 0 or 1, got "
f"{advantage_audience!r}.",
)
targeting["targeting_automation"] = {"advantage_audience": advantage_audience}
optimization_goal = spec.advanced.get("optimization_goal") or (
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get(
spec.objective or "", DEFAULT_OPTIMIZATION_GOAL,
+53 -1
View File
@@ -448,6 +448,7 @@ def test_build_ad_set_payload_maps_targeting_and_budget():
"age_min": 25,
"age_max": 45,
"geo_locations": {"countries": ["US", "CA"]},
"targeting_automation": {"advantage_audience": 0},
}
assert payload["optimization_goal"] == "LEAD_GENERATION"
assert payload["billing_event"] == "IMPRESSIONS"
@@ -485,7 +486,10 @@ def test_build_ad_set_payload_requires_an_audience():
def test_build_ad_set_payload_accepts_any_single_targeting_field():
payload = build_ad_set_payload(_spec(targeting={"countries": ["IN"]}), "c1")
assert payload["targeting"] == {"geo_locations": {"countries": ["IN"]}}
assert payload["targeting"] == {
"geo_locations": {"countries": ["IN"]},
"targeting_automation": {"advantage_audience": 0},
}
def test_build_ad_set_payload_accepts_allowlisted_optimization_goal():
@@ -705,6 +709,54 @@ def test_an_unknown_objective_falls_back_to_the_widest_goal():
assert payload["optimization_goal"] == "LINK_CLICKS"
# ---------------------------------------------------------------------------
# Advantage+ Audience: the field Graph requires on every ad set's targeting
# ---------------------------------------------------------------------------
def test_targeting_automation_defaults_to_advantage_audience_off():
"""Graph rejects an ad set whose targeting spec omits this — code 100 /
subcode 1870227, the failure that stopped the live connection test
once optimisation_goal was fixed.
Off by default for the same reason as bid_strategy: an ad set matches
the audience actually specified, not whatever Meta additionally
expands it to.
"""
payload = build_ad_set_payload(_spec(), campaign_id="c1")
assert payload["targeting"]["targeting_automation"] == {
"advantage_audience": 0,
}
def test_advantage_audience_can_be_turned_on():
spec = _spec(advanced={"advantage_audience": 1})
payload = build_ad_set_payload(spec, campaign_id="c1")
assert payload["targeting"]["targeting_automation"] == {
"advantage_audience": 1,
}
def test_advantage_audience_rejects_anything_but_0_or_1():
spec = _spec(advanced={"advantage_audience": 2})
with pytest.raises(ValueError, match="advantage_audience"):
build_ad_set_payload(spec, campaign_id="c1")
def test_an_empty_audience_is_still_refused_even_though_targeting_automation_is_always_set():
"""targeting_automation is added unconditionally, so `targeting` is
never an empty dict any more. The no-audience guard has to run before
that, or this refusal silently stops firing."""
spec = _spec(targeting={})
with pytest.raises(ValueError, match="no audience"):
build_ad_set_payload(spec, campaign_id="c1")
def test_an_explicit_optimisation_goal_still_wins():
spec = _spec(
objective="OUTCOME_TRAFFIC",