<?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: Ogi</title>
    <description>The latest articles on DEV Community by Ogi (@brokeitwithai).</description>
    <link>https://dev.to/brokeitwithai</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%2F4097201%2F553f3d6e-4cf0-41e7-83cc-e91e393bcce0.jpg</url>
      <title>DEV Community: Ogi</title>
      <link>https://dev.to/brokeitwithai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/brokeitwithai"/>
    <language>en</language>
    <item>
      <title>I thought my pipeline retried 6 times. It was making up to 48 billed calls.</title>
      <dc:creator>Ogi</dc:creator>
      <pubDate>Fri, 28 Aug 2026 09:16:19 +0000</pubDate>
      <link>https://dev.to/brokeitwithai/i-thought-my-pipeline-retried-6-times-it-was-making-up-to-48-billed-calls-5869</link>
      <guid>https://dev.to/brokeitwithai/i-thought-my-pipeline-retried-6-times-it-was-making-up-to-48-billed-calls-5869</guid>
      <description>&lt;p&gt;My publishing pipeline had a retry limit. &lt;code&gt;MAX_TOPIC_ATTEMPTS = 6&lt;/code&gt;. If a draft failed validation — too short, dead link, too similar to an existing post — it would throw the topic away and try a different one, up to six times, then give up.&lt;/p&gt;

&lt;p&gt;Six attempts. That was the mental model. Six is a small number, so I never thought about it again.&lt;/p&gt;

&lt;p&gt;That last part is the actual mistake. I set the constant myself, I picked the value deliberately, and having picked it I stopped treating it as a question. A number you chose feels known in a way a number you inherited does not — which is backwards, because the one you chose is the one nobody else has checked.&lt;/p&gt;

&lt;p&gt;What made me look was a day where the schedule ran and nothing appeared on the blog. No post, no error I'd been alerted about, just an empty run. I went into the logs expecting a crash and found the opposite: the pipeline had worked perfectly, generated draft after draft, rejected every one of them on validation, exhausted its attempts, and shut down exactly as designed.&lt;/p&gt;

&lt;p&gt;Then I opened the billing page for that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six was the outer loop
&lt;/h2&gt;

&lt;p&gt;The retry limit I set was the &lt;em&gt;outermost&lt;/em&gt; loop. Inside each of those six attempts, other things were also retrying, and they were doing it against the API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAX_TOPIC_ATTEMPTS = 6            ← the number I was thinking of

  per attempt:
    _run_content_prompt(max_retries=3)   ← JSON came back malformed, regenerate
    + expansion call                     ← draft came in under the word count
    + image generation                   ← separate model, separate charge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A comment I later wrote in that file puts the real figure at &lt;strong&gt;4 to 8 API calls per attempt&lt;/strong&gt;. Six attempts at four to eight calls each is &lt;strong&gt;24 to 48 billed requests&lt;/strong&gt; for what I had filed in my head as "six tries."&lt;/p&gt;

&lt;p&gt;Nothing here was a bug in the ordinary sense. Every loop was doing exactly what it was written to do. The failure was that I had a number in my head — six — and the code had a different number, and I never multiplied them.&lt;/p&gt;

&lt;p&gt;It is worth noticing why this shape is easy to build by accident. Each of those retries was added on a different day, for a different reason, by someone reasonable. The JSON retry went in because the model occasionally returns malformed output and regenerating fixes it. The expansion call went in because drafts sometimes land under the word count and asking for more is cheaper than starting over. The topic loop went in because some topics are simply duplicates of existing posts and the fix is to pick another. Every one of those is a good local decision. Nobody ever sat down and decided the system should be allowed to make 48 calls; the 48 is an emergent property of three sensible choices stacked on top of each other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvev752yz944rn9j5o6u9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvev752yz944rn9j5o6u9.png" alt="The retry limit was 6, but each attempt made 4 to 8 API calls, for 24 to 48 billed requests per run." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  And the calls I thought were free were not
&lt;/h2&gt;

&lt;p&gt;The second half of this was a pricing assumption I had never checked.&lt;/p&gt;

&lt;p&gt;I had it in my head that image generation was the expensive part and text was more or less free. Images are visibly a "generation," they take seconds, they produce a file. Text felt like it was barely a request.&lt;/p&gt;

&lt;p&gt;So when I looked for the cost, I looked at image count.&lt;/p&gt;

&lt;p&gt;The billing breakdown said something else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Text generation: 42% of the spend&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Image generation: 58%&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not a rounding error. Nearly half the bill was the part I had been treating as free. And text generation is exactly the part that multiplies — every one of those 24 to 48 calls is a text call. Images generate once per post; text regenerates every time validation rejects a draft.&lt;/p&gt;

&lt;p&gt;Which means the expensive failure mode is invisible. A run that publishes nothing costs &lt;em&gt;more&lt;/em&gt; text tokens than a run that succeeds, because failure is what triggers regeneration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7p0pugg8z24dj515hbga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7p0pugg8z24dj515hbga.png" alt="Billing breakdown — text generation 42%, image generation 58%. The half I assumed was free." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the trap inside the fix
&lt;/h2&gt;

&lt;p&gt;I added a hard ceiling on the raw call count:&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;MAX_GEMINI_CALLS_PER_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_gemini_call_budget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_gemini_call_count&lt;/span&gt;
    &lt;span class="n"&gt;_gemini_call_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_gemini_call_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_GEMINI_CALLS_PER_RUN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;GeminiCallBudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail is &lt;em&gt;where&lt;/em&gt; it is checked: immediately before every actual API request, not at the top of any loop. Loop-level limits are what got me into this — each loop respected its own limit perfectly and the product was still 48. A counter on the raw calls doesn't care how many loops are nested above it.&lt;/p&gt;

&lt;p&gt;Then I nearly broke it in a way that would have been much harder to notice.&lt;/p&gt;

&lt;p&gt;My first version had &lt;code&gt;GeminiCallBudgetExceeded&lt;/code&gt; subclass &lt;code&gt;RuntimeError&lt;/code&gt;. That felt right — it's a runtime problem, and the codebase already used &lt;code&gt;RuntimeError&lt;/code&gt; for generation failures.&lt;/p&gt;

&lt;p&gt;That would have made the circuit breaker do nothing.&lt;/p&gt;

&lt;p&gt;The retry logic catches &lt;code&gt;RuntimeError&lt;/code&gt; and treats it as "this attempt failed, try another topic." So the budget exception would have been swallowed by the exact machinery it was built to stop, and the run would have kept going — now with a ceiling that reported being hit while changing nothing.&lt;/p&gt;

&lt;p&gt;The class now carries a comment explaining why it inherits from plain &lt;code&gt;Exception&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Deliberately does NOT subclass RuntimeError — the retry blocks must NOT catch this and try yet another topic, which would defeat the whole point of a hard ceiling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A circuit breaker that raises an exception your retry loop already catches is not a circuit breaker.&lt;/strong&gt; It's a log line.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkw0maxu9fdc679301x8a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkw0maxu9fdc679301x8a.png" alt="If the budget exception subclasses RuntimeError the retry loop catches it and keeps going; inheriting from Exception lets it end the run." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one generalizes past API budgets. Any time you add a stop condition to a system that already has broad error handling, the question is not "does it raise" — it's "who catches it first."&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check this on your own pipeline
&lt;/h2&gt;

&lt;p&gt;Three things, none of which take long:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count the worst case by hand.&lt;/strong&gt; Find every retry limit in the generation path and multiply them, then add whatever fires once per attempt. If the answer surprises you, that's the finding. Mine was six in my head and 24-48 on paper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull the actual billing breakdown by model, not the total.&lt;/strong&gt; The total tells you what you spent; the split tells you which assumption was wrong. I would never have looked at text calls if I hadn't seen 42% sitting next to them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grep your error handling for the exception type your limiter raises.&lt;/strong&gt; If anything upstream catches that type or a parent of it, your limit is decorative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debugging loop was billed too
&lt;/h2&gt;

&lt;p&gt;There is a second-order version of this problem that took me longer to see.&lt;/p&gt;

&lt;p&gt;When a run fails, you debug it. Debugging means running it again to watch what happens. Every one of those diagnostic runs went through the same generation path and cost the same money as a real one — and diagnostic runs fail by definition, which is the expensive branch.&lt;/p&gt;

&lt;p&gt;So I added a preview mode. It generates the draft and runs the full validation pass — word count, live-link checks, duplicate detection, required fields — then stops before image generation and before publishing.&lt;/p&gt;

&lt;p&gt;I want to be precise about what that saves, because I initially overstated it to myself. &lt;strong&gt;Preview mode is not free.&lt;/strong&gt; It still makes the text calls; those are the whole point, since text generation is what I'm usually debugging. What it removes is image generation and the publish step. Given the 42/58 split, that is real money on a run I'm going to throw away anyway, but it is not zero.&lt;/p&gt;

&lt;p&gt;The rule I use now: if the question is "why did validation reject this," preview mode answers it. If the question is "does the published output look right," that requires a real run, and I should expect to pay for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it without paying to test it
&lt;/h2&gt;

&lt;p&gt;There is an obvious problem with verifying a spend limiter: the straightforward test is to let it spend.&lt;/p&gt;

&lt;p&gt;I mocked the API call to fail every time and ran the pipeline against the mock. No requests left the machine, every loop behaved exactly as it does in production, and the counter incremented on the same line it increments on for real.&lt;/p&gt;

&lt;p&gt;It stopped on call 20. Not 19, not 21.&lt;/p&gt;

&lt;p&gt;That took a couple of minutes and cost nothing, and it is the only reason I know the ceiling actually holds rather than merely existing in the source.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Count calls, not loops.&lt;/strong&gt; Every retry limit in a nested system is a factor in a multiplication, and the product is the number that gets billed. If you can't say what the worst-case call count of one run is, you don't have a limit — you have several limits that happen to be small individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what things actually cost instead of assuming.&lt;/strong&gt; I assumed text was free because it felt lightweight. It was 42% of the bill, and it was the half that multiplies under failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the failure path visible.&lt;/strong&gt; A run that publishes nothing looks like "nothing happened." In this system it was the most expensive outcome available, and there was nothing on any dashboard that said so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask what catches your exception.&lt;/strong&gt; This is the one I'd have missed for months. A safety mechanism that throws a type your existing error handling already absorbs is worse than no mechanism, because it reads as protection in the source and does nothing at runtime.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>I let AI run two blogs for a month. Google indexed 1 post out of 81.</title>
      <dc:creator>Ogi</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:24:18 +0000</pubDate>
      <link>https://dev.to/brokeitwithai/i-let-ai-run-two-blogs-for-a-month-google-indexed-1-post-out-of-81-30ga</link>
      <guid>https://dev.to/brokeitwithai/i-let-ai-run-two-blogs-for-a-month-google-indexed-1-post-out-of-81-30ga</guid>
      <description>&lt;p&gt;I built two fully automated blog pipelines. Pick a topic, research it, write the body, generate the images, publish — no human in the loop. One was WordPress on its own domain, the other was Blogspot.&lt;/p&gt;

&lt;p&gt;WordPress ran from July 23 to August 23 and shipped 44 posts. Blogspot started August 8 and stopped on the same day the other one did, so 37 posts in 15 days. Eighty-one posts total.&lt;/p&gt;

&lt;p&gt;Both pipelines ran on a schedule with no approval step. A run would ground itself against official sources, draft the body, generate three to five images, run a mechanical validation pass (word count, live-link checks, duplicate detection against previous posts, required-field checks), retry on failure, and publish. If validation failed too many times, the run gave up and logged it. That was the entire loop. It worked, in the sense that posts appeared on a schedule and none of them were broken.&lt;/p&gt;

&lt;p&gt;Today I measured what Google actually did with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One post is indexed. Out of 81.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one that made it is a guide to searching Illinois unclaimed property. I have no idea why that one and not the other 80. It is not the longest, it is not the most linked, and it is not the oldest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual numbers
&lt;/h2&gt;

&lt;p&gt;I pulled these from the Search Console URL Inspection API, one URL at a time. This is not an estimate off a dashboard summary — it is Google telling me, per URL, what state it thinks that page is in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;WordPress&lt;/th&gt;
&lt;th&gt;Blogspot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Posts published&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crawled — currently not indexed&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovered — currently not indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect error&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL is unknown to Google&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Indexed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Search performance over the same window: WordPress got &lt;strong&gt;217 impressions and 0 clicks&lt;/strong&gt;. Blogspot got 0 impressions.&lt;/p&gt;

&lt;p&gt;Two hundred and seventeen times a human saw one of my pages in a result list and decided not to click it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwr1w3thadqp46b8y151.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjwr1w3thadqp46b8y151.png" alt="Stacked bar chart: 81 posts published across two sites, only 1 indexed by Google" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured this, in case you want to check your own site
&lt;/h2&gt;

&lt;p&gt;The Search Console web UI will show you an aggregate coverage chart, which is where I had been getting my (wrong) sense that things were merely slow. The per-URL truth lives in the URL Inspection API, and it takes about fifteen lines to pull.&lt;/p&gt;

&lt;p&gt;You need a service account with access to the property, and you need to match the property type exactly. A domain property is addressed as &lt;code&gt;sc-domain:example.com&lt;/code&gt;; a URL-prefix property, which is what a blogspot subdomain verifies as, is addressed with the full &lt;code&gt;https://example.com/&lt;/code&gt; string. Getting this wrong returns a 403 that looks like a permissions problem but is not — I lost a round to that today.&lt;/p&gt;

&lt;p&gt;Then for each published URL you call &lt;code&gt;urlInspection().index().inspect()&lt;/code&gt; with the URL and the site, and read &lt;code&gt;indexStatusResult.coverageState&lt;/code&gt;. The states you care about are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Submitted and indexed&lt;/code&gt; — the only one that means anything good&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Crawled - currently not indexed&lt;/code&gt; — Google read it and passed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Discovered - currently not indexed&lt;/code&gt; — Google never fetched it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URL is unknown to Google&lt;/code&gt; — Google has no record of it at all&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Redirect error&lt;/code&gt; — something is broken in your redirect chain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Counting those five states across every post you have published takes a couple of minutes and tells you more than any dashboard summary. I would run it before drawing any conclusion about whether a site is working, and specifically before applying to any ad network.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two sites failed in different ways, and that turned out to matter
&lt;/h2&gt;

&lt;p&gt;My first read was just "both dead." But the two columns are not the same failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WordPress: 27 posts are "Crawled — currently not indexed."&lt;/strong&gt; Googlebot fetched those pages. It read them. Then it decided they were not worth putting in the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blogspot: 25 posts are "Discovered — currently not indexed."&lt;/strong&gt; Google knows the URLs exist and never bothered to fetch them.&lt;/p&gt;

&lt;p&gt;That distinction is the whole story. The first is a content problem. The second is a crawl budget problem, and no amount of writing better posts fixes it, because nothing is coming to read them.&lt;/p&gt;

&lt;p&gt;I had been treating both sites as one failure with one cause. They were not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three causes, separated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. I picked niches without looking at who already owns the results
&lt;/h3&gt;

&lt;p&gt;Site one was unclaimed property and government benefits. Site two was product recalls and consumer rights. I checked search volume. The demand was real.&lt;/p&gt;

&lt;p&gt;I never checked &lt;strong&gt;who currently sits in the top ten.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I finally looked: unclaimed property results are dominated by state government sites. Every state runs its own official lookup, and there is no reason for a reader to prefer my page over the actual agency holding their money. Recalls were worse. Recall news breaks and major outlets take the top slots instantly on domain authority alone. For lemon law queries, six of the top nine results were law firms. In that market a single converted reader is worth thousands of dollars in fees, so law firms spend accordingly on SEO.&lt;/p&gt;

&lt;p&gt;There was no seat left at either table. I built anyway.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkdlaxy0c52ek9yyjcc7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkdlaxy0c52ek9yyjcc7.png" alt="Top search results for both niches, colored by site type. Independent blogs hold 0 of 19 spots." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. I had AI write about things neither of us had done
&lt;/h3&gt;

&lt;p&gt;Ask a model to write "how to find unclaimed property in Illinois" and it will. Fluently. With structure and headers and a FAQ.&lt;/p&gt;

&lt;p&gt;The problem is that the output is a recombination of things already published elsewhere. I have never searched for unclaimed property. Neither has the model. There is no first-hand anything in those 81 posts.&lt;/p&gt;

&lt;p&gt;I learned how that reads from the outside when AdSense rejected the site.&lt;/p&gt;

&lt;p&gt;I did not think that was unfair. It was accurate. I had published 81 pages of information that already existed in better form, at more authoritative addresses. Google has no reason to index a worse copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. I published into a vacuum
&lt;/h3&gt;

&lt;p&gt;This is the part I would most want back.&lt;/p&gt;

&lt;p&gt;I believed that consistent quality publishing gets you found eventually. So I published and waited. I never shared a post anywhere. Nobody linked to either site. Nobody mentioned them.&lt;/p&gt;

&lt;p&gt;A new domain with zero external signal does not get crawl budget. Those 25 "Discovered — currently not indexed" URLs on Blogspot are what that looks like in the data. I could have published 100 more posts into that state and the number would not have moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine posts died to an unchecked box
&lt;/h2&gt;

&lt;p&gt;Nine of the Blogspot URLs came back as "Redirect error." That one is not about content at all.&lt;/p&gt;

&lt;p&gt;Blogspot has a setting called HTTPS redirect — it sends http requests to the https version. It was off. Google crawled those URLs while it was off, recorded them as redirect errors, and that is the nine.&lt;/p&gt;

&lt;p&gt;The setting was turned on later. I fetched all nine of those URLs today, one by one: &lt;strong&gt;every single one returns 200 now.&lt;/strong&gt; The problem was already fixed. But the Search Console record still reflects the state from when it was broken, because nothing has come back to recrawl them.&lt;/p&gt;

&lt;p&gt;So those nine are not "currently broken." They are the scar from something that was broken, and the scar is not healing because the site never gets recrawled. Which folds right back into the crawl budget problem above.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was confidently wrong about the cause
&lt;/h2&gt;

&lt;p&gt;Worth writing down plainly: I had diagnosed those nine as a &lt;strong&gt;mobile redirect loop&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Blogspot appends &lt;code&gt;?m=1&lt;/code&gt; for mobile requests, and the canonical tag on the resulting page points back at the original URL. Each end points at the other. I looked at that structure, decided it was a loop Google would choke on, and stopped there. It is a satisfying explanation. It is internally consistent. It is the kind of thing that sounds right when you say it out loud.&lt;/p&gt;

&lt;p&gt;Then I spun up a fresh blog and measured it. &lt;strong&gt;The new blog has the exact same structure.&lt;/strong&gt; Different theme, same behavior — because that is just how Blogspot serves mobile, on every blog on the platform, all of which index fine.&lt;/p&gt;

&lt;p&gt;The real cause was a settings toggle. Much less interesting.&lt;/p&gt;

&lt;p&gt;That is the most useful thing I learned today. &lt;strong&gt;A plausible mechanism is not evidence.&lt;/strong&gt; I saw a structure, reasoned my way to a conclusion, and never built the one comparison that would have taken five minutes to falsify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building that comparison turned up something else
&lt;/h2&gt;

&lt;p&gt;While setting up the new blog I went through the settings and found two switches under crawlers and indexing: &lt;strong&gt;custom robots.txt&lt;/strong&gt; and &lt;strong&gt;custom robots header tags&lt;/strong&gt;. Both were on. So I opened the actual file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/robots.txt&lt;/code&gt; was &lt;strong&gt;completely empty.&lt;/strong&gt; Not one line.&lt;/p&gt;

&lt;p&gt;Turning that switch on means "I'll write this myself." Nothing had been written. So the sensible default the platform generates for you got replaced with a blank file.&lt;/p&gt;

&lt;p&gt;I turned the switch off and fetched it again. This is what should have been there the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;search&lt;/span&gt;
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;share&lt;/span&gt;-&lt;span class="n"&gt;widget&lt;/span&gt;
&lt;span class="n"&gt;Allow&lt;/span&gt;: /

&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;blogspot&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/&lt;span class="n"&gt;sitemap&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Disallow: /search&lt;/code&gt; is the line that matters. Without it, every label archive and every search results page is fair game for crawling. On a site with healthy crawl budget that is a rounding error. &lt;strong&gt;On a site that gets almost no crawl budget, those listing pages eat the few fetches you were going to get.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I checked the old blog. Its robots.txt is still empty. It ran that way for the full fifteen days.&lt;/p&gt;

&lt;p&gt;I'm not going to claim that's &lt;em&gt;the&lt;/em&gt; cause of the 25 uncrawled URLs — zero external signal produces the same symptom, and I can't separate their contributions with the data I have. What I can say is that both conditions were bad at the same time.&lt;/p&gt;

&lt;p&gt;Same lesson as above, in a different shape. A toggle you didn't need to touch will silently replace a working default with nothing, and no screen anywhere warns you. You only see it by fetching the file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwze383o4du0z0ffn16i9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwze383o4du0z0ffn16i9.png" alt="My blog served an empty robots.txt for 15 days, next to the default Blogger serves with Disallow: /search and a sitemap line." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am changing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check occupancy, not volume.&lt;/strong&gt; Before committing to a topic, search three to five of its head terms and look at what kind of site holds the top ten. Government agencies, major news outlets, law firms, or a funded company's content marketing — any one of those in control means a new site does not get in. Search volume tells you the room is crowded. It does not tell you the seats are taken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write only what actually happened to you.&lt;/strong&gt; This is a strategy argument, not an ethics one. Recombined information loses to the source it was recombined from, permanently. But a failure I personally had is something no other site can publish. This post is the first thing I have written that meets that bar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publishing is the start of distribution, not the end of it.&lt;/strong&gt; New domains need some external signal before Google spends crawl budget on them. That means putting the work somewhere humans already are, before expecting search to do anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And one open question.&lt;/strong&gt; Blogspot published 37 posts in 15 days, roughly two and a half a day from a domain that was days old. I do not know whether those 25 uncrawled URLs are the result of zero external signal, publishing velocity, or both. The current data cannot separate them. Next time I am slowing the cadence down specifically so that variable is off the table.&lt;/p&gt;

&lt;p&gt;One month, plus hosting, a domain, and API bills. The tuition was not cheap for the runtime, but at least I got numbers out of it instead of a hunch.&lt;/p&gt;

&lt;p&gt;I am rebuilding now with all three of those inverted, and I will be publishing the measurements either way, including if it fails again.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>ai</category>
      <category>writing</category>
    </item>
  </channel>
</rss>
