Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/social_accounts/error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
WEBHOOK_TEMPORARY_MESSAGE = "The platform was temporarily unavailable when we asked it to push updates."
WEBHOOK_REJECTED_MESSAGE = "The platform declined to push updates for this account."
WEBHOOK_GENERIC_MESSAGE = "We couldn't set up real-time updates for this account."
WEBHOOK_CAPABILITY_MESSAGE = (
"The platform doesn't allow this app's access level to register push "
"delivery for this account — retrying or reconnecting won't change that; "
"it lifts with elevated platform access (app review)."
)
# Not a platform failure at all: this app could not build a client for the
# platform, which in practice means its app credentials are missing.
WEBHOOK_UNAVAILABLE_MESSAGE = "This platform isn't fully configured, so real-time updates couldn't be set up."
Expand Down Expand Up @@ -180,6 +185,20 @@ class WebhookFailure(NamedTuple):
needs_reconnect: bool


def _is_capability_gate(exc: Exception) -> bool:
"""Graph error code 3 — the app's access tier cannot use the endpoint.

Distinct from a permission problem on the token: a reconnect issues a
same-tier token and a retry replays the same call, so neither remedy the
card offers for other failures applies here. Meta returns it as
``{"error": {"code": 3, "type": "OAuthException", ...}}`` with HTTP 400.
"""
if not isinstance(exc, APIError):
return False
error = (exc.raw_response or {}).get("error")
return isinstance(error, dict) and error.get("code") == 3


def classify_webhook_failure(exc: Exception) -> WebhookFailure:
"""Map a failed webhook subscription to user-facing text and a next step.

Expand All @@ -196,6 +215,9 @@ def classify_webhook_failure(exc: Exception) -> WebhookFailure:
``needs_reconnect`` marks the one class a retry can never fix: an auth
failure means the grant is missing what the subscription needs.
"""
if _is_capability_gate(exc):
return WebhookFailure(message=WEBHOOK_CAPABILITY_MESSAGE, needs_reconnect=False)

kind = _classify(exc)
message = {
_RECONNECT: WEBHOOK_RECONNECT_MESSAGE,
Expand Down
31 changes: 31 additions & 0 deletions apps/social_accounts/tests/test_error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,34 @@ def test_a_publish_error_quoting_a_dict_repr_is_not_passed_through():
def test_an_overlong_publish_error_is_not_passed_through():
assert friendly_publish_error(PublishError("x" * 301)) == PUBLISH_GENERIC_MESSAGE
assert friendly_publish_error(PublishError("x" * 300)) == "x" * 300


def test_capability_gated_api_error_maps_to_capability_copy():
"""Meta's (#3) "Application does not have the capability" is an app-access-
tier gate on subscribed_apps: a reconnect issues a same-tier token and a
retry replays the same call, so neither may be offered as the remedy
(verified in production — a fresh token changed nothing). It must not fall
into the generic REJECTED bucket, whose card offers "Try again"."""
from apps.social_accounts.error_messages import (
WEBHOOK_CAPABILITY_MESSAGE,
WEBHOOK_REJECTED_MESSAGE,
classify_webhook_failure,
)

exc = APIError(
"Instagram API error 400",
status_code=400,
raw_response={
"error": {
"message": "(#3) Application does not have the capability to make this API call.",
"type": "OAuthException",
"code": 3,
}
},
)

failure = classify_webhook_failure(exc)

assert failure.needs_reconnect is False
assert failure.message == WEBHOOK_CAPABILITY_MESSAGE
assert failure.message != WEBHOOK_REJECTED_MESSAGE