Automation Pipeline6 min read

I Guarded Against Propagation Delay — On the Read Path Only

In August I fixed the 404 you get when you list a playlist you just created. Four months later the same spot killed a run with a 409. Same cause, different call: the guard was attached to 'the read', not to 'before propagation finishes' — and both forks were carrying the exact same half.

#automation-pipeline#youtube#gotchas#api#reality-check
Left: on the same propagation delay the read call is guarded while the write call is bare, so it takes a 409. Right: both forks carry only the read half, so diffing them against each other shows nothing
What I fixed was a status code. What I should have fixed was the window it belongs to.

My weekly documentary bot died on its Wednesday run. The video was already published; the crash landed on the last step, playlist assembly.

googleapiclient.errors.HttpError: <HttpError 409 when requesting
https://youtube.googleapis.com/youtube/v3/playlistItems ...
returned "The operation was aborted.">

The line right above it said:

[playlists] created: ... PLAF•••••••••

Create a playlist, then try to put a video in it, and get a 409.

That picture was familiar. I had published a post about this exact cause the week before: the playlist I just created did not exist. That fix shipped in August, to both bots.

One question for you. That bug you fixed last quarter — is the guard attached to the cause, or to the symptom you happened to see that day?

What got fixed, and what didn't

Here is what the code looked like:

# A freshly created playlist is empty. Listing it returns 404 before propagation.
have = set() if fresh else _existing_items(yt, pid)
for vid in vids:
    if vid in have:
        continue
    yt.playlistItems().insert(...).execute()   # bare

The comment is exactly right. "A freshly created one returns 404 before propagation." I knew that, and I skipped the read because of it.

And then on the very next line I write to that freshly created playlist, with nothing protecting it.

A resource that hasn't propagated isn't only missing for reads. It's missing for writes too. Reads say 404, writes say 409 — that is the entire difference. What I should have learned in August was "you cannot touch this resource before propagation finishes." What ended up in the code was "avoid the listing call that returns 404." I took the status code from the incident and left the window behind.

Both forks were carrying the same half

This bot has two forks. Only the niche differs; the engines split from a common ancestor. So I have a habit: fix one, port it to the other. The August 404 fix did land in both.

Which means:

fork A   read guarded    write bare
fork B   read guarded    write bare

Diffing the two forks against each other shows nothing. They are missing the same thing. Sameness between forks catches "did the fix reach both sides"; it cannot catch "was the fix right in the first place."

I do run a fork-drift check — one invariant line per real incident:

("engine/claude_cli.py", "retry a transient failure once", r"for attempt in \(1, 2\)", None),
("engine/discover.py",   "429 is a backoff signal, not a failure", r"Retry-After", None),

There was no playlist line in that table. When I fixed the 404 in August I didn't write an invariant — I had hand-ported it to both sides, so I considered it handled.

Two options here

You can wrap the one call that threw and be done. How far would you go?

  1. Add a 409 retry to playlistItems.insert. Today's crash stops happening today.
  2. Guard the whole window — "before propagation finishes" — and force that fact onto both forks.

I took 2. The code is short:

TRANSIENT = (404, 409, 500, 503)
RETRIES = (2, 5, 15)               # seconds
 
def _retry(request, what, sleep=time.sleep):
    for i, wait in enumerate(RETRIES + (None,)):
        try:
            return request().execute()
        except HttpError as e:
            if wait is None or e.resp.status not in TRANSIENT:
                raise
            sleep(wait)

The last two lines are the point. A failure that isn't a propagation delay (a 403, say) is raised on the first try, and exhausting the retries raises too. Swallow the exception here and a video that never got added looks like "assembly complete." Turning a failure into an empty result is something I have already been burned by badly enough that those lines write themselves now.

Then one line into the fork invariants:

("engine/playlists.py", "retry propagation delay (409/404) on insert-after-create (2026-09-09)",
 r"def _retry", None),

Delete _retry on either side now and the check blocks it.

The check runs without a network

Retry code is hard to verify until you take a real 409 again. So I injected sleep and asserted three things:

  • propagation delays (409, 404) are absorbed — succeeds on the third attempt, waits 2s then 5s
  • a 403 is raised on the first attempt — permissions don't get papered over by retries
  • exhausting the retries ends in failure — no quiet success
[playlists] test 409 - retrying in 2s (1/3)
[playlists] test 404 - retrying in 5s (2/3)
selfcheck OK - 3 retries · transient (404, 409, 500, 503)

The honest part: the retry path has not met a real 409 yet. I ran both channels live, but there was no new playlist to create, so the retry branch never executed. Real evidence arrives the next time a new category reaches two episodes. Until then this is "a fix that passes its checks," not "a fix confirmed in the field."

Three things to check on your own code

  • Pick one bug you fixed last quarter. Is the guard on the cause, or on the status code you saw?
  • Is there another call in that same file where the same cause can surface? (Guarded the read? Check the write. Guarded the list? Check the single fetch.)
  • If you keep forks or copies, where do you write the invariant both must satisfy — as opposed to diffing them against each other?

Conclusion

This wasn't a new bug. It was the other half of the one I fixed in August. And the reason that half survived four months is that I took the symptom home from the incident instead of the cause.

The cost was one run. One video's playlist entry, with publishing already done. A small accident. But when a small accident happens at a spot marked "fixed," the thing worth re-reading is how you fixed it.

Do one thing right now: open a file where you recently wrote "this is fixed," and read whether the cause that comment describes shows up again a few lines below it.

Related