fix(blender): send a real User-Agent so Cloudflare stops blocking the add-on
Deploy / deploy (push) Successful in 5m59s

Submitting failed with a 403 before the request reached the server:
Cloudflare error 1010, a browser-signature ban. Python's urllib
identifies itself as "Python-urllib/3.x", which is exactly the scripted
signature that rule targets. The AE panel was unaffected because curl
sends curl/8.x, and the worker because .NET sends its own.

Verified against the live zone, same request and token, only the agent
changed: Python-urllib/3.11 gets 403 while VFXReviewBlender/1.0.1 gets
200 — as do curl and a browser agent.

Naming the tool is also the honest thing: the studio's logs should say
what is calling them, and the version travels with it.

A Cloudflare block is also no longer reported to the artist as a wall of
JSON about browser signatures — it says the firewall stopped the request
before it reached the server, and to ask the pipeline TD.

Add-on 1.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-15 19:11:52 +02:00
parent 70cf45ead4
commit 3733c0c4fa
2 changed files with 32 additions and 3 deletions
+8
View File
@@ -86,3 +86,11 @@ nothing in the UI sets them today — one place for render settings is the point
**Exit code 0 with no frames is treated as a failure.** Blender exits cleanly
when a frame range is empty or an output path is unwritable, so the worker
checks that frames were actually saved before reporting success.
**The add-on sends its own User-Agent** (`VFXReviewBlender/<version>`). Python's
`urllib` otherwise identifies itself as `Python-urllib/3.x`, which Cloudflare
blocks outright as a scripted signature — a 403 with error 1010 before the
request ever reaches the server. Verified against the live zone: `Python-urllib`
gets 403, the add-on's own agent gets 200. If a future Cloudflare rule blocks it
anyway, the add-on says so in plain language rather than showing the raw JSON,
and the fix is a WAF allow rule on the agent or on `/api/ext/*`.
+24 -3
View File
@@ -1,7 +1,7 @@
bl_info = {
"name": "VFXReview — Submit Render",
"author": "Two Tales",
"version": (1, 0, 0),
"version": (1, 0, 1),
"blender": (3, 6, 0),
"location": "Properties > Output > VFXReview",
"description": "Queue this .blend on the studio render farm instead of rendering locally",
@@ -48,6 +48,10 @@ def prefs():
return bpy.context.preferences.addons[__name__].preferences
ADDON_VERSION = ".".join(str(n) for n in bl_info["version"])
USER_AGENT = "VFXReviewBlender/%s (Blender %s)" % (ADDON_VERSION, bpy.app.version_string)
# ── API ───────────────────────────────────────────────────────────────────────
def api_request(path, payload=None, method="GET"):
@@ -61,6 +65,11 @@ def api_request(path, payload=None, method="GET"):
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + p.api_key)
req.add_header("Accept", "application/json")
# urllib identifies itself as "Python-urllib/3.x" by default, which
# Cloudflare blocks outright as a scripted signature (error 1010). Sending a
# real name for this tool is also the honest thing: the studio's logs should
# say what is calling them.
req.add_header("User-Agent", USER_AGENT)
if data:
req.add_header("Content-Type", "application/json")
@@ -70,9 +79,21 @@ def api_request(path, payload=None, method="GET"):
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
try:
message = json.loads(body).get("error", body)
parsed = json.loads(body)
except Exception:
message = body
parsed = {}
# A Cloudflare block is not a pipeline error and reads nothing like one,
# so say what it actually is rather than showing the artist a wall of
# JSON about browser signatures.
if parsed.get("cloudflare_error") or parsed.get("error_code") == 1010:
raise RuntimeError(
"Blocked by Cloudflare before reaching the server (error %s). "
"The studio's firewall needs to allow this add-on — ask the pipeline TD."
% parsed.get("error_code", e.code)
)
message = parsed.get("error", body) if parsed else body
raise RuntimeError("Server said %s: %s" % (e.code, message))
except urllib.error.URLError as e:
raise RuntimeError("Could not reach %s (%s)" % (url, e.reason))