My first pull request to Apache Airflow was closed on May 6. Not rejected on the merits. Closed for inactivity, after eight days of me not answering a maintainer.
The bug was real, the root cause was correct, and the patch worked. None of that mattered, which is the part worth writing down.
The bug
Airflow issue #57984. Open the connection form, pick Snowflake, and type a value directly into the raw Extras JSON field:
{ "account": "1234" }
Leave the dedicated Account widget blank. Save.
The value is gone. No error, no warning, no log line. The form reports success and the connection now has an empty account.
There was a second half. Saving any Snowflake connection injected {"insecure_mode": false} into Extras whether you asked for it or not.
Why this class of bug is the bad one
Nothing fails at save time. It fails later, at DAG runtime, as an authentication error that points at Snowflake and gives you no reason to suspect a form handler. The gap between the cause and the symptom is where the hours go.
Silent writes that discard user input are worse than crashes. A crash tells you where to look.
The root cause
Airflow’s connection form merges two sources of truth: the dedicated per-provider widgets, and the raw Extras JSON blob the user can type into directly. process_form() reconciles them.
The merge loop walked every dedicated widget and wrote its value into extras unconditionally. Shaped roughly like this:
for name, widget_value in widget_fields.items():
extras[name] = widget_value # always writes
Two consequences fall out.
A blank string widget writes an empty value over whatever the user typed in the raw JSON. That is the deleted account.
An unchecked checkbox is worse. WTForms renders unchecked BooleanField as False, not None. A field the user never touched is indistinguishable from a field the user deliberately set to false, if all you have is the value. That is where insecure_mode: false came from on every save.
The whole bug reduces to one missing distinction: did the user set this, or is this just the default?
The part that took longest
The fix is to skip a widget when it sits at its default. Empty string for text fields, False for booleans.
Which means you need to know a field is a boolean. And this is where WTForms gets in the way.
WTForms does not store instantiated field objects on the form class. It stores UnboundField wrappers, which are deferred constructors that only become real fields when the form is instantiated. So the obvious check fails:
isinstance(field, BooleanField) # False. It's an UnboundField.
You have to reach through the wrapper to the class it will eventually construct:
field.field_class is BooleanField
That one attribute was most of the debugging time. Everything after it was mechanical.
Final patch: 143 lines added, 5 removed, across 2 files. Only write a widget value into extras if the user actively set it. Skip empty strings, skip False, leave raw extras keys untouched at widget default.
Then it was closed
Here is the honest accounting.
I targeted the wrong base branch. I opened against apache:v2-11-stable, because 2.11 was what I was running. The 2.11 line was not planned for further releases. A fix merged into a branch that never ships is not a fix. The v2 code still lives under airflow-core/ on the development branch, so the change applied there with very little work. I simply never checked the project’s release policy before choosing a base. Fifteen minutes of reading would have caught it.
I did not run the project’s own checks. Static checks failed. Docs build failed, both --docs-only and --spellcheck-only. Both are reproducible locally, with commands the project documents plainly:
prek run --stage pre-commit
breeze build-docs
I ran neither before opening. A maintainer cannot evaluate a patch they cannot get green, so CI status is not paperwork around the contribution. It is part of the contribution.
Then I went quiet. The triage comment was explicit: this is not a rejection, just an invitation to bring the PR up to standard. No rush. I did not answer. Eight days later it was closed to keep the review queue clean, with a note that I was welcome to reopen whenever I resumed work.
That last one is the actual failure. The first two are things I did not know. The third is something I knew and did not do.
What I got wrong, precisely
Not the analysis. The analysis holds up.
What I got wrong was treating the pull request as the deliverable. It is not. It is the opening turn in a conversation with maintainers who owe you nothing, on a project with 46,000 stars and 527 open pull requests. The patch was maybe a third of the work. The rest was branch discipline, green CI, and answering when someone replies.
I also disclosed that the patch was co-authored with an AI tool, which Airflow asks about directly in the PR template. I stand by the disclosure and by using the tool. It helped me write the patch. It did not read the contributing guide for me, and the contributing guide is where I actually failed.
One detail I did not expect
The first review pass came from an AI-assisted triage tool, labeled as such at the bottom of the comment.
My first reaction was that it felt impersonal for a first contribution. Then I read it again. Every item was correct. Every item was specific. Every item shipped with the exact command to reproduce it locally. Airflow documents this as a deliberate two-stage process, so that maintainer attention goes to the conversation rather than to mechanical checks a machine can run.
Sitting with that longer: the bottleneck in open source was never diagnosis. It was attention. A tool that handles the mechanical pass so a human can spend their scarce time on judgment is not a downgrade of the review. It is what makes the human review possible at all.
I received a better first review than my patch deserved, and I did not answer it.
Where this goes
I am rebasing onto the development branch, greening the static checks and the docs build, and reopening. The door was left open explicitly, which is more grace than the queue owes anyone.
Three things I would tell myself in April:
- Check the release policy before picking a base branch. A stable branch near end of life accepts nothing, no matter how good the patch is.
- Run the project’s own checks locally before opening. Not after someone tells you they are red.
- Answer the review. Slowly is fine. The maintainer said “no rush” and meant it. Silence is the only response that closes the PR.
The bugs that matter most are the ones that are silent. Turns out that applies to contributors too.