<?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: Michael Yousrie</title>
    <description>The latest articles on DEV Community by Michael Yousrie (@michael-yousrie).</description>
    <link>https://dev.to/michael-yousrie</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%2F4024580%2F5113fe07-bcda-40ee-bb54-f2d285440322.jpg</url>
      <title>DEV Community: Michael Yousrie</title>
      <link>https://dev.to/michael-yousrie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michael-yousrie"/>
    <language>en</language>
    <item>
      <title>Looking for a Thunder Client alternative? I built one where free stays free</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 15 Aug 2026 11:43:02 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/looking-for-a-thunder-client-alternative-i-built-one-where-free-stays-free-4ic3</link>
      <guid>https://dev.to/michael-yousrie/looking-for-a-thunder-client-alternative-i-built-one-where-free-stays-free-4ic3</guid>
      <description>&lt;p&gt;If you searched for a Thunder Client alternative, you probably hit the same wall I did: the most installed REST client for VS Code (7.4 million installs) sits at 2.39 stars, and the reviews explain why. Things that used to be free moved behind a paywall, including looking at your own request history. One review puts it plainly: "You must pay to look at a request you did in the past."&lt;/p&gt;

&lt;p&gt;I did not want to leave VS Code for API work, and I did not want to rent access to my own data. So I built MailPal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The promise first
&lt;/h2&gt;

&lt;p&gt;MailPal exists because of one rule, and it is written into the listing like a contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free tier is fully usable for a single user, forever&lt;/li&gt;
&lt;li&gt;Unlimited requests, unlimited collections, unlimited local history&lt;/li&gt;
&lt;li&gt;No account, no cloud, everything works offline&lt;/li&gt;
&lt;li&gt;Paid only ever adds new value. It never gates access to your own data. If a license lapses, everything you made keeps working.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&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%2Fqj9s2lqyg9tbyz3vet21.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%2Fqj9s2lqyg9tbyz3vet21.png" alt="MailPal request panel" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A native feeling request editor: auto-growing key/value rows like Postman, header autocomplete, color coded JSON editing with format and live validation&lt;/li&gt;
&lt;li&gt;Collections stored as plain JSON in your workspace, so they diff, review, and share through git like any other code&lt;/li&gt;
&lt;li&gt;Per collection variables with placeholders. Secret values go in the OS keychain through VS Code SecretStorage, never into files.&lt;/li&gt;
&lt;li&gt;Syntax highlighted responses, an HTML raw/preview toggle (fully sandboxed), and a resizable request/response split&lt;/li&gt;
&lt;li&gt;Import from Postman v2.1, Thunder Client, OpenAPI 3.x, and curl, so switching takes minutes&lt;/li&gt;
&lt;li&gt;Simple per request assertions with pass/fail on every send&lt;/li&gt;
&lt;li&gt;Unlimited local history with one click re-send&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Full control over the wire
&lt;/h2&gt;

&lt;p&gt;One thing I always missed in lightweight clients: knowing exactly what gets sent. MailPal surfaces every header the tool adds on its own (Content-Type from the body type, Authorization from the auth config, the HTTP engine defaults) with a note on where each comes from and how to override 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%2F5v3dhc7wguh7xndo82x4.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%2F5v3dhc7wguh7xndo82x4.png" alt="Auto headers" width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP part
&lt;/h2&gt;

&lt;p&gt;MailPal ships a built in MCP server, registered with VS Code agent mode automatically. That means Copilot agent mode, Claude, Cursor, or any MCP client can list your saved requests, run them, and read the results. Ask your agent to "run the Create invoice request and tell me if the status changed" and it just works. As far as I know, no other REST client does this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What paid adds
&lt;/h2&gt;

&lt;p&gt;$4/month or $70 once. It adds AI request generation (describe a request in plain English, get it built) and MCP write tools so agents can create and edit requests. That is it. Your data is never part of the deal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Install: &lt;a href="https://marketplace.visualstudio.com/items?itemName=MichaelYousrie.mailpal" rel="noopener noreferrer"&gt;https://marketplace.visualstudio.com/items?itemName=MichaelYousrie.mailpal&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Issues and feature requests: &lt;a href="https://github.com/michaelyousrie/mailpal/issues" rel="noopener noreferrer"&gt;https://github.com/michaelyousrie/mailpal/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First release shipped this week. If you try it and something annoys you, tell me and there is a good chance it gets fixed the same week.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>api</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My Pest test passed because I added a helpful failure message</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Tue, 11 Aug 2026 18:02:39 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/my-pest-test-passed-because-i-added-a-helpful-failure-message-5hj2</link>
      <guid>https://dev.to/michael-yousrie/my-pest-test-passed-because-i-added-a-helpful-failure-message-5hj2</guid>
      <description>&lt;p&gt;I write marketing copy about my own products and I have a bad habit of leaving claims in it that stop being true. Prices change, a feature moves tier, and the blog post from March keeps happily saying the old thing. So I wrote a test that reads the published posts and asserts the dead claims are gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'$29/mo'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pricing changed in June, this post is stale'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green. Every one of them green. The string &lt;code&gt;$29/mo&lt;/code&gt; was still sitting in the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that second argument actually does
&lt;/h2&gt;

&lt;p&gt;Here is the signature, straight out of &lt;code&gt;vendor/pestphp/pest/src/Mixins/Expectation.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$needles&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$needles&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Assert&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertStringContainsString&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;is_iterable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nc"&gt;InvalidExpectationValue&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'iterable'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nc"&gt;Assert&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;assertContains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&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;It is variadic. My careful explanatory message was never a message. It was needle number two.&lt;/p&gt;

&lt;p&gt;The positive expectation is therefore a conjunction: contains A &lt;strong&gt;and&lt;/strong&gt; contains B. Which means the negation is "missing at least one of them", and my sentence about June pricing was guaranteed to be missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How &lt;code&gt;not&lt;/code&gt; gets there
&lt;/h2&gt;

&lt;p&gt;Pest does not implement a separate negative assertion. &lt;code&gt;OppositeExpectation::__call&lt;/code&gt; runs the positive one and catches the failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$arguments&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Expectation&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;}(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExpectationFailedException&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nc"&gt;AssertionFailedError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// positive failed, so `not` succeeds&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;throwExpectationFailedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$arguments&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;So follow my call through. The loop checks &lt;code&gt;$29/mo&lt;/code&gt;, finds it, passes. Then it checks &lt;code&gt;pricing changed in June, this post is stale&lt;/code&gt;, does not find it, throws. That throw is caught, and &lt;code&gt;not&lt;/code&gt; reports success.&lt;/p&gt;

&lt;p&gt;The first needle was checked. The result was thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this one is so easy to walk into
&lt;/h2&gt;

&lt;p&gt;I counted the public methods on that mixin. There are 68 expectations. &lt;strong&gt;66 of them take a real message parameter.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toBeFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toStartWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two do not: &lt;code&gt;toContain&lt;/code&gt; and &lt;code&gt;toContainEqual&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;toStartWith&lt;/code&gt; is defined forty lines below &lt;code&gt;toContain&lt;/code&gt; in the same file, and it takes a message. So the habit is correct 66 times out of 68 and you are not misremembering the library, you are remembering the other 97% of it. At the call site a variadic parameter and an optional trailing string are the same keystrokes. Nothing about &lt;code&gt;('needle', 'my message')&lt;/code&gt; looks different from &lt;code&gt;('needle', 'other needle')&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four cases, run
&lt;/h2&gt;

&lt;p&gt;Pest 4.4.1 on PHP 8.4.13, value is &lt;code&gt;'hello world'&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;assertion&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-&amp;gt;toContain('zzz', 'qqq')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;passes, correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-&amp;gt;toContain('hello', 'qqq')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;passes&lt;/strong&gt;, and 'hello' is right there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-&amp;gt;toContain('qqq', 'hello')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;passes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not-&amp;gt;toContain('hello')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fails, correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the single-needle form does what you read it as doing. Add anything after it and the assertion turns into a coin flip you always win.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;For the check I actually wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$29/mo'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pricing changed in June, this post is stale'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toBeFalse&lt;/code&gt; has a real &lt;code&gt;$message&lt;/code&gt;, so the sentence lands where I meant it to and the assertion is about one thing.&lt;/p&gt;

&lt;p&gt;The wider habit I picked up from this: if an assertion is load-bearing, make it fail once on purpose before you trust it. I had written a test specifically to catch a stale claim, the stale claim was present, and the test was green. Deleting the second argument turned it red immediately. That took ten seconds and I had not done it, because the test was passing and passing tests do not feel like they need investigating.&lt;/p&gt;

&lt;p&gt;That is the actual failure. Not the variadic signature, which is documented and reasonable. It is that a green test reads as evidence, and a test that cannot fail is green in exactly the same shade as a test that just did its job.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Reddit was removing my posts and my code could not see it</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:03:23 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/reddit-was-removing-my-posts-and-my-code-could-not-see-it-1bkm</link>
      <guid>https://dev.to/michael-yousrie/reddit-was-removing-my-posts-and-my-code-could-not-see-it-1bkm</guid>
      <description>&lt;p&gt;I spent this week posting to Reddit through the API and being pleased with how it was going. Every submission returned a URL. Every URL loaded. Four of the last six were gone and I had no idea.&lt;/p&gt;

&lt;p&gt;If you automate anything against Reddit, this is the part nobody tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A removed post looks completely fine to its author
&lt;/h2&gt;

&lt;p&gt;This is the whole problem in one sentence. Reddit does not show you your own removals. Load your post while logged in as the account that made it and you get the title, the body, the score, the comment count. It looks like a post. It is not visible to anybody else.&lt;/p&gt;

&lt;p&gt;The fix is to read it back with an &lt;em&gt;anonymous&lt;/em&gt; client. Same credentials, no user context:&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;import&lt;/span&gt; &lt;span class="n"&gt;praw&lt;/span&gt;

&lt;span class="c1"&gt;# app-only: no username, no password
&lt;/span&gt;&lt;span class="n"&gt;anon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;praw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Reddit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;client_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;removal-check by u/yourname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;read_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;abc123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;removed_by_category&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;selftext&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[removed]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[deleted]&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;Two things to note. You need both checks: &lt;code&gt;selftext&lt;/code&gt; goes to &lt;code&gt;[removed]&lt;/code&gt; in some cases and stays intact in others, while &lt;code&gt;removed_by_category&lt;/code&gt; is populated in cases where the body still reads normally. And this only works anonymously. Run the same code with &lt;code&gt;username=&lt;/code&gt; and &lt;code&gt;password=&lt;/code&gt; set and you will cheerfully report that everything is fine.&lt;/p&gt;

&lt;p&gt;I also tried a plain HTTP request to &lt;code&gt;reddit.com/user/&amp;lt;name&amp;gt;/about.json&lt;/code&gt; as a shadowban check. Don't bother from a server: it returns 403 for datacenter IPs regardless of account state, so you will diagnose a shadowban that isn't there. The app-only PRAW client is the one that tells the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  removed_by_category is four different stories
&lt;/h2&gt;

&lt;p&gt;This field is the useful one and its values are not interchangeable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;th&gt;who did it&lt;/th&gt;
&lt;th&gt;what it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;automod_filtered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AutoModerator&lt;/td&gt;
&lt;td&gt;a rule, usually with a bot comment explaining it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;moderator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a human&lt;/td&gt;
&lt;td&gt;someone looked and said no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reddit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reddit itself&lt;/td&gt;
&lt;td&gt;the sitewide spam filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deleted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the author&lt;/td&gt;
&lt;td&gt;not a removal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;automod_filtered&lt;/code&gt; is the friendly one. AutoModerator almost always leaves a comment saying exactly what tripped, and those comments are the only place some rules are ever published. One of mine came back with "you need at least 3 karma earned in this subreddit to post here", a threshold that appears in no rules API, no sidebar, and no flair list. Read those comments; they are documentation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;moderator&lt;/code&gt; means a person decided. No appeal in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;reddit&lt;/code&gt; is the one that should stop you.&lt;/strong&gt; It isn't about your post, it's about your account. A subreddit rule is a local problem you can fix by writing differently. The sitewide filter deciding your submissions look like spam is a global one, and posting more into it is how it gets worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that made me stop
&lt;/h2&gt;

&lt;p&gt;Once I could actually measure it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;last 6 submissions:  4 removed (2 of them by `reddit`)
last 8 comments:     8 live
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Posting was dead. Commenting was perfectly healthy, and the comment karma was still going up. Those are two completely different signals from the same account on the same day, and I would have guessed the opposite: I assumed comments were riskier because they carry links more often.&lt;/p&gt;

&lt;p&gt;So the rule I now follow: if the site filter is touching your submissions, stop submitting and keep commenting. Comments are what rebuild standing anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that made all of this invisible
&lt;/h2&gt;

&lt;p&gt;I had a guard for part of this already. It never fired once.&lt;/p&gt;

&lt;p&gt;Subreddits can require a minimum comment karma &lt;em&gt;earned in that subreddit&lt;/em&gt; before they accept a post. Reddit exposes this:&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;karma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;display_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_karma&lt;/span&gt;&lt;span class="sh"&gt;"&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;sr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reddit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;karma&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my gate did roughly:&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;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;karma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub_name&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;have&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;          &lt;span class="c1"&gt;# allow
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully. &lt;code&gt;user.karma()&lt;/code&gt; returns &lt;strong&gt;only the subreddits where you have karma&lt;/strong&gt;. A subreddit you have never commented in is not in the dict at all. So in the exact case the gate exists for, a sub you have never participated in, &lt;code&gt;have&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;, and &lt;code&gt;None&lt;/code&gt; was being treated as "cannot measure, allow it".&lt;/p&gt;

&lt;p&gt;Absent meant zero. The code read it as unknown.&lt;/p&gt;

&lt;p&gt;The fix distinguishes the two states properly:&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;karma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_karma&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;karma&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;                       &lt;span class="c1"&gt;# the CALL failed: genuinely unknown
&lt;/span&gt;&lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;karma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub_name&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="c1"&gt;# absent from a good response: zero
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A missing key means "not present in the result". Whether that means zero or unknown depends entirely on whether the call succeeded, and only your code knows that.&lt;/strong&gt; Same shape as a &lt;code&gt;COUNT(*)&lt;/code&gt; returning no row versus returning 0, or a metrics endpoint that omits idle counters. If you branch on &lt;code&gt;is None&lt;/code&gt; you are usually collapsing those two into one, and the failure is silent because the code takes the permissive path.&lt;/p&gt;

&lt;p&gt;Mine had never fired. It was written specifically to prevent a removal, it sat in the pipeline looking reassuring, and the removal it was written for happened anyway.&lt;/p&gt;

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

&lt;p&gt;Read your own posts back anonymously, on a schedule, rather than when you get suspicious. And treat &lt;code&gt;removed_by_category == "reddit"&lt;/code&gt; as a stop signal, not a data point to log.&lt;/p&gt;

&lt;p&gt;The guard thing still bothers me though. It had never failed, and I read that as it working.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Pest test that asserts nothing: toContain takes needles, not a message</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 08 Aug 2026 22:46:35 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/a-pest-test-that-asserts-nothing-tocontain-takes-needles-not-a-message-15j5</link>
      <guid>https://dev.to/michael-yousrie/a-pest-test-that-asserts-nothing-tocontain-takes-needles-not-a-message-15j5</guid>
      <description>&lt;p&gt;I added a test last week that was doing absolutely nothing, and it passed every time I ran it.&lt;/p&gt;

&lt;p&gt;The rule it was guarding is boring: no em dashes in anything my project publishes. I write marketing copy alongside the code, em dashes are the single most obvious tell that a machine wrote something, and I'd already had to go back and rewrite 126 of them out of published posts once. So I wanted a test, not a habit.&lt;/p&gt;

&lt;p&gt;Here's what I wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'uses no em or en dashes in copy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'comparisons'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$comparison&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;allStrings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$comparison&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2014}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"An em dash appears in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2013}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"An en dash appears in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&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;Green. Every run. And completely inert.&lt;/p&gt;

&lt;h2&gt;
  
  
  toContain takes needles, not a message
&lt;/h2&gt;

&lt;p&gt;Most assertion libraries have a second parameter for a custom failure message. PHPUnit does. &lt;code&gt;expect($x)-&amp;gt;toBeTrue('why this matters')&lt;/code&gt; does. So when I typed a helpful string as the second argument, I was pattern matching off everything around it.&lt;/p&gt;

&lt;p&gt;Pest's &lt;code&gt;toContain&lt;/code&gt; isn't that shape. It's variadic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$needles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every argument is a needle. My "message" was a second thing it went looking for.&lt;/p&gt;

&lt;p&gt;The positive form does what you'd guess: it requires all of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alpha beta'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alpha'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'beta'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// passes&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alpha beta'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alpha'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'zzz'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// fails&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The negated form is where it gets you. &lt;code&gt;not&lt;/code&gt; negates the whole conjunction, so &lt;code&gt;-&amp;gt;not-&amp;gt;toContain(a, b)&lt;/code&gt; asserts "it is not the case that both are present". One missing needle satisfies that on its own, and the other needle is never examined.&lt;/p&gt;

&lt;p&gt;I ran the four cases against Pest 4.4.1 rather than reasoning about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;toContain(present, absent)        =&amp;gt; FAILED
not-&amp;gt;toContain(present, absent)   =&amp;gt; PASSED   &amp;lt;-- the one that bit me
not-&amp;gt;toContain(absent, absent)    =&amp;gt; PASSED
not-&amp;gt;toContain(present)           =&amp;gt; FAILED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line two is the whole bug. My failure message was, by definition, never in the string being checked. That made the conjunction false, which made the negation true, forever, no matter how many dashes were sitting in the copy.&lt;/p&gt;

&lt;p&gt;The single-needle version on line four works exactly as expected. That's what makes this so easy to ship: the code you wrote first was correct, and then you improved it by adding a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Route it through a plain predicate, which does take a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2014}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toBeFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"An em dash appears in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slightly uglier. Actually asserts something. If you'd rather keep the fluent style, drop the message and let the diff speak:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;not&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\u{2014}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are fine. The one to avoid is the one that reads best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I believed it
&lt;/h2&gt;

&lt;p&gt;The fix took a minute. The harder question is why I trusted a test I'd never seen fail.&lt;/p&gt;

&lt;p&gt;A test that only ever passes is indistinguishable from a test that can't fail. Green tells you nothing on its own. It only means something if you know the test is capable of turning red, and for most tests you learn that by accident: you write it, it fails, you fix the code, it goes green. The red came free.&lt;/p&gt;

&lt;p&gt;Guard tests don't work like that. You write them for a bug you've already fixed, or for a rule you're trying not to break in future. They're green from birth. Nobody ever sees them fail, so nobody finds out they can't.&lt;/p&gt;

&lt;p&gt;So now, whenever I write one, I break it on purpose first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# put the banned thing back&lt;/span&gt;
git stash
php artisan &lt;span class="nb"&gt;test &lt;/span&gt;tests/Feature/BlogClaimsTest.php   &lt;span class="c"&gt;# expect FAILURES&lt;/span&gt;
git stash pop
php artisan &lt;span class="nb"&gt;test &lt;/span&gt;tests/Feature/BlogClaimsTest.php   &lt;span class="c"&gt;# expect green&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the dash test I injected a real em dash into one config value and watched it fail with the message I'd written. Ten seconds. It's the same red-green loop as TDD, just applied after the fact, and it's the only thing that separates a guard from a comment.&lt;/p&gt;

&lt;p&gt;While I was doing this I found a second one in the same file that had the identical mistake, and I'd have shipped both.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more trap in the same family
&lt;/h2&gt;

&lt;p&gt;The other guard I wrote that day banned a phrase from my published copy. It flagged something correct on the first real run, and the reason is worth mentioning because it's the opposite failure.&lt;/p&gt;

&lt;p&gt;I'd been claiming a feature in a blog post that my product doesn't actually have. Reasonable response: ban the phrase in a test. Except the phrase also appeared in a perfectly true sentence about a competitor's plan, which does have that feature. The test was doing something, and what it was doing was wrong.&lt;/p&gt;

&lt;p&gt;So the two failure modes sit next to each other. A guard that asserts nothing tells you your copy is clean when it isn't. A guard that's too blunt tells you your copy is broken when it's fine, and the fastest way to make that alarm stop is to delete the test. Neither one announces itself.&lt;/p&gt;

&lt;p&gt;The version I kept scopes the check per line, so it only fires when the banned phrase shows up on a line that's talking about my own product. More code, narrower blast radius, and it survived contact with the real corpus. That last part is the test of a guard: run it against everything you've already published, not just against the case you invented it for. If it flags something true, it's not ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pest's &lt;code&gt;toContain&lt;/code&gt; is variadic. There is no message parameter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-&amp;gt;not-&amp;gt;toContain($needle, 'message')&lt;/code&gt; passes whenever the message is absent, which is always. The assertion is dead.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;expect(str_contains($h, $n))-&amp;gt;toBeFalse('message')&lt;/code&gt; if you want a message, or a bare single-needle &lt;code&gt;not-&amp;gt;toContain($n)&lt;/code&gt; if you don't.&lt;/li&gt;
&lt;li&gt;Break every guard test on purpose once, before you trust it. Green from birth means nothing.&lt;/li&gt;
&lt;li&gt;Then run it against your whole existing corpus. A guard that flags a true statement gets deleted by whoever hits it next.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I found both of these while auditing my own marketing copy for claims that had drifted from what the product does, which turned out to be a much bigger problem than the tests were. That's a different post.&lt;/p&gt;

</description>
      <category>php</category>
      <category>testing</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Python's HTTPServer will bind a port that is already in use on Windows</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:41:05 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/pythons-httpserver-will-bind-a-port-that-is-already-in-use-on-windows-3lp5</link>
      <guid>https://dev.to/michael-yousrie/pythons-httpserver-will-bind-a-port-that-is-already-in-use-on-windows-3lp5</guid>
      <description>&lt;p&gt;I needed a throwaway HTTP server for about thirty seconds.&lt;/p&gt;

&lt;p&gt;The job was a one-time OAuth handshake. Provider redirects the browser to a localhost URL with a &lt;code&gt;?code=&lt;/code&gt; on it, you swap that code for a token, done. The codes expire in roughly thirty seconds, and I'd already lost two of them to the time it takes a human to copy a string out of an address bar and paste it into a terminal. So: listen on the redirect port, grab the code the moment it lands, exchange it immediately, shut down.&lt;/p&gt;

&lt;p&gt;Twenty lines with &lt;code&gt;http.server&lt;/code&gt;. It printed "listening on &lt;a href="http://localhost:8000/callback" rel="noopener noreferrer"&gt;http://localhost:8000/callback&lt;/a&gt;". I clicked approve. The browser landed on the callback and showed a 404.&lt;/p&gt;

&lt;p&gt;A 404 is a strange thing to get from a server that only knows how to return 200 and 204.&lt;/p&gt;

&lt;h2&gt;
  
  
  The port was already taken
&lt;/h2&gt;

&lt;p&gt;I run Laragon, which runs Apache, which owns :8000. Apache answered the browser. My handler never saw the request.&lt;/p&gt;

&lt;p&gt;That part is obvious in hindsight. What wasn't obvious is that my server didn't fail to start. It bound without complaint, printed its startup line, and then waited for a connection that was being handed to another process.&lt;/p&gt;

&lt;p&gt;I'd wrapped the bind in a try/except specifically to catch &lt;code&gt;OSError: address already in use&lt;/code&gt;, because that was the failure I could see coming. The except never ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the bind succeeds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;socketserver.TCPServer&lt;/code&gt; sets &lt;code&gt;allow_reuse_address = False&lt;/code&gt;. &lt;code&gt;http.server.HTTPServer&lt;/code&gt; overrides it:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socketserver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;socketserver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TCPServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_reuse_address&lt;/span&gt;
&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_reuse_address&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That flag becomes &lt;code&gt;SO_REUSEADDR&lt;/code&gt; on the socket, and &lt;code&gt;SO_REUSEADDR&lt;/code&gt; does not mean the same thing on Windows as it does on Linux.&lt;/p&gt;

&lt;p&gt;On Linux it mostly lets you rebind a port still sitting in &lt;code&gt;TIME_WAIT&lt;/code&gt; from a previous process, which is genuinely useful and is why &lt;code&gt;HTTPServer&lt;/code&gt; turns it on: restart your dev server without waiting a minute.&lt;/p&gt;

&lt;p&gt;On Windows it's broader. It lets you bind an address another socket is actively listening on. Both binds succeed, and which socket receives an incoming connection isn't something you get to decide.&lt;/p&gt;

&lt;p&gt;Two servers on the same address, no error:&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;import&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8931&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ident&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ident&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# no exception
&lt;/span&gt;&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PORT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows that prints &lt;code&gt;b'A'&lt;/code&gt;. Both servers are live, and the requests all go to whichever one bound first. The second process is running fine and simply never hears anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one line
&lt;/h2&gt;

&lt;p&gt;Turn the flag off on your own server:&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;class&lt;/span&gt; &lt;span class="nc"&gt;ExclusiveHTTPServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;allow_reuse_address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the bind fails the way you wanted it to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OSError: [WinError 10048] Only one usage of each socket address
(protocol/network address/port) is normally permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is the message I'd been trying to print by hand.&lt;/p&gt;

&lt;p&gt;The asymmetry is what decides whether this can happen to you, and it sits with the second binder rather than the first. If you don't ask for &lt;code&gt;SO_REUSEADDR&lt;/code&gt;, your bind gets refused whatever the other process did. If you bind first with &lt;code&gt;allow_reuse_address = False&lt;/code&gt;, nobody can quietly join you either. Windows also has &lt;code&gt;SO_EXCLUSIVEADDRUSE&lt;/code&gt; for a stronger version of the same guarantee.&lt;/p&gt;

&lt;p&gt;The tradeoff you're accepting is the &lt;code&gt;TIME_WAIT&lt;/code&gt; annoyance coming back. For a long-running dev server that restarts constantly, keep the default. For anything that must be &lt;em&gt;the&lt;/em&gt; listener on a port, turn it off. A short-lived callback catcher is squarely in the second group, and I had it in the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The catcher binds exclusively now, and it ignores requests that arrive without a &lt;code&gt;code&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt; parameter, since a browser will also ask for &lt;code&gt;/favicon.ico&lt;/code&gt; and that shouldn't count as the callback.&lt;/p&gt;

&lt;p&gt;The thing I got wrong wasn't the guess about what could fail. Port already in use was exactly the right failure to plan for. I assumed the operating system would be the one to tell me, and with this class on this platform it stays quiet instead. So if you're binding a well-known port on Windows and then waiting for one specific request, check &lt;code&gt;allow_reuse_address&lt;/code&gt; before you trust the startup message.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>python</category>
    </item>
    <item>
      <title>Things Shadow DOM does not isolate</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Wed, 05 Aug 2026 09:56:44 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/things-shadow-dom-does-not-isolate-3jj3</link>
      <guid>https://dev.to/michael-yousrie/things-shadow-dom-does-not-isolate-3jj3</guid>
      <description>&lt;p&gt;A while back I wrote about using the Shadow DOM to isolate an embeddable widget, and the summary was: styles do not leak in, styles do not leak out, your install shrinks to one script tag.&lt;/p&gt;

&lt;p&gt;That is all still true. It is also the optimistic half. "Isolated" turned out to be a stronger word in my head than it is in the browser, and every gap costs you a bug on somebody else's page, where you cannot open devtools.&lt;/p&gt;

&lt;p&gt;This is the other half. Everything below still crosses the boundary, roughly in the order it caused me trouble building the changelog widget for &lt;a href="https://swyfty.live/patchlog?utm_source=devto&amp;amp;utm_campaign=patchlog" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inheritance walks straight in
&lt;/h2&gt;

&lt;p&gt;The boundary blocks selectors. It does not block inheritance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;font-family&lt;/code&gt;, &lt;code&gt;color&lt;/code&gt;, &lt;code&gt;line-height&lt;/code&gt;, &lt;code&gt;letter-spacing&lt;/code&gt;, &lt;code&gt;text-transform&lt;/code&gt;, &lt;code&gt;visibility&lt;/code&gt;, &lt;code&gt;cursor&lt;/code&gt; and &lt;code&gt;direction&lt;/code&gt; all arrive from the host's ancestors. Drop your widget on a page with &lt;code&gt;text-transform: uppercase&lt;/code&gt; on a wrapper div and the whole panel is shouting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;all&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* all: initial resets display to inline */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line matters more than it looks. &lt;code&gt;all: initial&lt;/code&gt; sets &lt;code&gt;display&lt;/code&gt; back to its initial value, which is &lt;code&gt;inline&lt;/code&gt;, so your layout quietly collapses if you forget it.&lt;/p&gt;

&lt;h2&gt;
  
  
  rem is document scoped
&lt;/h2&gt;

&lt;p&gt;A shadow root does not get its own root element for font sizing, so &lt;code&gt;rem&lt;/code&gt; resolves against the host page's &lt;code&gt;html&lt;/code&gt; element rather than against anything of yours.&lt;/p&gt;

&lt;p&gt;A page still using the old &lt;code&gt;html { font-size: 62.5% }&lt;/code&gt; trick renders your widget at 62.5% of every size you designed. I replaced every &lt;code&gt;rem&lt;/code&gt; with &lt;code&gt;px&lt;/code&gt; and &lt;code&gt;em&lt;/code&gt;. Not elegant, but predictable, and predictability is worth more when you do not control the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  event.target is rewritten, which cuts both ways
&lt;/h2&gt;

&lt;p&gt;My earlier post framed retargeting as a feature, because it stops you leaking internal structure. It is also a trap in the other direction.&lt;/p&gt;

&lt;p&gt;By the time a listener on &lt;code&gt;document&lt;/code&gt; sees a composed event, &lt;code&gt;target&lt;/code&gt; has been rewritten to your host element. So the host page's own click tracking records one identical element for every click anywhere inside your widget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;composedPath&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="c1"&gt;// not e.target&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;document.activeElement&lt;/code&gt; has the same shape of problem and returns the host. Use &lt;code&gt;host.shadowRoot.activeElement&lt;/code&gt; for what is genuinely focused.&lt;/p&gt;

&lt;h2&gt;
  
  
  ID references do not cross, so ARIA breaks silently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;aria-labelledby&lt;/code&gt;, &lt;code&gt;aria-describedby&lt;/code&gt; and &lt;code&gt;for&lt;/code&gt; are all ID references, and IDs are scoped per root. An ID reference pointing across a shadow boundary does not resolve.&lt;/p&gt;

&lt;p&gt;It is not deprecated, it is not discouraged, it simply does nothing, and nothing warns you. Keep the label and the control in the same root, or use &lt;code&gt;aria-label&lt;/code&gt; with a literal string. There is spec work on cross root ARIA. I would not build on it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing outside can select in, including things you wanted
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;document.querySelector&lt;/code&gt; cannot reach your nodes. That is the point, right up until it is not: the host's analytics selectors, their end to end tests and their browser extensions cannot reach them either.&lt;/p&gt;

&lt;p&gt;Worth knowing before someone files a bug: Playwright pierces open shadow roots by default, Cypress needs &lt;code&gt;includeShadowDom&lt;/code&gt;, and anybody poking at your widget with jQuery in the console will conclude it does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layout is not isolated at all
&lt;/h2&gt;

&lt;p&gt;This is the one that cost me an evening.&lt;/p&gt;

&lt;p&gt;Shadow DOM scopes styles. It says nothing about the containing block. If any ancestor has &lt;code&gt;overflow: hidden&lt;/code&gt;, your popover is clipped. If any ancestor has a &lt;code&gt;transform&lt;/code&gt; or a &lt;code&gt;filter&lt;/code&gt;, that ancestor creates a containing block, and your &lt;code&gt;position: fixed&lt;/code&gt; element is suddenly positioned relative to it instead of the viewport.&lt;/p&gt;

&lt;p&gt;Both are properties of where you mounted, not of your CSS. I now append the host element as a direct child of &lt;code&gt;document.body&lt;/code&gt; and position from there, rather than rendering wherever the script tag happens to sit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use adoptedStyleSheets, not a stylesheet link
&lt;/h2&gt;

&lt;p&gt;Put your CSS in the shadow root as an inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;, or use &lt;code&gt;adoptedStyleSheets&lt;/code&gt;. A &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; inside a shadow root gives a visible unstyled flash on every single load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closed mode buys you almost nothing
&lt;/h2&gt;

&lt;p&gt;Anything can patch &lt;code&gt;Element.prototype.attachShadow&lt;/code&gt; before your script runs and keep a reference to every root you create. Closed mode mostly makes your own debugging worse. I said use open last time and I still think so, just for a less flattering reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff nobody mentions up front
&lt;/h2&gt;

&lt;p&gt;You also give up letting the host restyle you. That is exactly what you asked for, and it means "can you make the heading smaller" stops being a two line CSS override on their end and becomes a feature request on yours.&lt;/p&gt;

&lt;p&gt;Custom properties inherit through the boundary and &lt;code&gt;::part()&lt;/code&gt; exposes named hooks, so decide up front how much surface you are willing to expose, then hold that line. I went with a small fixed set: light, dark and auto, plus one accent color.&lt;/p&gt;

&lt;p&gt;If the main selling point of your widget is that it blends into the host's design, Shadow DOM may be the wrong tool, and a carefully namespaced class prefix or an iframe will serve you better. I picked "never break a stranger's site" over "fully themeable". For something people install on trust, I still think that is the right way round, but it is a choice with a cost rather than a free win.&lt;/p&gt;

&lt;p&gt;If you have a cleaner answer than "px and em everywhere" for the rem problem, I would genuinely like to hear it.&lt;/p&gt;

</description>
      <category>css</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I measured 402 posts across 4 founder subreddits before posting to any of them</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Mon, 03 Aug 2026 12:04:12 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/i-measured-402-posts-across-4-founder-subreddits-before-posting-to-any-of-them-4dkg</link>
      <guid>https://dev.to/michael-yousrie/i-measured-402-posts-across-4-founder-subreddits-before-posting-to-any-of-them-4dkg</guid>
      <description>&lt;p&gt;Every "where should I post my SaaS" thread produces the same list of subreddits. I wanted to know whether that list is worth anything, so before posting again I recorded 402 posts across four of them and looked at the numbers.&lt;/p&gt;

&lt;p&gt;Some of what I found changed where I post. More of it changed how I measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I collected
&lt;/h2&gt;

&lt;p&gt;For r/SaaS, r/microsaas, r/indiehackers and r/SideProject I pulled roughly 100 recent posts each and stored the score, the comment count, the author, and whether the post was a link or a text post. Nothing clever, just the public listing.&lt;/p&gt;

&lt;p&gt;Then I re-checked the same post IDs six hours later, which turns out to be the only way to learn anything about removals. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: comments per upvote is a better signal than either number alone
&lt;/h2&gt;

&lt;p&gt;Upvoting is one click. Commenting is work. In a normal community you would expect far more upvotes than comments.&lt;/p&gt;

&lt;p&gt;Here is the ratio of total comments to total upvotes across each sample:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;subreddit&lt;/th&gt;
&lt;th&gt;upvotes&lt;/th&gt;
&lt;th&gt;comments&lt;/th&gt;
&lt;th&gt;comments per upvote&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;r/indiehackers&lt;/td&gt;
&lt;td&gt;1,463&lt;/td&gt;
&lt;td&gt;6,101&lt;/td&gt;
&lt;td&gt;4.17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;r/microsaas&lt;/td&gt;
&lt;td&gt;217&lt;/td&gt;
&lt;td&gt;462&lt;/td&gt;
&lt;td&gt;2.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;r/SaaS&lt;/td&gt;
&lt;td&gt;554&lt;/td&gt;
&lt;td&gt;453&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;r/SideProject&lt;/td&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;td&gt;0.89&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those are inverted, and one is inverted by a factor of four.&lt;/p&gt;

&lt;p&gt;A ratio above 1 means people are commenting on things they are not willing to spend a click endorsing. That is what reciprocal posting looks like: I comment on yours, you comment on mine, neither of us reads the other.&lt;/p&gt;

&lt;p&gt;The top five comment-getters in the r/indiehackers sample were titled, in order: "Share what you're building", "Share what you're building", "i made a free list of 100 places where you can promote your app", "Share what you're building", "100 users is hard." Those five threads pulled 292, 212, 210, 193 and 189 comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: count the authors, not the posts
&lt;/h2&gt;

&lt;p&gt;The same sample of 100 r/indiehackers posts came from &lt;strong&gt;56 unique authors&lt;/strong&gt;. One account wrote 10 of them.&lt;/p&gt;

&lt;p&gt;The other three subs were nothing like that: 93, 94 and 101 unique authors per 100 posts.&lt;/p&gt;

&lt;p&gt;Author count is worth checking anywhere you plan to spend time, because subscriber count cannot tell you the difference between a community and a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: you cannot measure removals by looking
&lt;/h2&gt;

&lt;p&gt;This is the one that generalises beyond Reddit.&lt;/p&gt;

&lt;p&gt;A removed post still looks fine to the person who posted it. Logged in as the author you see your post, your title, your body. Logged out, the body is &lt;code&gt;[removed]&lt;/code&gt;. The API returns HTTP 200 either way.&lt;/p&gt;

&lt;p&gt;Worse, listings only ever show survivors. I checked 100 recent posts in one sub and exactly zero carried a removal marker, which does not mean nothing gets removed. It means removed posts are not in the listing I was reading.&lt;/p&gt;

&lt;p&gt;So the only way to get a removal rate is to record a cohort of post IDs now and re-check those same IDs later, through a logged-out client. That is the difference between "my post is up" and "my post is visible", and I had a post sitting invisible for three weeks before I understood the distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: the median post gets nothing
&lt;/h2&gt;

&lt;p&gt;Across all 402 posts, &lt;strong&gt;50% scored 1 or 0&lt;/strong&gt; and 25% had no comments at all. Median score was 2.&lt;/p&gt;

&lt;p&gt;That is not a criticism of those subreddits. It is what a firehose looks like from inside. But it does mean a single post is a lottery ticket, and the honest way to treat it is as one sample, not as a verdict on the channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part of doing this was realising I had no way to check the conclusion, because not one link I had ever posted carried a campaign tag. Every claim I could make about which channel worked was an argument, not a measurement.&lt;/p&gt;

&lt;p&gt;Platform metrics do not fix that. Upvotes tell you how a post did on the platform, which is a different question from whether anyone came to your site and stayed. Those two answers disagree more often than you would like.&lt;/p&gt;

&lt;p&gt;So now every link gets a source tag at the moment it is posted, rather than being stored pre-tagged. Three things that are worth knowing if you set this up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Answer 302, not 301, on anything you want to count.&lt;/strong&gt; A 301 is a permanent redirect and the browser caches it, so the second click from the same person never reaches your server. The count does not break, it quietly flattens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not bake the source into the stored link.&lt;/strong&gt; Store the bare URL and add &lt;code&gt;utm_source&lt;/code&gt; when you post. Otherwise every channel reports as whichever one you set up first, and you cannot separate them afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A short link resolves to one destination.&lt;/strong&gt; If you route a deep link through it, someone clicks a link about a specific page and lands on your homepage. Tag deep links in place on their own domain instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the target's rules first.&lt;/strong&gt; r/SaaS auto-removes shortened links and says so in its rules, along with a ban threat for disguised links. Posting a tracked link there would have gotten the post deleted, and the zero clicks would have read as "this sub does not convert" rather than "nobody ever saw it". A confident wrong answer is worse than no answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong
&lt;/h2&gt;

&lt;p&gt;My first read of the r/indiehackers ratio was "bots". It is not. The accounts are real people with real post histories.&lt;/p&gt;

&lt;p&gt;It is more awkward than bots. Those subs are full of founders, and founders are there to be seen. Everyone in the room is selling and almost nobody is buying. That makes them a reasonable place to talk to peers and a poor place to expect customers, which is a different conclusion from "Reddit does not work" and leads to different behaviour.&lt;/p&gt;

&lt;p&gt;The thing that still seems worth it: those threads get indexed, and a post answering a real question keeps getting found by people searching that question months later. That is a slower payoff than upvotes and it is the one the platform's own metrics are worst at showing you, which is the whole argument for tagging your own links.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://swyfty.live?utm_source=devto&amp;amp;utm_medium=marketing-saas-analytics-webdev&amp;amp;utm_campaign=swyfty" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, which is the shortener I used for the tagging described above, so treat that section as biased and check the redirect behaviour of whatever you use. The 301 caching problem in particular applies to every shortener, including the one you host yourself.&lt;/p&gt;

</description>
      <category>marketing</category>
      <category>saas</category>
      <category>analytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shipping a read-only API for a small SaaS: the decisions that are not about REST</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 01 Aug 2026 07:35:01 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/shipping-a-read-only-api-for-a-small-saas-the-decisions-that-are-not-about-rest-2bp7</link>
      <guid>https://dev.to/michael-yousrie/shipping-a-read-only-api-for-a-small-saas-the-decisions-that-are-not-about-rest-2bp7</guid>
      <description>&lt;p&gt;Adding an API to a small SaaS is mostly not a REST question. The routes are the easy part. The decisions that bite are about what the API is allowed to see, who it answers to, and how it fails.&lt;/p&gt;

&lt;p&gt;I shipped a read API last week for a product that already had a public widget endpoint and a billing plan with an &lt;code&gt;api_access&lt;/code&gt; flag that had been granting precisely nothing since launch. Here is what actually needed deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read only, on purpose
&lt;/h2&gt;

&lt;p&gt;The first version exposes &lt;code&gt;GET&lt;/code&gt; and nothing else. Not because writes are hard, but because every verb you expose is a promise you maintain forever, and the dangerous verbs are the ones with side effects your users see.&lt;/p&gt;

&lt;p&gt;For a changelog tool the risky verb is publish. A write API means somebody's broken script can push a half written entry to their customers at 3am, and no amount of "well, they called it" makes that a good day. Read gives integrators most of what they actually ask for (pull entries into a docs site, mirror them into Slack, back them up) with none of that.&lt;/p&gt;

&lt;p&gt;Ship the surface you can support. Adding &lt;code&gt;POST&lt;/code&gt; later is a feature announcement. Removing it is a breaking change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API and the UI must agree on what "published" means
&lt;/h2&gt;

&lt;p&gt;This is the one I would put at the top if I could only keep one.&lt;/p&gt;

&lt;p&gt;The product supports scheduled entries: an entry can be marked published with a date in the future, and the widget deliberately hides it until that date arrives. That behaviour lives in a query the widget endpoint uses.&lt;/p&gt;

&lt;p&gt;The API has to use the exact same query. If it filters on &lt;code&gt;status = published&lt;/code&gt; alone, it happily hands out entries the customer scheduled for next Tuesday. Nobody would call that a security hole in a bug report, but it is an embargo leak: the product promised "this goes live Tuesday" and then served it on Friday to anyone with a token.&lt;/p&gt;

&lt;p&gt;So the rule is: any new read surface reuses the existing visibility scope rather than reimplementing the filter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Published means published AND past dated, exactly as the widget sees it.&lt;/span&gt;
    &lt;span class="s1"&gt;'published'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;publishedEntries&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'draft'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$project&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;entries&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;The general form: whenever two code paths answer "what is visible", one of them will drift. Make them the same function. If you cannot, write the test that asserts they agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  401, 403 and 404 are three different sentences
&lt;/h2&gt;

&lt;p&gt;Most APIs blur these and it makes them miserable to integrate with. The distinctions I settled on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;401&lt;/strong&gt; means the token is missing or unknown. Nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;403&lt;/strong&gt; means the token is real but the thing you are asking for is not available to it. That covers two cases: the plan does not include API access, and the project exists but belongs to a different account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;404&lt;/strong&gt; means the resource genuinely does not exist for anybody.&lt;/p&gt;

&lt;p&gt;The second 403 case deserves an argument, because the security reflex is to return 404 for another user's resource so the API does not confirm the ID exists. That reflex is right for an unauthenticated or public endpoint. Here the caller has already proven they hold a valid token for a real account, so the enumeration win is small, and the cost is a developer staring at a 404 for an ID they can see in their dashboard. I took the clearer error.&lt;/p&gt;

&lt;p&gt;The error bodies say which it is, too. "The API is a Pro feature. Upgrade to use it." is a support ticket that never gets opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate on plan data, not on code
&lt;/h2&gt;

&lt;p&gt;The plan check is one line reading a column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;activePlan&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;api_access&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'The API is a Pro feature. Upgrade to use it.'&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;&lt;code&gt;api_access&lt;/code&gt; is a boolean on the plan row, alongside the other entitlement flags. No plan names in controllers, no &lt;code&gt;if ($user-&amp;gt;plan === 'pro')&lt;/code&gt; scattered across the codebase. When a plan changes, or a grandfathered account needs an exception, it is a database update and not a deploy.&lt;/p&gt;

&lt;p&gt;Two smaller things that fall out of this: the check happens server side on every request (never trust a flag the frontend read), and a downgrade takes effect on the next call rather than whenever a cache decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cap the page size, not just the default
&lt;/h2&gt;

&lt;p&gt;Pagination defaults are easy. The cap is the part people skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;MAX_PER_PAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$perPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'per_page'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MAX_PER_PAGE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;?per_page=100000&lt;/code&gt; is a request that reads an entire account into memory and serializes it, and the first person to try it will be a well meaning integrator writing a sync job, not an attacker. Validate the input, clamp the value, and put a rate limit on the route as well. They defend different things: validation bounds one request, the throttle bounds a loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokens: show once, revoke instantly
&lt;/h2&gt;

&lt;p&gt;The token screen creates, lists and revokes. The plain value is displayed exactly once at creation and is not readable afterwards, because only a hash is stored.&lt;/p&gt;

&lt;p&gt;The test worth writing is not "a valid token works". It is "a revoked token stops working on the very next request". That is the assertion that catches the day someone adds a cache in front of token lookup, and it is the entire security promise of a revoke button.&lt;/p&gt;

&lt;p&gt;One limitation I will state plainly rather than let somebody discover: tokens are scoped to the account, not to a single project, so a leaked token reaches everything that account owns. That is acceptable for a first iteration with a small user base, and the fix is a nullable project column applied at lookup. Knowing where your own edges are is more useful than pretending you have none.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tell integrators where data came from
&lt;/h2&gt;

&lt;p&gt;Each entry in the response carries a &lt;code&gt;source&lt;/code&gt; field, &lt;code&gt;manual&lt;/code&gt; or &lt;code&gt;github&lt;/code&gt;, plus the commit range it was drafted from when it came from a push. It costs two columns and it answers a question an integration will otherwise have to guess at.&lt;/p&gt;

&lt;p&gt;Cheap metadata that preserves provenance tends to be worth more than it looks a year later.&lt;/p&gt;




&lt;p&gt;None of this is exotic. It is just the set of things that are annoying to change once other people's scripts depend on them, which is why they are worth an hour before launch rather than a migration after.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, a changelog widget for small SaaS, and this is the API it now ships on the Pro plan: read only over your own projects and entries, four endpoints, bearer authenticated. If you want the same shape, take the decisions rather than the product.&lt;/p&gt;

</description>
      <category>api</category>
      <category>laravel</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Sending your git diffs to an HTTP endpoint from GitHub Actions, and the four things that broke it</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 01 Aug 2026 07:34:35 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/sending-your-git-diffs-to-an-http-endpoint-from-github-actions-and-the-four-things-that-broke-it-4lcf</link>
      <guid>https://dev.to/michael-yousrie/sending-your-git-diffs-to-an-http-endpoint-from-github-actions-and-the-four-things-that-broke-it-4lcf</guid>
      <description>&lt;p&gt;Sending your own diffs somewhere is a surprisingly common need. Post them to an internal service, run them through a review bot, feed them to a summarizer, archive them for compliance. The GitHub Actions job that does it looks like five lines until you run it on a real repo, at which point four separate things break.&lt;/p&gt;

&lt;p&gt;Here is a workflow that works, followed by why each awkward bit is there. Everything is plain &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt;, all present on &lt;code&gt;ubuntu-latest&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Send diffs&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Send changes&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MY_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;BEFORE="${{ github.event.before }}"&lt;/span&gt;
          &lt;span class="s"&gt;AFTER="${{ github.sha }}"&lt;/span&gt;

          &lt;span class="s"&gt;# First push to a new branch has no usable "before" ref.&lt;/span&gt;
          &lt;span class="s"&gt;if ! git cat-file -e "$BEFORE^{commit}" 2&amp;gt;/dev/null; then&lt;/span&gt;
            &lt;span class="s"&gt;BEFORE="$(git rev-parse "$AFTER~1" 2&amp;gt;/dev/null || echo "$AFTER")"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;

          &lt;span class="s"&gt;git diff "$BEFORE" "$AFTER" -- . ':(exclude).env*' ':(exclude)*.lock' &amp;gt; /tmp/full.diff&lt;/span&gt;
          &lt;span class="s"&gt;head -c 200000 /tmp/full.diff &amp;gt; /tmp/send.diff&lt;/span&gt;
          &lt;span class="s"&gt;TRUNCATED=false&lt;/span&gt;
          &lt;span class="s"&gt;[ "$(wc -c &amp;lt; /tmp/full.diff)" -gt 200000 ] &amp;amp;&amp;amp; TRUNCATED=true&lt;/span&gt;

          &lt;span class="s"&gt;git log --format='%H%x09%s%x09%an%x09%aI' "$BEFORE".."$AFTER" \&lt;/span&gt;
            &lt;span class="s"&gt;| jq -Rn '[inputs | split("\t") | {sha:.[0], message:.[1], author:.[2], date:.[3]}]' \&lt;/span&gt;
            &lt;span class="s"&gt;&amp;gt; /tmp/commits.json&lt;/span&gt;

          &lt;span class="s"&gt;jq -n \&lt;/span&gt;
            &lt;span class="s"&gt;--arg repository "${{ github.repository }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--arg before "$BEFORE" \&lt;/span&gt;
            &lt;span class="s"&gt;--arg after "$AFTER" \&lt;/span&gt;
            &lt;span class="s"&gt;--rawfile diff /tmp/send.diff \&lt;/span&gt;
            &lt;span class="s"&gt;--slurpfile commits /tmp/commits.json \&lt;/span&gt;
            &lt;span class="s"&gt;--argjson truncated "$TRUNCATED" \&lt;/span&gt;
            &lt;span class="s"&gt;'{repository:$repository, before:$before, after:$after,&lt;/span&gt;
              &lt;span class="s"&gt;commits:$commits[0], diff:$diff, truncated:$truncated}' \&lt;/span&gt;
            &lt;span class="s"&gt;&amp;gt; /tmp/payload.json&lt;/span&gt;

          &lt;span class="s"&gt;curl -sS -X POST "https://example.com/api/push" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Authorization: Bearer $TOKEN" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Content-Type: application/json" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Accept: application/json" \&lt;/span&gt;
            &lt;span class="s"&gt;--retry 8 --retry-delay 15 --retry-max-time 300 --retry-all-errors \&lt;/span&gt;
            &lt;span class="s"&gt;--fail-with-body \&lt;/span&gt;
            &lt;span class="s"&gt;--data-binary @/tmp/payload.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the parts that are not obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;fetch-depth: 0&lt;/code&gt;, because the default checkout cannot diff
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;actions/checkout&lt;/code&gt; does a shallow clone by default. Shallow means &lt;code&gt;git diff BEFORE..AFTER&lt;/code&gt; fails on anything but the most recent commit, because the older side of the range is not in the local history at all.&lt;/p&gt;

&lt;p&gt;This is the failure that looks like a bug in your endpoint. The workflow goes green on the push where you tested it and starts failing on real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;github.event.before&lt;/code&gt; is not always a commit
&lt;/h2&gt;

&lt;p&gt;On the first push to a new branch, &lt;code&gt;github.event.before&lt;/code&gt; is forty zeros. On a force push it can point at a commit that no longer exists in the remote history.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git cat-file -e "$BEFORE^{commit}"&lt;/code&gt; asks a simple question: does that object exist here and is it a commit? When it does not, the fallback diffs against the parent of the current commit, and if even that fails (an initial commit with no parent), it diffs the commit against itself and sends an empty diff. An empty payload is fine. A crashed workflow on somebody's first push is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write to files, do not put diffs in shell variables
&lt;/h2&gt;

&lt;p&gt;This is the one that cost me the most time, and it fails in a way that reads as nonsense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq: error: Argument list too long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A week of changes can be hundreds of kilobytes. &lt;code&gt;ARG_MAX&lt;/code&gt; on Linux is a couple of megabytes for the whole argument list and environment, but you hit trouble well before that, and the error blames &lt;code&gt;jq&lt;/code&gt; when the kernel refused the &lt;code&gt;exec&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to stop passing large content as arguments. &lt;code&gt;jq --rawfile name path&lt;/code&gt; reads a file in as a JSON string, correctly escaped, and &lt;code&gt;--slurpfile&lt;/code&gt; reads a file of JSON in as a value. The diff never becomes an argument, so it never counts against &lt;code&gt;ARG_MAX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same trick applies to the request body: &lt;code&gt;--data-binary @file&lt;/code&gt; streams from disk instead of expanding on the command line.&lt;/p&gt;

&lt;p&gt;While you are there, use &lt;code&gt;jq -n&lt;/code&gt; to build the whole payload rather than string concatenation. A diff contains quotes, backslashes and newlines, which is exactly the set of characters that turns hand-rolled JSON into an unparseable mess at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cap the size, and say that you capped it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;head -c 200000&lt;/code&gt; bounds what you send. The interesting half is the &lt;code&gt;truncated&lt;/code&gt; flag next to it.&lt;/p&gt;

&lt;p&gt;A consumer that receives a silently truncated diff has no way to know the input was incomplete, so it will report confidently on half a change. Sending the flag lets the other side adapt (in my case, lean on commit messages where the diff runs out). A boolean is cheap. Silent truncation is a correctness bug wearing a performance costume.&lt;/p&gt;

&lt;p&gt;One caveat: &lt;code&gt;head -c&lt;/code&gt; cuts on a byte boundary, so it can split a multibyte character in half. Depending on what reads the file next, that stray tail byte turns into a replacement character or an encoding error further down the pipe. If your repo has non-ASCII content, piping through &lt;code&gt;iconv -c -f utf-8 -t utf-8&lt;/code&gt; after the &lt;code&gt;head&lt;/code&gt; drops the broken tail and costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exclude paths in CI, not on the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git diff -- . ':(exclude).env*'&lt;/code&gt; uses pathspec magic to drop files before the diff is built. It is worth understanding why that placement matters.&lt;/p&gt;

&lt;p&gt;Filtering on the receiving end means the sensitive file was transmitted, arrived, and was then deleted by code that has to be correct. Filtering in CI means it never left the machine. Those are very different promises to make to whoever owns the repo, and only one of them survives a bug on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;Accept: application/json&lt;/code&gt; and &lt;code&gt;--fail-with-body&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Without an &lt;code&gt;Accept&lt;/code&gt; header, a lot of server frameworks answer a validation failure with an HTML error page or a &lt;code&gt;302&lt;/code&gt; back to a login screen. Your workflow then either goes green on a redirect it never followed, or logs a wall of HTML.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--fail-with-body&lt;/code&gt; is the flag people miss. Plain &lt;code&gt;-f&lt;/code&gt; fails the step on a 4xx or 5xx but throws the response body away, which is where the reason lives. &lt;code&gt;--fail-with-body&lt;/code&gt; fails the step and prints the body, so the Actions log tells you what was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry, because your endpoint will be deploying
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--retry 8 --retry-delay 15 --retry-max-time 300 --retry-all-errors&lt;/code&gt; covers about five minutes of downtime.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--retry-all-errors&lt;/code&gt; is the important one. By default &lt;code&gt;curl&lt;/code&gt; retries only on a small set of transient conditions and does not retry on connection refused, which is exactly what you get while the receiving app restarts. Deploys are the single most likely reason a push arrives at an endpoint that is not there, and a push is not repeatable: nobody is going to &lt;code&gt;git push --force&lt;/code&gt; just to trigger your workflow again.&lt;/p&gt;

&lt;p&gt;Make the receiving endpoint idempotent on the commit sha as well. A unique index on the after sha turns a duplicate delivery into a no-op, which is what you want when a retry succeeds after the first attempt actually landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;Four rules, none of them specific to diffs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Assume the range you were handed might not exist, and degrade instead of crashing.&lt;/li&gt;
&lt;li&gt;Keep large content out of argument lists. Files and &lt;code&gt;--rawfile&lt;/code&gt; are not a style preference.&lt;/li&gt;
&lt;li&gt;Truncate loudly and let the consumer know.&lt;/li&gt;
&lt;li&gt;Make failures readable in the Actions log, and retry the network like the other side might be restarting, because it might be.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, and this workflow is the one it hands you for turning pushes into draft changelog entries, which is why it has been beaten on by real repos. The traps above are yours to use with any endpoint at all.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>git</category>
      <category>bash</category>
    </item>
    <item>
      <title>I turned git pushes into changelog drafts with an LLM. The prompt was the easy part.</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 01 Aug 2026 07:34:28 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/i-turned-git-pushes-into-changelog-drafts-with-an-llm-the-prompt-was-the-easy-part-o0h</link>
      <guid>https://dev.to/michael-yousrie/i-turned-git-pushes-into-changelog-drafts-with-an-llm-the-prompt-was-the-easy-part-o0h</guid>
      <description>&lt;p&gt;If you are adding an LLM feature to an existing product, you will spend a week on the prompt and then discover the prompt was never the risky part. The risky parts are what you feed it, how often you call it, what you do with a bad response, and what you keep afterwards.&lt;/p&gt;

&lt;p&gt;I just shipped a feature that turns pushes to a Git repo into draft changelog entries. Here are the five decisions that actually determined whether it worked, none of which are prompt engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Do not generate on every event
&lt;/h2&gt;

&lt;p&gt;The obvious design is: push arrives, generate an entry, done. It is wrong for two reasons.&lt;/p&gt;

&lt;p&gt;The first is volume. A normal working day produces pushes like "fix typo", "wip", "actually fix it". One entry per push gives you twenty drafts a day and a review queue nobody opens.&lt;/p&gt;

&lt;p&gt;The second is worse. A single feature usually lands across several pushes. Generating per push means the model never sees the whole change, so it writes three half descriptions of one thing instead of one description of the thing.&lt;/p&gt;

&lt;p&gt;So pushes go into a buffer, and generation runs on a cadence. Each run gets a window of work and can see the shape of it. The cadence is the user's choice (hourly for a repo that ships all day, weekly for a side project), and the scheduler ticks more often than the shortest cadence so that "hourly" is not secretly "up to an hour late".&lt;/p&gt;

&lt;p&gt;Buffering also splits the two halves at exactly the right seam. Ingestion has to be fast, because a CI step is waiting on the HTTP response. Generation takes minutes. Put them in the same request and you have a CI job that hangs on a language model.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Decide the output granularity before you write the prompt
&lt;/h2&gt;

&lt;p&gt;"Summarize this week's work" gives you one blob of text. That reads like a status report, and status reports are not what anyone subscribes to.&lt;/p&gt;

&lt;p&gt;The right unit was already sitting in the database schema. Each changelog entry has one type (feature, fix, improvement, security) and renders as one item with one badge. So the contract is N entries, one per user-facing change, and the model is told to cluster related commits into a single entry.&lt;/p&gt;

&lt;p&gt;The general lesson: your existing schema usually already encodes the granularity your users think in. Match it. If the model's output shape and your storage shape disagree, you will either mangle the output on the way in or build a second review screen you did not need.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. An empty response is a success
&lt;/h2&gt;

&lt;p&gt;This one is easy to get wrong and expensive to leave wrong.&lt;/p&gt;

&lt;p&gt;A week of dependency bumps, refactors, CI fixes and test changes contains nothing a user can see. The correct output for that week is nothing at all.&lt;/p&gt;

&lt;p&gt;If you do not say that explicitly, the model will invent something. It has been handed a pile of work and asked for changelog entries, and returning nothing feels like failure, so you get "Improved performance and stability" on a week where you renamed a variable. That is how changelogs become noise, and it is the model doing exactly what it was asked.&lt;/p&gt;

&lt;p&gt;So the contract has an explicit success case for nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"entries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the prompt says, in as many words, that this is a correct and expected answer, not a failure, and that inventing an entry to avoid an empty result is worse than the empty result.&lt;/p&gt;

&lt;p&gt;Worth generalizing: for any extraction or summarization feature, define the empty answer as a first class output and say so in the prompt. Otherwise "no signal here" becomes "here is some noise".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. House rules belong in a validator, not only in the prompt
&lt;/h2&gt;

&lt;p&gt;I have a hard rule that nothing I publish contains an em dash or an en dash. It is the single most recognizable tell of generated text, and these drafts become public changelog copy on somebody else's website.&lt;/p&gt;

&lt;p&gt;Putting that rule in the prompt is necessary and not sufficient. Prompts are a request. Models comply most of the time, which is precisely the failure mode you cannot ship on, because "most of the time" means it appears in production copy on a day you are not looking.&lt;/p&gt;

&lt;p&gt;So the rule is also a validator. Every title and body is checked, and a response containing a banned character is rejected in full, then retried once with the rejection reason fed back so the retry knows what to fix. If it fails again, the job fails and creates nothing.&lt;/p&gt;

&lt;p&gt;Three properties of that design are worth stealing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reject the whole response, not the bad entry.&lt;/strong&gt; Persisting three of four drafts leaves a human to work out which one is missing and why. Rejecting everything costs one retry and keeps the state simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feed the reason back.&lt;/strong&gt; "Your previous reply was rejected: a title contained an em dash. Fix that and reply again." is a much better retry than the same prompt sent twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Losing a run beats shipping a violation.&lt;/strong&gt; Whatever your equivalent rule is (no pricing claims, no customer names, no promises about unreleased features), decide up front which side you fail on. Write the rule as code, because a rule that lives only in the prompt is a preference.&lt;/p&gt;

&lt;p&gt;And do not fix it with find and replace. Swapping an em dash for a comma leaves the same rhythm behind, which is the actual tell. Ask for a rewrite or drop the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Delete the input as soon as you are done with it
&lt;/h2&gt;

&lt;p&gt;To write a decent entry from commit messages like "fix stuff", the model needs the diff. Which means the feature asks people to send their source code to a third party, and how that is handled is a real question, not a paragraph in a privacy policy.&lt;/p&gt;

&lt;p&gt;The design I settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exclusions are applied in CI, before anything leaves the repo, so excluded paths are never transmitted rather than transmitted and then dropped.&lt;/li&gt;
&lt;li&gt;The diff is stored only until a run consumes it, then nulled in the same transaction that writes the drafts.&lt;/li&gt;
&lt;li&gt;A sweep nulls diffs on anything a run never reached, so source does not linger because generation kept failing.&lt;/li&gt;
&lt;li&gt;Commit messages and stats stay, because they are the audit trail for where a draft came from and they carry no source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a real cost to this, and it is worth naming rather than hiding: once the diffs are gone, a draft cannot be regenerated. You get the drafts from that run or you write it yourself. I took that trade because "we delete your code" is only worth saying if it is literally true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same shape. The model is a component with a contract: defined input, defined output, defined failure. The prompt describes the contract, the code enforces it, and the retention policy decides what the component is allowed to remember.&lt;/p&gt;

&lt;p&gt;Teams that treat the prompt as the feature end up with a system whose behaviour is a matter of opinion. Teams that treat the prompt as one part of a contract can actually ship the thing.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, a changelog widget for small SaaS, which is where this feature lives (it is on the Pro plan, and it is GitHub only for now, one repo per project, push triggers only). Everything above is how it is built, so you can take the design without taking the product.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>UTM conventions that stop your analytics turning to mush</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:42:05 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/utm-conventions-that-stop-your-analytics-turning-to-mush-332h</link>
      <guid>https://dev.to/michael-yousrie/utm-conventions-that-stop-your-analytics-turning-to-mush-332h</guid>
      <description>&lt;p&gt;Most broken analytics I have seen were not broken by a bug. They were broken by three people tagging links slightly differently for six months.&lt;/p&gt;

&lt;p&gt;UTM parameters have no schema. Nothing validates them. Your analytics tool takes whatever string you put in &lt;code&gt;utm_source&lt;/code&gt; and treats it as gospel, which means the discipline has to live in you, not the tool. Here is a convention that takes ten minutes to adopt and saves you from a report you cannot trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five parameters, and what each is actually for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;utm_source&lt;/code&gt;: &lt;strong&gt;where the click came from&lt;/strong&gt;. A single named place: &lt;code&gt;newsletter&lt;/code&gt;, &lt;code&gt;instagram&lt;/code&gt;, &lt;code&gt;partner-acme&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_medium&lt;/code&gt;: &lt;strong&gt;what kind of channel it is&lt;/strong&gt;: &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;social&lt;/code&gt;, &lt;code&gt;cpc&lt;/code&gt;, &lt;code&gt;qr&lt;/code&gt;, &lt;code&gt;referral&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_campaign&lt;/code&gt;: &lt;strong&gt;which initiative it belongs to&lt;/strong&gt;: &lt;code&gt;spring-launch&lt;/code&gt;, &lt;code&gt;black-friday-2026&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_content&lt;/code&gt;: &lt;strong&gt;which specific link&lt;/strong&gt;, when one page has several: &lt;code&gt;header-cta&lt;/code&gt;, &lt;code&gt;footer-link&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_term&lt;/code&gt;: paid keyword. Mostly vestigial unless you are running search ads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction people get wrong is source versus medium. Source is the &lt;em&gt;place&lt;/em&gt;, medium is the &lt;em&gt;type&lt;/em&gt;. Instagram is a source; social is a medium. Your newsletter is a source; email is a medium. If you find yourself putting &lt;code&gt;instagram&lt;/code&gt; in medium, the whole report will fragment later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: lowercase everything, always
&lt;/h2&gt;

&lt;p&gt;This is the single highest-value rule, so it goes first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;utm_source=Instagram
utm_source=instagram
utm_source=INSTAGRAM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most analytics tools treat those as &lt;strong&gt;three different sources&lt;/strong&gt;. You will not notice for a month. Then you will open a report, see Instagram split across three rows, and have no clean way to merge history.&lt;/p&gt;

&lt;p&gt;Lowercase everything. No exceptions, no judgment calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: hyphens, never spaces or underscores
&lt;/h2&gt;

&lt;p&gt;A space becomes &lt;code&gt;%20&lt;/code&gt; and looks broken in reports. Underscores are fine technically but mixing them with hyphens gives you the same fragmentation problem as casing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ utm_campaign=Spring Launch
❌ utm_campaign=spring_launch
✅ utm_campaign=spring-launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick hyphens. Write it down. Done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: date your campaigns
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;utm_campaign=black-friday&lt;/code&gt; seems fine until it is your third Black Friday and every year is stacked into one row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ utm_campaign=black-friday-2026
✅ utm_campaign=2026-03-product-launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Year-first works well if you want campaigns to sort chronologically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: never tag internal links
&lt;/h2&gt;

&lt;p&gt;This is the one that silently corrupts data.&lt;/p&gt;

&lt;p&gt;If you tag a link from your own homepage to your own pricing page, most analytics tools start a &lt;strong&gt;new session&lt;/strong&gt; at that click and overwrite the original referrer. The visitor came from a newsletter, clicked one internal link, and is now attributed to your own site.&lt;/p&gt;

&lt;p&gt;UTMs are for traffic arriving from outside. Internal navigation is what your normal page-path reporting is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: keep a canonical list
&lt;/h2&gt;

&lt;p&gt;Not a spreadsheet nobody opens. A short list in whatever doc your team actually reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;newsletter, instagram, tiktok, linkedin, youtube, partner-&amp;lt;name&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;mediums&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;email, social, cpc, qr, referral, print&lt;/span&gt;
&lt;span class="na"&gt;campaign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;yyyy&amp;gt;-&amp;lt;mm&amp;gt;-&amp;lt;short-name&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;header-cta, inline-link, footer-link, bio-link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is: &lt;strong&gt;if it is not on the list, it does not get used until it is added to the list.&lt;/strong&gt; That single sentence is what prevents drift, because the failure mode is never one bad decision, it is fifty small ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 6: think about what the user sees
&lt;/h2&gt;

&lt;p&gt;A URL like this in an Instagram bio is ugly, and it leaks your internal campaign naming to anyone who looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/pricing?utm_source=instagram&amp;amp;utm_medium=social&amp;amp;utm_campaign=2026-03-launch&amp;amp;utm_content=bio-link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where a short link earns its place. The tagged URL becomes the destination, and the thing you publish stays clean. You keep the attribution and the reader gets a link that fits.&lt;/p&gt;

&lt;p&gt;It also means the tagging lives in one editable place. Get a UTM wrong on a printed QR code and you are reprinting; get it wrong behind a short link and you fix the destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 7: QR codes need &lt;code&gt;utm_medium=qr&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Scans from print are not "direct" traffic, but that is where they land if you do not tag them. Tag the URL behind the code, and if the same campaign runs on a poster and a flyer, separate them with &lt;code&gt;utm_content&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;utm_source=poster&amp;amp;utm_medium=qr&amp;amp;utm_campaign=2026-03-launch&amp;amp;utm_content=cafe-window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "did the flyers do anything" is a question with an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Spring launch, promoted through the newsletter, Instagram bio, and a printed flyer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# newsletter, main button
?utm_source=newsletter&amp;amp;utm_medium=email&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=header-cta

# instagram bio link
?utm_source=instagram&amp;amp;utm_medium=social&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=bio-link

# flyer QR code
?utm_source=flyer&amp;amp;utm_medium=qr&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=cafe-window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same campaign across all three, so they roll up. Different sources and mediums, so they break down. That is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that catches drift
&lt;/h2&gt;

&lt;p&gt;Once a month, list your distinct &lt;code&gt;utm_source&lt;/code&gt; values. If you see &lt;code&gt;instagram&lt;/code&gt; and &lt;code&gt;Instagram&lt;/code&gt;, or &lt;code&gt;ig&lt;/code&gt; and &lt;code&gt;insta&lt;/code&gt;, drift has started. Fix the convention now, because merging historical rows is unpleasant and sometimes impossible.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://swyfty.live" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, a short-link and analytics tool, which is why I have opinions about this. But none of the above needs my product or any product. It needs a list of allowed values and the discipline to stick to it.&lt;/p&gt;

&lt;p&gt;The tooling is never what makes attribution work. Consistency is. A boring convention applied every time beats a sophisticated analytics stack fed inconsistent strings, every single time.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>marketing</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Bitly alternatives: the limit that bites is data retention, not links</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:42:03 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/bitly-alternatives-the-limit-that-bites-is-data-retention-not-links-54b</link>
      <guid>https://dev.to/michael-yousrie/bitly-alternatives-the-limit-that-bites-is-data-retention-not-links-54b</guid>
      <description>&lt;p&gt;If you are comparing link shorteners, you have probably lined them up by price per month and by how many links each tier allows. That is the comparison every pricing page invites you to make, and it hides the limit that actually bites.&lt;/p&gt;

&lt;p&gt;For click analytics, the limit that matters is &lt;strong&gt;how long your data survives&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bitly actually meters
&lt;/h2&gt;

&lt;p&gt;Bitly's published pricing, as of July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Links/month&lt;/th&gt;
&lt;th&gt;Click data history&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;(not offered)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core&lt;/td&gt;
&lt;td&gt;$10/mo, annual only ($120/yr)&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 days&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Growth&lt;/td&gt;
&lt;td&gt;$29/mo annual, or $35/mo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4 months&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;td&gt;$199/mo annual, or $300/mo&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 year&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;td&gt;2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Clicks themselves are unlimited on every tier. That sounds generous, and it is the number everyone quotes. But look at the right-hand column.&lt;/p&gt;

&lt;p&gt;On Core, your click history is &lt;strong&gt;30 days&lt;/strong&gt;. Run a campaign in March, come back in May to see how it did, and there is nothing to see. The clicks happened. The record of them expired.&lt;/p&gt;

&lt;p&gt;The second thing worth noticing: &lt;strong&gt;city-level and device tracking sit on Premium&lt;/strong&gt;. That is $199/month billed annually, or $300 month to month. If "I just want to know where my clicks come from and what they are opening it on" is your entire requirement, Bitly's answer is its top self-serve tier.&lt;/p&gt;

&lt;p&gt;None of this makes Bitly a bad product. It is a mature platform with a real enterprise business, and the pricing is shaped for that buyer. But if you are one person with some links and a question about them, you are shopping in the wrong aisle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question to ask every shortener
&lt;/h2&gt;

&lt;p&gt;Before you compare prices, ask this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"In six months, can I still see what happened to a link I made today?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It reframes the whole evaluation. A $10/month plan that forgets everything after 30 days is not cheaper than a $7/month plan that does not. It is a different product that happens to have a similar-looking price.&lt;/p&gt;

&lt;p&gt;Then two follow-ups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Is the granularity I need on the tier I can afford?"&lt;/strong&gt; Country-level is common on cheap plans. City and device often are not. Decide which you actually need before you pay for a tier that gives you neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What happens to my links if I stop paying?"&lt;/strong&gt; This is the one people find out about late. A short link is a promise printed on things you no longer control: business cards, packaging, an old tweet, someone else's blog post. If those links go dead or start showing a parked page when you downgrade, you have handed your reputation to a billing relationship. Ask before you print.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for comparing link tools
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data retention window.&lt;/strong&gt; The single most under-compared spec. Get the number in months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granularity per tier.&lt;/strong&gt; Country, city, device, browser, referrer, unique versus repeat. Which of those are on the plan you would actually buy?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique versus total clicks.&lt;/strong&gt; If a tool only reports total clicks, one enthusiastic person and a preview bot can look like a successful campaign. You want both numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens on downgrade or cancellation.&lt;/strong&gt; Do existing links keep resolving?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link creation limits.&lt;/strong&gt; Monthly caps are common. If you generate links programmatically, a 100/month cap disappears fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain cost.&lt;/strong&gt; Often the real jump between tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there an API on the plan you can afford?&lt;/strong&gt; If links need to come out of your own tooling rather than a dashboard, check this early. It is frequently gated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export.&lt;/strong&gt; Can you get a CSV out? If the answer is no, the data was never really yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list is the useful part of this post. Run it against any tool, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Swyfty fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I build &lt;a href="https://swyfty.live" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, so treat this section as the biased part and check it yourself.&lt;/p&gt;

&lt;p&gt;It exists because of the gap above. Short links, click analytics, QR codes and a link-in-bio page behind one login, with a REST API. Analytics include country and city, device, browser, referrer, and unique versus repeat visitors, and they are on the $7/month plan rather than a $199 one. There is CSV export, so the data leaves with you. Free covers 3 links, 500 visits each, and one bio page, with a short interstitial before each redirect; Pro is $7/month or $60/year and removes the interstitial, adds custom slugs, QR codes and hourly checks that your destination URLs are still alive.&lt;/p&gt;

&lt;p&gt;Now the honest part, because a comparison post that only lists strengths is an ad. Swyfty does &lt;strong&gt;not&lt;/strong&gt; have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team accounts or multiple seats&lt;/li&gt;
&lt;li&gt;White-label custom domains&lt;/li&gt;
&lt;li&gt;Retargeting pixels&lt;/li&gt;
&lt;li&gt;A/B testing of links&lt;/li&gt;
&lt;li&gt;Scheduled link expiry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need branded custom domains at scale, or several people working in the same account with permissions, &lt;strong&gt;Bitly or Rebrandly is genuinely the better buy&lt;/strong&gt; and I would rather you went there than churned out of mine in a month. If all you want is a bio page and nothing else, Linktree has a much larger template ecosystem.&lt;/p&gt;

&lt;p&gt;It is also early. There is no traction to point at, no testimonials, and I am not going to invent any. What I can tell you is what the product does and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule
&lt;/h2&gt;

&lt;p&gt;Are you buying link infrastructure for a team, or answers about your own links?&lt;/p&gt;

&lt;p&gt;If it is a team, buy the platform. Seats, permissions, branded domains and long retention are what those prices are for, and they are worth it at that size.&lt;/p&gt;

&lt;p&gt;If it is answers, then retention and granularity are the only specs that matter, and you should not have to reach an enterprise tier to keep six months of your own click history.&lt;/p&gt;

&lt;p&gt;Either way: compare the retention column first. It is the one nobody puts in the comparison table, and it is the one you notice in six months when you go looking for a number that is no longer there.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>analytics</category>
      <category>marketing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
