The CLI printed "updated" on all 7 products and 2 of them did not change
A bulk description update reported success on every product. Re-reading the field showed the new text had not persisted on two of them. No error, no retry hint, no pattern. The exit code told me the request was accepted, not that the state changed.
TL;DR · THE FIX
A success line from a third-party CLI is a claim about the request, not about the state. A bulk update printed "Product <id> updated." on 7 of 7 and silently failed to persist on 2. After any write through a tool you did not write, read the field back and compare, and where the change is meant to be public, fetch the live page too. That is what surfaced it here: 5 pages showed the new line and 2 showed nothing.
The symptom
I added a sentence to the description of seven products with a loop over a vendor CLI:
for id in "${ids[@]}"; do
gumroad products update "$id" --description "$new_html"
done
# Product <id> updated.
# Product <id> updated.
# ... seven times, all clean, exit 0 each
Seven success lines. Then I fetched the live pages to screenshot them, and five had the new sentence and two did not. Re-reading the field through the same CLI confirmed it: on two of the seven products the description was still the old one. The tool had reported updating something it had not updated.
What I tried first
I assumed I had built the payload wrong for those two, since the descriptions are HTML and some are longer than others. They were not the two longest, and no character in them needed different escaping. I looked for a pattern and found none: different product sizes, same account, sequential calls in the same loop seconds apart, no rate-limit response, no partial output, nothing in the exit codes. Two out of seven, and if I had run it on three products I might well have gotten three successes and never known.
Then I ran the identical command again, reading the field back before and after each write. It stuck on both. The same request worked on the retry, so the request was fine; the write can silently fail to commit.
What was happening
I do not have the vendor’s internals, so I cannot say why those two writes did not persist. Caching, replication lag on their side, a race between the update and a read model are all plausible and none of them provable from outside.
What I can say is what the CLI’s success line means. Product <id> updated. is printed when the API call returns a 2xx, which says the request was accepted. Whether the field holds the new value when you next read it is a separate question, and on this API the two came apart twice in seven attempts.
This is the same shape as a keepalive returning 200 from a layer that never touched the database and a shutdown flag that exits 0 without shutting anything down. In all three the tool reports truthfully on the thing it observed, and the thing it observed is one layer short of the effect you cared about.
The bulk loop is what made this one worse. Seven success lines scroll past as one green block, and a partial failure inside a uniform block of successes is invisible. Had it failed loudly on two, I would have retried two. Instead I got a clean run and moved on, and I only caught it because I happened to open the live pages afterwards for an unrelated reason.
The fix
Read the field back and compare:
update_and_verify() {
local id="$1" want="$2"
local before after
before=$(gumroad products view "$id" --json | jq -r '.description')
gumroad products update "$id" --description "$want" >/dev/null
after=$(gumroad products view "$id" --json | jq -r '.description')
if [ "$after" = "$want" ]; then
echo "OK $id"
elif [ "$after" = "$before" ]; then
echo "FAIL $id (unchanged, retrying)"; return 1
else
echo "WARN $id (changed to something unexpected)"; return 1
fi
}
Capturing before as well as after is worth the extra call, because it lets the failure message distinguish “the write did nothing” from “the write did something I did not ask for”, and those two want different responses from you.
Where the change is meant to be public, fetch the live page too. The API’s own read is still the vendor’s read, and it can be served from the same place the write went. The public page is the only view that matches what a customer sees:
curl -sk "https://example.gumroad.com/l/$slug" | grep -qF "$marker" \
&& echo "LIVE $slug" || echo "NOT LIVE $slug"
Note the -k: a TLS-intercepting proxy makes a plain curl fail silently and hands you a false negative on every product. That public fetch is what surfaced the problem in the first place, and it stayed in the script afterwards for that reason.
The lesson
A success line from a tool you did not write tells you the request was accepted. Whether the state changed is a separate fact. The gap between the two is usually zero, which is why checking feels like paranoia, and why the one time it is not zero you have already stopped looking.
The rule I apply now to any write through a third-party CLI: the write is not done until a separate read, not the same call’s response body, says so, and if the point of the write was to change something the public can see, the public surface is the read that counts. For bulk operations, which is where this cost me, a partial failure inside a loop of successes is invisible unless each iteration verifies itself. Summarising at the end is not enough, because the summary is built from the same success lines you should not have trusted.
Discussion
Powered by GitHub. Sign in to leave a comment.