Phabricator Conduit API Python urllib HTTP 403 Forbidden on maniphest.edit
Filing a Phabricator task programmatically against a Wikimedia-hosted Phabricator with a valid API token failed twice, for two unrelated reasons, neither of which the error text points at.
Failure 1 - HTTP 403 Forbidden. A urllib.request.urlopen POST to /api/maniphest.edit raised:
urllib.error.HTTPError: HTTP Error 403: ForbiddenThis is an HTTP-level rejection, so it looks like a permissions or token-scope problem. It is not: conduit reports auth failures as HTTP 200 with a JSON body ({"error_code": "ERR-INVALID-AUTH", ...}), so any 403 from conduit is coming from in front of the application. The same token via curl worked and authenticated correctly:
$ curl -sS https://phabricator.example.org/api/user.whoami -d "api.token=$TOK"
{"result":{"userName":"...","realName":"..."},"error_code":null}Isolated it with a read-only call, which removes write permissions as a variable entirely:
urllib user.whoami (default UA) -> FAIL HTTPError 403
urllib user.whoami (descriptive UA) -> OK
curl user.whoami -> OKFailure 2 - after fixing the UA, conduit rejected the payload. Passing the transaction list as a JSON string (transactions=[{"type":"title",...}] via json.dumps) returned HTTP 200 with:
ERR-CONDUIT-CORE: Parameter "transactions" is not a list of transactions.Misleading because the value was a valid JSON list of transaction objects, and conduit does accept JSON-encoded values for some other parameters, so partial JSON support makes this look like a schema mistake in the transaction objects rather than an encoding-level rejection.
Two independent fixes.
1. Send a descriptive User-Agent. Wikimedia infrastructure blocks default library user agents (Python-urllib/3.x) with a flat HTTP 403, per the Wikimedia Foundation user-agent policy (findable on foundation.wikimedia.org). This applies to Phabricator, not just the wiki APIs. curl gets through, which is what makes it look like a token problem when you compare the two.
UA = 'myproject/1.0 (https://example.com/; contact)'
req = urllib.request.Request(url, data=body, headers={'User-Agent': UA})Diagnostic that separates transport from permissions in one step: call a read-only method (user.whoami) from the failing client. Still 403 -> transport/UA. Returns your username -> the token is fine and the problem is the request encoding or the write itself.
2. Encode transactions as PHP-style nested form keys, not JSON. Conduit wants transactions[0][type]=title&transactions[0][value]=..., which is what every curl example in the Phabricator docs shows. A json.dumps'd list arrives as a scalar string and fails the "is not a list of transactions" check.
def _flatten(prefix, obj, out):
if isinstance(obj, dict):
for k, v in obj.items():
_flatten('%s[%s]' % (prefix, k), v, out)
elif isinstance(obj, (list, tuple)):
for i, v in enumerate(obj):
_flatten('%s[%d]' % (prefix, i), v, out)
else:
out[prefix] = obj if isinstance(obj, str) else json.dumps(obj)
flat = {'api.token': token}
_flatten('transactions', [
{'type': 'title', 'value': title},
{'type': 'description', 'value': body},
{'type': 'priority', 'value': 'normal'},
{'type': 'projects.add', 'value': [phid_a, phid_b]}, # nested list is fine
], flat)Resolve project PHIDs first with project.search (constraints[query]=<name>); projects.add takes PHIDs, not names.
Verify afterwards, and check for duplicates. Both failures above abort before any write, so nothing is created - but confirm rather than assume, because a retry loop around a partially-successful write is how you end up with two tasks. Re-read the created task and diff the description against your local source:
# maniphest.search constraints[ids][0]=<id>, then compare
assert remote_description.strip() == local_body.strip()Also list your own recent authored tasks (constraints[authorPHIDs][0]=<your phid>, order=newest) to prove only one exists. Note the author PHID must come from user.whoami - guessing it silently returns an empty result set, which reads as "no duplicates" for the wrong reason.