Under which category would you file this issue?
Airflow Core
Apache Airflow version
3.2.1 (code path still present on main as of this report)
What happened and how to reproduce it?
Summary
VariableBody.value is typed as JsonValue, so the Variables REST API accepts any JSON type (array, object, number, bool). Everything downstream, however, assumes a str. This produces two distinct failures depending on the verb:
| Request |
Result |
POST /api/v2/variables with value as a JSON array |
201 — silently stores the Python repr, i.e. ['a', 'b'], which is not valid JSON |
PATCH /api/v2/variables/{key} with value as a JSON array |
500 Internal Server Error, real exception masked |
The POST case is the more dangerous of the two: it succeeds, so nobody notices until a DAG calls Variable.get(key, deserialize_json=True) and blows up on a value that can no longer be parsed.
Note the inconsistency: a non-string description is correctly rejected with a clean 422. Only value misbehaves.
Reproduction
# 1) POST — silently corrupts (HTTP 201)
curl -X POST "$HOST/api/v2/variables" \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"key":"my_var","value":["a","b"],"description":"d"}'
# -> 201 {"key":"my_var","value":"['a', 'b']", ...}
# json.loads("['a', 'b']") raises JSONDecodeError
# 2) PATCH — 500
curl -X PATCH "$HOST/api/v2/variables/my_var" \
-H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
-d '{"key":"my_var","value":["a","b"],"description":"d"}'
# -> 500 Internal Server Error
For contrast, both verbs behave correctly when value is a JSON string ("[\"a\", \"b\"]") — 200/201 and the stored value round-trips as valid JSON.
Root cause
airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py:
class VariableBody(StrictBaseModel):
key: str = Field(max_length=ID_LEN)
value: JsonValue = Field(serialization_alias="val") # accepts any JSON type
description: str | None = Field(default=None)
team_name: str | None = Field(max_length=50, default=None)
The two verbs then diverge:
PATCH — update_orm_from_pydantic() applies the patch straight onto the ORM instance via BulkService.apply_patch_with_update_mask(). setattr hits the Variable.val setter in airflow-core/src/airflow/models/variable.py:
@val.setter
def set_val(self, value):
if value is not None:
...
self._val = fernet.encrypt(bytes(value, "utf-8")).decode()
bytes(<list>, "utf-8") raises. Verified in-process:
>>> from airflow.models import Variable
>>> v = Variable(key='k'); v.val = ['a', 'b']
Traceback (most recent call last):
File "airflow/models/variable.py", line 103, in set_val
self._val = fernet.encrypt(bytes(value, "utf-8")).decode()
TypeError: encoding without a string argument
POST — goes through Variable.set(), which coerces instead of validating:
if serialize_json:
stored_value = json.dumps(value, indent=2)
else:
stored_value = str(value) # list -> "['a', 'b']"
str() on a list yields Python repr with single quotes, so the stored value is not valid JSON and cannot be recovered by deserialize_json=True.
Middleware masks the real error
The TypeError never reaches the logs. JWTRefreshMiddleware (a Starlette BaseHTTPMiddleware) surfaces only:
File "airflow/api_fastapi/auth/middlewares/refresh_token.py", line 61, in dispatch
response = await call_next(request)
File "starlette/middleware/base.py", line 169, in call_next
raise RuntimeError("No response returned.")
RuntimeError: No response returned.
This made the failure substantially harder to diagnose — the traceback names neither the field nor the underlying TypeError. Same masking behaviour as reported in #68868 (different trigger, team_name: "") and #66889.
Suggested fix
Tighten the schema so the API rejects non-string values with a 422, consistent with description:
value: str | None = Field(serialization_alias="val", default=None)
If JsonValue must be kept for backwards compatibility, add a field_validator that JSON-serialises non-string input (json.dumps) rather than letting str() produce a Python repr, and guard Variable.set_val so it raises a validation error rather than a bare TypeError.
Separately, it would be worth making JWTRefreshMiddleware propagate the underlying exception so these failures are diagnosable from the API server logs.
Operating System
Debian GNU/Linux 12 (official apache/airflow base image, Python 3.11)
Versions of Apache Airflow Providers
apache-airflow-providers-fab (FAB auth manager enabled)
Deployment
Official Apache Airflow Helm Chart
Deployment details
Airflow 3.2.1, Python 3.11, Kubernetes, Postgres metadata DB, FAB auth manager. Reproduced both directly against the api-server pod (localhost:8080) and through the ingress, so it is not proxy-related. Also reproduced with two different users (roles Admin and Op), so it is not permission-related.
Anything else?
Occurs every time. Originally hit via the Web UI when saving a variable whose value was a JSON array; the UI issues 4 retries with backoff, so a single click produces 4 x 500 in the api-server access log.
Are you willing to submit PR?
Code of Conduct
Under which category would you file this issue?
Airflow Core
Apache Airflow version
3.2.1 (code path still present on
mainas of this report)What happened and how to reproduce it?
Summary
VariableBody.valueis typed asJsonValue, so the Variables REST API accepts any JSON type (array, object, number, bool). Everything downstream, however, assumes astr. This produces two distinct failures depending on the verb:POST /api/v2/variableswithvalueas a JSON arrayrepr, i.e.['a', 'b'], which is not valid JSONPATCH /api/v2/variables/{key}withvalueas a JSON arrayThe
POSTcase is the more dangerous of the two: it succeeds, so nobody notices until a DAG callsVariable.get(key, deserialize_json=True)and blows up on a value that can no longer be parsed.Note the inconsistency: a non-string
descriptionis correctly rejected with a clean422. Onlyvaluemisbehaves.Reproduction
For contrast, both verbs behave correctly when
valueis a JSON string ("[\"a\", \"b\"]") —200/201and the stored value round-trips as valid JSON.Root cause
airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py:The two verbs then diverge:
PATCH —
update_orm_from_pydantic()applies the patch straight onto the ORM instance viaBulkService.apply_patch_with_update_mask().setattrhits theVariable.valsetter inairflow-core/src/airflow/models/variable.py:bytes(<list>, "utf-8")raises. Verified in-process:POST — goes through
Variable.set(), which coerces instead of validating:str()on a list yields Pythonreprwith single quotes, so the stored value is not valid JSON and cannot be recovered bydeserialize_json=True.Middleware masks the real error
The
TypeErrornever reaches the logs.JWTRefreshMiddleware(a StarletteBaseHTTPMiddleware) surfaces only:This made the failure substantially harder to diagnose — the traceback names neither the field nor the underlying
TypeError. Same masking behaviour as reported in #68868 (different trigger,team_name: "") and #66889.Suggested fix
Tighten the schema so the API rejects non-string values with a
422, consistent withdescription:If
JsonValuemust be kept for backwards compatibility, add afield_validatorthat JSON-serialises non-string input (json.dumps) rather than lettingstr()produce a Pythonrepr, and guardVariable.set_valso it raises a validation error rather than a bareTypeError.Separately, it would be worth making
JWTRefreshMiddlewarepropagate the underlying exception so these failures are diagnosable from the API server logs.Operating System
Debian GNU/Linux 12 (official
apache/airflowbase image, Python 3.11)Versions of Apache Airflow Providers
apache-airflow-providers-fab (FAB auth manager enabled)
Deployment
Official Apache Airflow Helm Chart
Deployment details
Airflow 3.2.1, Python 3.11, Kubernetes, Postgres metadata DB, FAB auth manager. Reproduced both directly against the api-server pod (
localhost:8080) and through the ingress, so it is not proxy-related. Also reproduced with two different users (rolesAdminandOp), so it is not permission-related.Anything else?
Occurs every time. Originally hit via the Web UI when saving a variable whose value was a JSON array; the UI issues 4 retries with backoff, so a single click produces 4 x 500 in the api-server access log.
Are you willing to submit PR?
Code of Conduct