<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tech Auto Lab</title>
    <description>The latest articles on DEV Community by Tech Auto Lab (@techlabautodev).</description>
    <link>https://dev.to/techlabautodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4124383%2F0ab2e1ba-e2d3-4e3c-b189-b1f188b3c5c9.png</url>
      <title>DEV Community: Tech Auto Lab</title>
      <link>https://dev.to/techlabautodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/techlabautodev"/>
    <language>en</language>
    <item>
      <title>I Added Retries to My Automation and It Doubled the Failures</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Sun, 04 Oct 2026 15:08:19 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-added-retries-to-my-automation-and-it-doubled-the-failures-20la</link>
      <guid>https://dev.to/techlabautodev/i-added-retries-to-my-automation-and-it-doubled-the-failures-20la</guid>
      <description>&lt;p&gt;Retries made my Playwright jobs &lt;strong&gt;worse&lt;/strong&gt;, not better: failure rate went from 3.1% to 6.7% in one week. The culprit wasn't flaky code — it was retrying things that should never be retried.&lt;/p&gt;

&lt;p&gt;I found this the hard way after wiring a blanket retry wrapper around every step of my account-automation pipeline. Here's what broke and what actually fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Retries turned "already done" into "done twice"
&lt;/h2&gt;

&lt;p&gt;The worst failure class had nothing to do with the network. A step would time out on the response, my wrapper would retry, and the step would run a second time — because the first attempt had actually succeeded server-side.&lt;/p&gt;

&lt;p&gt;In two days I double-posted to the same account twice and sent a duplicate signup to one platform. No exception, no error log. Just silent duplicates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# WRONG: retry on any exception, no idempotency key
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The rule I actually needed: retry only idempotent actions
&lt;/h2&gt;

&lt;p&gt;I split every step into two buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Safe to retry&lt;/strong&gt; — reads, GETs, checks. Retry freely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsafe to retry&lt;/strong&gt; — writes, posts, sends. Retry only with an idempotency key, or not at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The write steps got an explicit key (account_id + action + date) so a replay would be rejected instead of duplicated.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Backoff without jitter synchronized my retries
&lt;/h2&gt;

&lt;p&gt;Every failed job retried on the same exponential schedule, so a brief API hiccup would make 20 workers hammer the endpoint at the exact same second. Adding jitter — a random 0–500 ms — spread them out and cut the second-order failures by more than half.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Numbers after the fix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Failure rate&lt;/td&gt;
&lt;td&gt;6.7%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate writes&lt;/td&gt;
&lt;td&gt;5 in 2 days&lt;/td&gt;
&lt;td&gt;0 in 7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry storms&lt;/td&gt;
&lt;td&gt;~2/day&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The change that mattered most wasn't the retry count or the backoff curve. It was &lt;strong&gt;deciding what deserves a retry at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I kept the idempotency-key helper in the shared utils module — it's now the default for every write step in the pipeline. Next I'm applying the same split to the signup flow, which still has one hot spot where a captcha timeout re-submits the whole form.&lt;/p&gt;

&lt;p&gt;What's the one place in your automation you retry blindly?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>automation</category>
      <category>playwright</category>
    </item>
    <item>
      <title>I slowed my scraper down 10x and it finally stopped getting banned</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:06:58 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-slowed-my-scraper-down-10x-and-it-finally-stopped-getting-banned-268d</link>
      <guid>https://dev.to/techlabautodev/i-slowed-my-scraper-down-10x-and-it-finally-stopped-getting-banned-268d</guid>
      <description>&lt;p&gt;Faster is not better. I spent two weeks squeezing every millisecond out of my Playwright scraper, and every speedup bought me a new IP ban. The fix was throttling it down to a tenth of the speed — bans dropped to zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that made me stop and re-read my logs
&lt;/h2&gt;

&lt;p&gt;I was running a headless Playwright loop over a public catalog, ~120 requests a minute, no delay, no jitter. The result was predictable: a fresh datacenter IP died in under 40 minutes, and I was burning residential proxy traffic on a loop that wasn't even finishing.&lt;/p&gt;

&lt;p&gt;The log line that broke the illusion: the scraper wasn't failing on the target site at all. It was failing on the &lt;em&gt;rate-limiter&lt;/em&gt; page — a 429 that my retry logic treated as a transient error and hammered even harder. I had built a feedback loop that made the ban faster every time it retried.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Paced requests instead of bursting them.&lt;/strong&gt; I capped the loop at 8 requests per minute with a random 2–6 second sleep between hits. Not a fixed &lt;code&gt;sleep(5)&lt;/code&gt;, but &lt;code&gt;sleep(random.uniform(2, 6))&lt;/code&gt; — fixed delays are a fingerprint too.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stopped retrying on 429.&lt;/strong&gt; A 429 is not "try again in a second", it's "back off or leave". I added an exponential backoff that starts at 60 seconds and doubles, capped at 10 minutes, and it stops the loop entirely after three consecutive 429s.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spread the load across sessions, not threads.&lt;/strong&gt; I had four concurrent browser contexts sharing one IP. Now one context per IP, one page at a time. Throughput per minute went &lt;em&gt;down&lt;/em&gt;, total pages completed per hour went &lt;em&gt;up&lt;/em&gt;, because nothing was getting killed mid-run anymore.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dropped the "faster" micro-optimizations.&lt;/strong&gt; I had parallelized page loads, disabled images, disabled CSS, and used raw HTTP where I could. All of it made the traffic pattern look &lt;em&gt;less&lt;/em&gt; human. I reverted to a normal page load with default timeouts.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The hot-take
&lt;/h2&gt;

&lt;p&gt;The bottleneck was never my code's speed. It was my traffic's &lt;em&gt;shape&lt;/em&gt;. A scraper that finishes 100 pages and gets banned collected 100 pages. A scraper that finishes 50 pages an hour and stays alive collects 500 by morning. Rate limits are not a bug to work around — they're the only signal a site gives you before it silently shadow-bans your IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Measure pages &lt;em&gt;completed per day&lt;/em&gt;, not requests per second. If your success rate is under 100%, you don't have a speed problem — you have a survival problem. Slow the loop down until bans stop, then inch it back up. You'll find the real ceiling is far lower than your code's ceiling, and that's fine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>automation</category>
      <category>playwright</category>
    </item>
    <item>
      <title>I stopped logging in every run and my automation stopped getting flagged</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:06:18 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-stopped-logging-in-every-run-and-my-automation-stopped-getting-flagged-m1p</link>
      <guid>https://dev.to/techlabautodev/i-stopped-logging-in-every-run-and-my-automation-stopped-getting-flagged-m1p</guid>
      <description>&lt;p&gt;Fresh logins are what get you flagged. Reusing a saved session is what keeps the bot alive. I found this out after 41 accounts: the ones I re-logged into every run died first.&lt;/p&gt;

&lt;p&gt;For months my automation treated every browser run like a brand-new visitor. Open MoreLogin, navigate to the login form, type the password, solve the captcha, submit. It worked — until the captchas got harder, the sessions started dying mid-run, and three accounts got flagged in one week. Then I switched to persisting sessions, and the failure rate dropped from roughly 1 in 4 runs to 1 in 30.&lt;/p&gt;

&lt;p&gt;Here's the shift, in the order it mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A warm cookie jar beats a fresh password every time
&lt;/h2&gt;

&lt;p&gt;Every fresh login is a signal: new device, new IP, a burst of navigation, then a form submission. Anti-bot systems are built to notice exactly that. A persisted session looks like a returning human.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BEFORE: log in every run, solve the captcha every run
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... type, solve captcha, submit, hope
&lt;/span&gt;
&lt;span class="c1"&gt;# AFTER: inject a saved session, go straight to work
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storage_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail: reuse the same browser fingerprint and the same state file. Changing either one defeats the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Capture the session once, store it, reuse it until it rots
&lt;/h2&gt;

&lt;p&gt;I capture &lt;code&gt;storage_state&lt;/code&gt; after a successful manual login, save it to a file, and reload it until the session dies. Then — and only then — I log in fresh once and capture again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# capture after the one good login
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storage_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# reuse it across runs until a 401/redirect tells me it's stale
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stale session announces itself clearly: a redirect to &lt;code&gt;/login&lt;/code&gt;, a 401, or a page that should have my name and doesn't. I detect that and re-capture, instead of logging in preemptively.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Keep the session alive with cheap touch requests, not full re-logins
&lt;/h2&gt;

&lt;p&gt;Sessions rot on a schedule, but a light touch resets the clock. A periodic GET on a page the session already owns is far cheaper — and far less suspicious — than a full re-login.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cheap heartbeat: keep the cookie warm without re-authenticating
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;reauthenticate_and_recapture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. One session per identity, never shared
&lt;/h2&gt;

&lt;p&gt;The moment I reused a session across two accounts, both got flagged within a day. A session is a fingerprint of one identity. Sharing it is the fastest way to link two profiles to the same operator.&lt;/p&gt;

&lt;p&gt;The rule I now follow: one state file per account, stored under the account's own name, never touched by another job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently next time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Capture the session on the very first login, before doing anything else.&lt;/li&gt;
&lt;li&gt;Detect staleness by URL or a marker element, never by a timer guess.&lt;/li&gt;
&lt;li&gt;Never share a state file between accounts — isolate by name from the start.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The takeaway that surprised me most: the bot didn't get more stealthy by acting smarter. It got safer by stopping the one thing that screams "automation" — logging in over and over again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm logging this automation series build-in-public — which is cheaper for you in practice: a fresh login per run, or a persisted session you refresh once in a while? Tell me in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>playwright</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I deleted 200 lines of Playwright waits and my automation got more stable</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Fri, 25 Sep 2026 14:14:43 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-deleted-200-lines-of-playwright-waits-and-my-automation-got-more-stable-1m2b</link>
      <guid>https://dev.to/techlabautodev/i-deleted-200-lines-of-playwright-waits-and-my-automation-got-more-stable-1m2b</guid>
      <description>&lt;p&gt;Your automation isn't flaky because it's missing sleeps. It's flaky because you keep adding them.&lt;/p&gt;

&lt;p&gt;I spent my first weeks of browser automation treating Playwright like Selenium: &lt;code&gt;time.sleep(2)&lt;/code&gt; after every click, &lt;code&gt;wait_for_timeout&lt;/code&gt; sprinkled everywhere, and a wall of &lt;code&gt;try/except&lt;/code&gt; around everything that might race. The scripts worked on my machine and died at 3 a.m. in production.&lt;/p&gt;

&lt;p&gt;Then I deleted nearly all of it. The scripts got faster &lt;em&gt;and&lt;/em&gt; stopped failing. Here's the shift, in the order it matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Stop waiting for elements. Wait for state instead.
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;sleep&lt;/code&gt; encodes a guess about how long something takes. The guess is always wrong somewhere — a slow CI box, a cold cache, a network hiccup.&lt;/p&gt;

&lt;p&gt;Playwright auto-waits for the element to be actionable &lt;em&gt;if&lt;/em&gt; you use the right locator. The moment I replaced sleeps with state-based waits, the flakiness dropped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BEFORE: guess the timing, hope it's enough
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text=Submit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER: wait for the *outcome*, not the clock
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Submit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome back&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Role-based locators survive redesigns. CSS selectors don't.
&lt;/h2&gt;

&lt;p&gt;My first selectors were pure CSS: &lt;code&gt;#submit-btn&lt;/code&gt;, &lt;code&gt;.login &amp;gt; form &amp;gt; input[3]&lt;/code&gt;. Every site redesign broke them, and I was the one who found out at 2 a.m.&lt;/p&gt;

&lt;p&gt;Switching to role-based locators — &lt;code&gt;get_by_role&lt;/code&gt;, &lt;code&gt;get_by_label&lt;/code&gt;, &lt;code&gt;get_by_text&lt;/code&gt; — was the single biggest stability win. They describe &lt;em&gt;what a user sees&lt;/em&gt;, which barely changes, instead of &lt;em&gt;how the DOM is shaped&lt;/em&gt;, which changes constantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BEFORE: breaks when the class is renamed
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.form__submit--primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# AFTER: survives a redesign
&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Publish&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Web-first assertions catch the failure where it happens, not three steps later
&lt;/h2&gt;

&lt;p&gt;The old way: click, sleep, read some value, compare it yourself, raise your own error. By the time my custom check ran, the actual failure was three steps behind.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;expect&lt;/code&gt; assertions poll for the condition automatically and fail &lt;em&gt;at the step that broke&lt;/em&gt;, with the state of the page in the message. That alone cut my debugging time more than any log line I ever wrote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;

&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_by_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Publish&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_have_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/myhandle/my-slug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The retry belongs at the top, not wrapped around every click
&lt;/h2&gt;

&lt;p&gt;I used to wrap individual actions in &lt;code&gt;try/except&lt;/code&gt; and retry them in place. That hides the real failure: if a click needs a retry, the page is already in a state you didn't expect.&lt;/p&gt;

&lt;p&gt;The correct place for a retry is the &lt;em&gt;whole task&lt;/em&gt; — re-run the job from a clean context. If the task is idempotent, a clean retry fixes far more than a hundred in-place &lt;code&gt;except&lt;/code&gt;s.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;run_job&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JobError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently next time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write role-based locators from the first line, not as a refactor.&lt;/li&gt;
&lt;li&gt;Ban &lt;code&gt;wait_for_timeout&lt;/code&gt; in review unless the wait has a comment saying why no state condition exists.&lt;/li&gt;
&lt;li&gt;Make every automated task idempotent so the clean retry is actually safe.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The takeaway that surprised me most: less code, fewer sleeps, and a browser that waits for state instead of clocks — that's the whole trick. The flakiness was never about timing. It was about describing what I wanted instead of guessing how long it would take.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which wait pattern still bites you in production? Tell me in the comments — I'm logging this series build-in-public and I read every one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>playwright</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Bugs That Killed My Automation Fleet Never Threw an Error</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:12:21 +0000</pubDate>
      <link>https://dev.to/techlabautodev/the-bugs-that-killed-my-automation-fleet-never-threw-an-error-4abg</link>
      <guid>https://dev.to/techlabautodev/the-bugs-that-killed-my-automation-fleet-never-threw-an-error-4abg</guid>
      <description>&lt;p&gt;The scary part of running an automation fleet isn't the error you see — it's the one that never throws. My accounts didn't crash when they failed. They quietly poisoned themselves, and I only noticed days later when the ban wave landed.&lt;/p&gt;

&lt;p&gt;Over the last month I scaled from one headless browser to 40 concurrent accounts. Along the way I found that the three costliest bugs produced no stack trace at all. Here they are, in the order they bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. reCAPTCHA v3 fails without a checkbox
&lt;/h2&gt;

&lt;p&gt;There is no "I am not a robot" to click. The score just decays silently on every request, and by the time a form rejects you, the account is already flagged. I logged token scores for a week to prove it: clean manual flows sat around 0.9, my automated ones averaged 0.3 — and the worst part is the requests still returned 200 the whole time.&lt;/p&gt;

&lt;p&gt;The fix wasn't a solver. It was treating every action like a gesture: slower typing, real pauses, and keeping one session inside one geographic identity for its whole lifetime. The score crawled back up to 0.7 without touching the captcha code.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The confirm-by-link flow broke accounts that looked fine
&lt;/h2&gt;

&lt;p&gt;Signup forms were solved long ago. The step that silently killed 6 accounts was email confirmation: I clicked the confirmation link in the wrong browser context, and the platform bound the confirmation to the wrong session. No error, no warning — the account just stayed unverified forever, and every post after that hit a wall I couldn't see.&lt;/p&gt;

&lt;p&gt;The rule that stopped it: one context per email, and confirm inside the same session that submitted the form. Boring, but it removed an entire failure class.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. I had no idea which accounts were actually healthy
&lt;/h2&gt;

&lt;p&gt;After the first ban wave I realized I couldn't answer the simplest question: which of my 40 accounts still worked? I had logs of commands, but nothing tying an account to its IP, its fingerprint, and its last real outcome.&lt;/p&gt;

&lt;p&gt;The single most useful thing I built was a 20-line audit log — one row per run: account, platform, IP, fingerprint hash, outcome. When the next wave hit, I diffed locked vs. alive in five minutes instead of guessing for a day. The log didn't make anything faster; it made everything &lt;em&gt;visible&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Warm-up that isn't measured isn't warm-up
&lt;/h2&gt;

&lt;p&gt;I "warmed up" accounts by waiting 24 hours and called it done. Then I looked at the data and found the accounts I'd actually &lt;em&gt;used&lt;/em&gt; during warm-up — one real read, one profile edit — survived at twice the rate of the ones that just sat idle. Passive waiting did almost nothing; light real activity did the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Two accounts, one platform, same text = a repeat, not scale
&lt;/h2&gt;

&lt;p&gt;I caught myself cross-posting near-identical text to two accounts on the same site. It wasn't banned, it was just noise — and the moment anyone compared the feeds it would have been an obvious duplicate. Same platform needs a different angle, or it doesn't need to happen.&lt;/p&gt;




&lt;p&gt;The lesson I'd compress it all into: &lt;strong&gt;the bugs that kill an automation fleet are the ones that never throw an error.&lt;/strong&gt; If you can't tell which accounts are healthy at a glance, you're running blind no matter how many proxies you have.&lt;/p&gt;

&lt;p&gt;If you're scaling your own fleet, build the audit log before you build the automation. Visibility first, proxies later — that order would have saved me a full week.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>playwright</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Automated reCAPTCHA in Playwright and the Proxy Was the Real Fix</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Sat, 19 Sep 2026 13:26:04 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-automated-recaptcha-in-playwright-and-the-proxy-was-the-real-fix-30mf</link>
      <guid>https://dev.to/techlabautodev/i-automated-recaptcha-in-playwright-and-the-proxy-was-the-real-fix-30mf</guid>
      <description>&lt;p&gt;I spent three days trying to get a headless Playwright bot past reCAPTCHA. I assumed the captcha was the hard part. It wasn't — the IP reputation was. Here are the five things that actually moved the needle, in the order I learned them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your datacenter IP is already burned before the page loads
&lt;/h2&gt;

&lt;p&gt;The first run failed on a clean datacenter IP before the captcha even rendered. The challenge never appeared; the request just came back flagged. That's the reputation layer doing its job: the moment the datacenter ASN shows up, the score is already too low for any token to matter.&lt;/p&gt;

&lt;p&gt;The fix was a residential proxy pinned to the target region. Same browser, same script, same sitekey — one IP swap later and the challenge finally showed up on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Solve the token server-side, not with a click simulator
&lt;/h2&gt;

&lt;p&gt;Once the challenge was visible, I dropped the idea of clicking tiles with Playwright. You don't need to interact with the widget at all. You send the sitekey and the page URL to a solving API, get back a response token, and inject it into the hidden field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;solver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sitekey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sitekey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;k =&amp;gt; document.getElementById(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;g-recaptcha-response&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;).value = k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then submit the form as a human would. The token is what the backend validates; how the checkbox looks is irrelevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The token is bound to the IP that requested it
&lt;/h2&gt;

&lt;p&gt;This is the one that cost me a full evening. I solved the token on one connection and submitted the form through a different proxy. Rejected every time. reCAPTCHA binds the token to the IP that opened the challenge, so the solve and the submit have to come from the same exit node.&lt;/p&gt;

&lt;p&gt;I moved the solver call to run from the same residential session as the browser, and the rejections stopped cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Reuse the session, not just the token
&lt;/h2&gt;

&lt;p&gt;A fresh cookie jar gets challenged on every single page load. Once a session passes one challenge, the cookie carries that trust for a while. I started persisting the browser context between runs instead of tearing it down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;storage_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... do work ...
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storage_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Warm sessions hit fewer challenges, which means fewer tokens, which means fewer paid solves and a much faster loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A proxy that dies mid-session is worse than no proxy
&lt;/h2&gt;

&lt;p&gt;I had a residential provider that rotated the exit node every few minutes. Every rotation was a new IP, a new reputation, a new challenge. The "helpful" rotation was actively breaking my solves. I switched to sticky sessions — one IP held for the whole run — and the error rate dropped to near zero.&lt;/p&gt;




&lt;p&gt;The lesson I'd compress it all into: &lt;strong&gt;the captcha is a reputation check, not a puzzle.&lt;/strong&gt; Spend your effort on the IP and the session, and let a solver handle the token. That order of priorities saved me more time than any widget interaction ever did.&lt;/p&gt;

&lt;p&gt;If you're automating anything with reCAPTCHA, start with sticky residential sessions and server-side token injection — the solver and the clicks come last.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>playwright</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Automated Cross-Posting to 5 Platforms and Broke 3 Things</title>
      <dc:creator>Tech Auto Lab</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:29:28 +0000</pubDate>
      <link>https://dev.to/techlabautodev/i-automated-cross-posting-to-5-platforms-and-broke-3-things-772</link>
      <guid>https://dev.to/techlabautodev/i-automated-cross-posting-to-5-platforms-and-broke-3-things-772</guid>
      <description>&lt;p&gt;Last month I built a small pipeline that takes one draft and publishes it to five platforms with per-site formatting. The idea was to write once, publish everywhere. What I got instead was three distinct failure modes that only showed up when the volume scaled.&lt;/p&gt;

&lt;p&gt;Here's what broke, in order, and what fixed each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The shared-draft collision
&lt;/h2&gt;

&lt;p&gt;I used one JSON file as the handoff between the writer and the publisher. Two publisher tasks ran in parallel, both reading the same file. One finished, the other overwrote it — and the second platform got the &lt;em&gt;first&lt;/em&gt; platform's draft.&lt;/p&gt;

&lt;p&gt;The fix was boring and correct: drop the shared file entirely. Each draft now lives in a database row with its own id, and the publisher reads by id. One writer, one row, no shared mutable state.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Markdown that isn't Markdown
&lt;/h2&gt;

&lt;p&gt;Each platform parses Markdown slightly differently. Code fences that rendered fine on one site got mangled on another — inline code lost its backticks, headings collapsed, a numbered list became a paragraph.&lt;/p&gt;

&lt;p&gt;The fix wasn't a universal formatter. It was a per-platform template: the same body, but headers, fences, and tags rewritten to each site's dialect before send. Formatting is now a field on the mechanic, not an assumption in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rate limits that only fire at cadence
&lt;/h2&gt;

&lt;p&gt;Single posts sailed through. Publishing two or three a week to the same platform was fine — until I hit the same platform twice in one hour. Then the second post silently throttled, no error, just a post that never surfaced.&lt;/p&gt;

&lt;p&gt;The fix was a scheduler with a real cadence: each pair gets a minimum gap, and no two posts share an hour. Slowing the loop down fixed more than any retry logic ever did.&lt;/p&gt;




&lt;p&gt;The three lessons compress to one rule: &lt;strong&gt;never let two parts of the pipeline share mutable state, and never let the scheduler run faster than the platform's patience.&lt;/strong&gt; Everything else is just plumbing.&lt;/p&gt;

&lt;p&gt;If you're building something similar, I'd start with the database row and the per-platform template — the scheduler you can tune later.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>playwright</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
