<?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: jidonglab</title>
    <description>The latest articles on DEV Community by jidonglab (@ji_ai).</description>
    <link>https://dev.to/ji_ai</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%2F3791767%2F6eb19afc-a99c-4736-9d12-459108893a16.png</url>
      <title>DEV Community: jidonglab</title>
      <link>https://dev.to/ji_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ji_ai"/>
    <language>en</language>
    <item>
      <title>Python requests timeout=60 Let My LLM Stream Run for 38 Minutes</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:16:44 +0000</pubDate>
      <link>https://dev.to/ji_ai/python-requests-timeout60-let-my-llm-stream-run-for-38-minutes-2mf5</link>
      <guid>https://dev.to/ji_ai/python-requests-timeout60-let-my-llm-stream-run-for-38-minutes-2mf5</guid>
      <description>&lt;p&gt;My nightly summarization job had &lt;code&gt;timeout=60&lt;/code&gt; on every request. One call ran for 38 minutes and 12 seconds anyway. Nothing crashed and nothing logged an error. The 6:00 a.m. report just didn't show up.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Python requests timeout&lt;/strong&gt; is not a deadline for the whole call. It caps how long you wait for the &lt;em&gt;next bytes&lt;/em&gt;. Point it at a streaming LLM endpoint that emits a token every 70 milliseconds and that timeout never fires.&lt;/p&gt;

&lt;p&gt;This post covers what I found over 14 nights and 33,600 calls, how I tracked it down, and the 20 lines that fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;requests.post(..., timeout=60)&lt;/code&gt; sets a &lt;strong&gt;connect timeout&lt;/strong&gt; and a &lt;strong&gt;read timeout&lt;/strong&gt;. The read timeout is the longest allowed gap &lt;em&gt;between bytes&lt;/em&gt;, not the total duration.&lt;/li&gt;
&lt;li&gt;A streaming LLM response sends bytes constantly, so it never trips the read timeout, even when the model is stuck in a repetition loop for half an hour.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;httpx&lt;/code&gt; works the same way: &lt;code&gt;Timeout(60)&lt;/code&gt; covers connect, read, write and pool separately. There is no total-time cap by default.&lt;/li&gt;
&lt;li&gt;Fix: set &lt;code&gt;max_tokens&lt;/code&gt;, keep a short read timeout, &lt;strong&gt;and&lt;/strong&gt; enforce a wall-clock deadline yourself (a check inside the stream loop, or &lt;code&gt;asyncio.timeout()&lt;/code&gt; around the whole call).&lt;/li&gt;
&lt;li&gt;After the fix: 0 missed reports in the next 14 nights. The longest call dropped from 38m12s to 1m51s.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Nothing exotic here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 7B instruct model served by vLLM through its OpenAI-compatible endpoint&lt;/li&gt;
&lt;li&gt;A Python batch job with 6 worker threads&lt;/li&gt;
&lt;li&gt;About 2,400 support tickets per night, each summarized into a short JSON blob&lt;/li&gt;
&lt;li&gt;Streaming on, so I could log time-to-first-token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The call looked like this, and I'd guess half the LLM scripts on GitHub look the same:&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_lines&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&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;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;line&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;data: [DONE]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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;I read &lt;code&gt;timeout=60&lt;/code&gt; as "this call dies after a minute." That was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the timeout parameter in Python requests actually do?
&lt;/h2&gt;

&lt;p&gt;The Python requests timeout sets two limits: how long to wait for the TCP connection, and how long to wait between bytes on the socket. Passing one number sets both to that value. Passing a tuple like &lt;code&gt;(5, 30)&lt;/code&gt; sets them separately. Neither one limits total request time.&lt;/p&gt;

&lt;p&gt;The requests docs say this outright. The read timeout is the number of seconds the client will wait for the server to send a byte. Every byte that arrives resets that clock.&lt;/p&gt;

&lt;p&gt;For a normal JSON API this almost never matters. The server is silent until it has an answer, so "time between bytes" and "total time" are basically the same number.&lt;/p&gt;

&lt;p&gt;Streaming changes that. A server streaming tokens is never silent for long. My model produced a chunk roughly every 70 ms under load, so the read timeout restarted about 14 times a second. A 60-second read timeout couldn't fire unless the model stopped completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why would an LLM stream run for 38 minutes?
&lt;/h2&gt;

&lt;p&gt;It ran that long because the model got stuck repeating itself, and nothing told it to stop. I hadn't set &lt;code&gt;max_tokens&lt;/code&gt;. vLLM's OpenAI-compatible server then allows generation up to whatever context window is left after the prompt. My context was 32k tokens, the prompts were about 1.5k, and that left roughly 30,000 tokens of room.&lt;/p&gt;

&lt;p&gt;At around 15 tokens per second per request under batch load, 30,000 tokens takes about 33 minutes. My worst call ran 38. The numbers line up.&lt;/p&gt;

&lt;p&gt;The outputs from those calls looked like what you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"summary": "Customer reports login failure. Customer reports login failure.
Customer reports login failure. Customer reports login failure. ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That repeated a few thousand times. Some tickets (very long ones, pasted stack traces, logs full of near-identical lines) push small models into these loops. It's rare, but over 2,400 calls a night it happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How rare, and how bad?
&lt;/h2&gt;

&lt;p&gt;Here's what 14 nights of logs showed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total calls&lt;/td&gt;
&lt;td&gt;33,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 duration&lt;/td&gt;
&lt;td&gt;4.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calls over 5 minutes&lt;/td&gt;
&lt;td&gt;61 (0.18%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest call&lt;/td&gt;
&lt;td&gt;38m 12s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU time spent on those 61 calls&lt;/td&gt;
&lt;td&gt;~9.2 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nights the 6:00 a.m. report was late&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;0.18% sounds harmless until you remember there were only 6 workers. When 2 or 3 runaway calls landed on the same night, a third to half of my capacity spent half an hour generating "Customer reports login failure." The other workers also slowed down, because every runaway sequence took a slot in vLLM's batch and a chunk of KV cache.&lt;/p&gt;

&lt;p&gt;On the 3 late nights, 4 or more workers were stuck at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;The job wasn't failing, so I had no errors to read. What I had:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;py-spy dump&lt;/code&gt; on the stuck process.&lt;/strong&gt; All 6 threads were sitting in &lt;code&gt;iter_lines()&lt;/code&gt; → &lt;code&gt;urllib3&lt;/code&gt; → &lt;code&gt;socket.recv&lt;/code&gt;. So they weren't deadlocked. They were busy receiving data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vLLM's &lt;code&gt;/metrics&lt;/code&gt; endpoint.&lt;/strong&gt; &lt;code&gt;vllm:num_requests_running&lt;/code&gt; sat at 6 with nothing waiting. The server thought it was doing useful work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging output length.&lt;/strong&gt; Normal summaries came out at 150 to 400 tokens. The 61 slow calls averaged about 21,000.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I wrote a 15-line test server that sent one SSE line every 30 seconds and pointed &lt;code&gt;requests.post(..., timeout=60)&lt;/code&gt; at it. It sat there for an hour until I killed it. That settled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set a total timeout for a streaming request in Python?
&lt;/h2&gt;

&lt;p&gt;Set a total timeout by enforcing a wall-clock deadline in your own code. Neither &lt;code&gt;requests&lt;/code&gt; nor &lt;code&gt;httpx&lt;/code&gt; gives you one. Check elapsed time on every chunk, cap generation with &lt;code&gt;max_tokens&lt;/code&gt;, and close the connection when you give up so the server stops generating.&lt;/p&gt;

&lt;p&gt;Here's what I run now:&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;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_timeout&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_lines&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TimeoutError&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;exceeded &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;deadline_s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s wall clock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&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;data: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;line&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;data: [DONE]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delta&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;finish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choice&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things changed, and each one matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_tokens=1024&lt;/code&gt;.&lt;/strong&gt; My longest legitimate summary was about 600 tokens. This alone puts a ceiling on the runaway case at roughly 70 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timeout=(5, 20)&lt;/code&gt;.&lt;/strong&gt; Short connect timeout, moderate read timeout. This still catches a server that goes completely silent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deadline check inside the loop.&lt;/strong&gt; This is what actually caps total time. It only runs when a chunk arrives, so the true worst case is &lt;code&gt;deadline_s + read_timeout&lt;/code&gt;, here 110 seconds. Know that number before you promise anyone an SLA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;with&lt;/code&gt; block.&lt;/strong&gt; Leaving it closes the socket. vLLM notices the disconnect and aborts the request, so the GPU stops generating junk for nobody. My server logs showed the aborts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is there a hard cap that doesn't depend on chunks arriving?
&lt;/h2&gt;

&lt;p&gt;Yes. With async code, wrap the entire call in &lt;code&gt;asyncio.timeout()&lt;/code&gt; (Python 3.11+). It cancels the task at the deadline whether or not bytes are flowing:&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;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stream_completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aiter_lines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancellation unwinds the &lt;code&gt;async with&lt;/code&gt; and closes the stream. This is my preferred version. I kept the sync one above because the batch job is threaded and I didn't want to rewrite it at 2 a.m.&lt;/p&gt;

&lt;p&gt;If you use an SDK built on httpx, like the OpenAI Python client, read what its &lt;code&gt;timeout&lt;/code&gt; argument means for streams in the version you have installed. Don't assume it's a total deadline. Wrap the call in your own deadline either way. It costs one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the 58 calls that hit max_tokens?
&lt;/h2&gt;

&lt;p&gt;Capping generation doesn't fix the loop. It just makes the loop cheap. In the 14 nights after the fix, 58 calls ended with &lt;code&gt;finish_reason == "length"&lt;/code&gt;, which in this job always meant a repetition loop.&lt;/p&gt;

&lt;p&gt;I handle those explicitly now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never parse them as valid output. A truncated JSON blob full of repeated sentences is garbage.&lt;/li&gt;
&lt;li&gt;Retry once at a slightly higher temperature (0.3 instead of 0). &lt;strong&gt;49 of 58&lt;/strong&gt; came back clean.&lt;/li&gt;
&lt;li&gt;Send the other &lt;strong&gt;9&lt;/strong&gt; to a manual review queue with the ticket ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 9 tickets a fortnight for a person to look at, instead of 9 GPU-hours of "Customer reports login failure."&lt;/p&gt;

&lt;h2&gt;
  
  
  Results after the fix
&lt;/h2&gt;

&lt;p&gt;Same job, same model, next 14 nights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Longest call: &lt;strong&gt;1m 51s&lt;/strong&gt; (it hit &lt;code&gt;max_tokens&lt;/code&gt;, then went through the retry path)&lt;/li&gt;
&lt;li&gt;Calls over 5 minutes: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Late reports: &lt;strong&gt;0&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Total nightly runtime dropped by about 11%, mostly because runaway sequences stopped taking batch slots from healthy requests&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist I now apply to every LLM HTTP call
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Always set &lt;code&gt;max_tokens&lt;/code&gt;. A server default of "whatever context is left" is not a limit.&lt;/li&gt;
&lt;li&gt;Use a tuple timeout: short connect, moderate read.&lt;/li&gt;
&lt;li&gt;Add a wall-clock deadline that you enforce yourself.&lt;/li&gt;
&lt;li&gt;Close the stream when you give up so the server stops generating.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;finish_reason == "length"&lt;/code&gt; as a failure until proven otherwise.&lt;/li&gt;
&lt;li&gt;Log output token counts. That's how the 21,000-token outliers showed up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  So why didn't timeout=60 stop my request?
&lt;/h2&gt;

&lt;p&gt;Because the Python requests timeout only limits connecting and the gap between received bytes. It never limits total request duration. A streaming LLM response sends a byte every few milliseconds, so the read timeout keeps resetting and a call can run as long as the model keeps generating: 38 minutes in my case, with no &lt;code&gt;max_tokens&lt;/code&gt; set. To actually bound a streaming call, set &lt;code&gt;max_tokens&lt;/code&gt;, keep a short read timeout, enforce a wall-clock deadline in your own loop or with &lt;code&gt;asyncio.timeout()&lt;/code&gt;, and close the connection when the deadline hits so the server stops generating.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>performance</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Claude Code Sign-in Fix Needed Two Fixes of Its Own</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 18:29:43 +0000</pubDate>
      <link>https://dev.to/ji_ai/my-claude-code-sign-in-fix-needed-two-fixes-of-its-own-14m9</link>
      <guid>https://dev.to/ji_ai/my-claude-code-sign-in-fix-needed-two-fixes-of-its-own-14m9</guid>
      <description>&lt;p&gt;On September 29 I removed a sign-in refusal from my app. The next day a review pass whose only job was to break that change found that it left some sign-ins reported as ready that then failed, and my repair of that opened a second hole. Both were caught before the first public release on October 1.&lt;/p&gt;

&lt;p&gt;Refmade is a desktop app that wraps the Claude Code and Codex command-line tools and draws what they are doing as an office floor. It is an independent app, not affiliated with Anthropic or OpenAI. The UI is Korean right now; that changes later in this series. Claude Code and Codex agents wrote most of the code, working from specs I gave them. I planned the work, made the calls and reviewed the results. This post is about one call that turned out to be half right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the app refused a sign-in the CLI accepts
&lt;/h2&gt;

&lt;p&gt;The app has a "team" mode, where several agents plan, write and check a task. Until September 29 it only worked when Claude Code was signed in with a Claude account (the plan login). If you had an API key instead, the app answered with a reason code, &lt;code&gt;AUTH_MODE_UNSUPPORTED&lt;/code&gt;, and stopped.&lt;/p&gt;

&lt;p&gt;I read Anthropic's documentation around that refusal. My conclusion was that an app wrapping the CLI should not refuse a sign-in method the CLI itself supports. I'm a developer, not a lawyer, and this is not legal advice. It is simply how I decided to design it, so I asked for the refusal to be removed.&lt;/p&gt;

&lt;p&gt;The app also tells people what it does with their data. Here is the first screen as it looked in a capture from the end of September:&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%2Ffjfhmjzz0t0fj11rgdx5.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%2Ffjfhmjzz0t0fj11rgdx5.png" alt="Refmade preview-mode first screen with two cards, Claude Code ready and Codex needing login, and four plain notes below about data, sign-in, usage and wrong answers" width="800" height="599"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Preview-mode first screen, late September (Korean UI, demo state). Title: "First, let's get an AI ready." Cards: Claude Code "ready", Codex "login needed". Notes below, translated: the app itself sends nothing to the internet; when you hand it a task, the Claude Code or Codex on your computer sends your request and needed file contents to Anthropic, OpenAI or wherever you connected, and checking that the AI is ready can also reach Anthropic; sign-in is managed by each AI tool and the app only checks whether and how you are signed in; tasks use up your plan's usage; AI answers can be wrong. A fifth note, cropped here, says the app is not made or endorsed by Anthropic or OpenAI.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One correction to that first note. It was true when I took the capture. Since version 0.3.0 the app also asks for a version number once a day, and since 0.3.3 it fetches banners for my other services in the office view. Neither request carries task content, but "sends nothing" is no longer an accurate sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review round one: the status check and the run disagree
&lt;/h2&gt;

&lt;p&gt;Removing the refusal was a small change. I asked for an adversarial review (September 30), a few agents whose only task is to find how the change fails, and it found this.&lt;/p&gt;

&lt;p&gt;Team mode starts Claude Code with &lt;code&gt;--restricted&lt;/code&gt;, which keeps settings files out of the run. That includes your own user settings file. But before a run, the app asks &lt;code&gt;claude auth status --json&lt;/code&gt; whether you are ready, and that command does read the user settings file. Some sign-ins live only there: an &lt;code&gt;apiKeyHelper&lt;/code&gt;, or the Bedrock and Google Cloud login wizards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Reads user settings file?&lt;/th&gt;
&lt;th&gt;What it sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Readiness check (&lt;code&gt;auth status&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;"signed in, ready"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team run (&lt;code&gt;--restricted&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no sign-in at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;run fails, or quietly uses another stored login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I reproduced it with a fake API key and a local test server standing in for the API. The run either failed after the screen said "ready" or went out under a different Claude account that happened to be stored on the machine. For those people the screen now showed a green light, followed by a failure or a silent account switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the second problem inside it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--settings&lt;/code&gt; still applies under &lt;code&gt;--restricted&lt;/code&gt;, so the app can hand the file back by path. I never read its contents. The app checks that it exists, is at most 2 MB and is readable, and Claude Code reads the rest. This is the condition from &lt;code&gt;providers/claude.mjs&lt;/code&gt; (one branch for third-party providers trimmed):&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signInFromSettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;platform&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;has&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;envValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;platform&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="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api_key_helper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keySource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apiKeyHelper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keySource&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oauth_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANTHROPIC_AUTH_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;My first version passed the file whenever a sign-in existed. A second review pass, one agent this time, found that the same file can contain &lt;code&gt;permissions.additionalDirectories&lt;/code&gt;. Handing it back would let a run reach folders outside the one you picked. The restriction I was relying on was gone. So the file is passed only when the sign-in actually comes from it.&lt;/p&gt;

&lt;p&gt;That condition has a trap. &lt;code&gt;auth status&lt;/code&gt; reports a key from the environment and a key from the settings file with the same label, &lt;code&gt;api_key&lt;/code&gt; and &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. The only way I found to tell them apart was to check whether the run's own environment holds the key, which is what &lt;code&gt;has(...)&lt;/code&gt; does above. A related lesson from the Codex side: its &lt;code&gt;-c&lt;/code&gt; override values are type-checked, so a value that looks right can still be rejected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other things the same review turned up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Team runs with Codex forced the provider to OpenAI. People using Azure or another provider had their tasks sent to OpenAI. I removed the forcing.&lt;/li&gt;
&lt;li&gt;Claude Code and Codex could each receive the other's API key through the environment. Each tool now gets only its own family of variables (&lt;code&gt;ENV_FAMILIES&lt;/code&gt; in the code).&lt;/li&gt;
&lt;li&gt;The label "Claude account" appeared for any login. It now appears only for a plan login.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What passed, and what that proves
&lt;/h2&gt;

&lt;p&gt;After the fixes: app tests 405, CLI tests 323, engine tests 401 plus one. I also ran mutation checks, which deliberately break the code and confirm a test fails. Real team runs through the actual CLIs finished in 6.1 seconds for Claude and 14.3 seconds for Codex.&lt;/p&gt;

&lt;p&gt;Those numbers say the code does what my specs say. They say nothing about whether real users find sign-in easy. I have not run a usability test with real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  A correction that belongs here
&lt;/h2&gt;

&lt;p&gt;On September 29 I reported that the app's macOS signature check had zero fatal findings. That was wrong. When I ran &lt;code&gt;syspolicy_check&lt;/code&gt; again, it listed three: two about the signature and one about missing notarization. Version 0.2.3 (September 30, 08:07) went out ad-hoc signed with 1,042 files sealed, and the count dropped from three to one. The remaining one is notarization, which I chose not to do. The next post covers that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A status check and a run must read the same inputs. If they differ, "ready" is a promise the run can't keep.&lt;/li&gt;
&lt;li&gt;Review the fix, not only the original change. Both regressions here came from the repairs, not from the code that was already there.&lt;/li&gt;
&lt;li&gt;Reproduce with fake credentials and a local server before you trust a login fix.&lt;/li&gt;
&lt;li&gt;Say what a test count measures. 405 passing tests is a statement about the specs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;If you wrap a CLI that has several ways to sign in, how do you keep your readiness check and your actual run reading the same configuration?&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>electron</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>LLM-as-Judge Position Bias: Swapping A/B Flipped 87 of 300 Verdicts</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:15:13 +0000</pubDate>
      <link>https://dev.to/ji_ai/llm-as-judge-position-bias-swapping-ab-flipped-87-of-300-verdicts-42hf</link>
      <guid>https://dev.to/ji_ai/llm-as-judge-position-bias-swapping-ab-flipped-87-of-300-verdicts-42hf</guid>
      <description>&lt;p&gt;In a head-to-head eval, my new system prompt beat the old one in 66% of comparisons. I already had the "we're shipping v2" message typed.&lt;/p&gt;

&lt;p&gt;Then I swapped which answer went first and reran the exact same 300 pairs. The new prompt won 43%.&lt;/p&gt;

&lt;p&gt;The judge, the answers, the rubric and temperature 0 were all the same. The only change was which answer sat in the slot labeled "A". That is &lt;strong&gt;LLM-as-judge position bias&lt;/strong&gt;, and if your eval script calls &lt;code&gt;judge(new, old)&lt;/code&gt; in a fixed order, your headline number is probably measuring it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM-as-judge position bias&lt;/strong&gt; means a pairwise judge prefers whichever answer sits in a particular slot (usually the first) regardless of quality.&lt;/li&gt;
&lt;li&gt;In my run, swapping A and B flipped &lt;strong&gt;87 of 300 verdicts (29%)&lt;/strong&gt;, and 78 of those flips went to whichever answer was in slot A.&lt;/li&gt;
&lt;li&gt;The bias shows up in close calls. When two answers are near-equal, slot position acts as the tiebreaker.&lt;/li&gt;
&lt;li&gt;Fix: judge every pair in &lt;strong&gt;both orders&lt;/strong&gt;. Count a win only when both runs agree, and treat disagreement as a tie.&lt;/li&gt;
&lt;li&gt;Before trusting a judge, run the &lt;strong&gt;mirror test&lt;/strong&gt;: put the identical answer in both slots. A fair judge has no reason to prefer either one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is LLM-as-judge position bias?
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge position bias is a judge model's tendency to pick an answer because of where it appears in the prompt, not because of what it says. It's one of the first biases documented for LLM judges (the MT-Bench / Chatbot Arena paper from Zheng et al. called it out back in 2023), and it's still easy to walk straight into.&lt;/p&gt;

&lt;p&gt;It hits pairwise evals hardest, the "here are Answer A and Answer B, which is better?" setup. That's the setup most of us use to compare prompts, models or RAG configs, because it's easier than defining an absolute 1-10 scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I caught it
&lt;/h2&gt;

&lt;p&gt;My eval script looked like every eval script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;old_answer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;wins&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;==&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new answer always went in slot A. I didn't pick that order for any reason. It was just the order of the function arguments.&lt;/p&gt;

&lt;p&gt;A coworker asked "did you swap them?" and I had no good answer. So I ran it again with the old answer in slot A. Here is what the 300 pairs did across both runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome across both orders&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New prompt won both times&lt;/td&gt;
&lt;td&gt;120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old prompt won both times&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slot A won both times (verdict flipped)&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slot B won both times (verdict flipped)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Count the slot picks: slot A won 369 of 600 judgments, or 61.5%. A judge with no position preference would land somewhere near 50%.&lt;/p&gt;

&lt;p&gt;The 66% headline was really 120 consistent wins plus 78 pairs where the new prompt just happened to sit in the favored seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does an LLM judge prefer the first answer?
&lt;/h2&gt;

&lt;p&gt;An LLM judge prefers the first answer mostly because of how it reads: one token at a time, left to right. Answer A gets evaluated with nothing to compare it to. By the time the model reaches Answer B, it has already formed a picture of what "good" looks like for this question, and that picture came from A.&lt;/p&gt;

&lt;p&gt;A few things stack on top of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Close calls get decided by the seat.&lt;/strong&gt; I read the 87 flipped pairs myself. In most of them I couldn't pick a winner either: both answers were correct, worded differently, and about the same length. With no real quality signal to go on, the position prior decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature 0 doesn't help.&lt;/strong&gt; It makes the bias reproducible, which is worse in a way, because rerunning the same order gives you the same wrong answer and feels like confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The direction isn't universal.&lt;/strong&gt; Some judge and prompt combinations favor slot B, especially with long answers where the most recent text is freshest. My judge favored A. Yours might favor B. Measure it before you assume a direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format priors.&lt;/strong&gt; "A" is the first option in countless multiple-choice examples the model has seen. Label tokens carry their own baggage before the model reads a word of the answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means the judge is useless. On the 213 pairs where it agreed with itself, its picks matched mine on almost every one I spot-checked. The judge is fine at clear differences. On near-ties it isn't measuring quality at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test an LLM judge for position bias?
&lt;/h2&gt;

&lt;p&gt;Run the mirror test: build pairs where Answer A and Answer B are the exact same text, then ask the judge to pick. A fair judge has no basis to prefer either, so it should say TIE or split close to 50/50.&lt;/p&gt;

&lt;p&gt;I ran 50 mirror pairs. My judge picked A 34 times, B 9 times and TIE 7 times. It was confidently picking a winner between two copies of the same text, and it usually picked the first one.&lt;/p&gt;

&lt;p&gt;This takes ten minutes and tells you more about your judge than a week of reading verdicts. I now run it every time I change the judge model or the judge prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix position bias in pairwise LLM evals?
&lt;/h2&gt;

&lt;p&gt;Judge every pair twice, once in each order, and count a win only when both runs agree. If the judge contradicts itself, call it a tie. That's the whole fix:&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;JUDGE_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are comparing two answers to the same question.

Question:
{question}

Answer A:
{a}

Answer B:
{b}

Briefly list the strengths and weaknesses of each answer.
Then end with exactly one line: VERDICT: A, VERDICT: B, or VERDICT: TIE&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;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&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;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JUDGE_PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&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="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse_verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# "A" | "B" | "TIE"
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;judge_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# new in slot A
&lt;/span&gt;    &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ask_judge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# new in slot B
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;==&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;==&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="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;==&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;==&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="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# Self-contradiction, or a TIE in either run: no signal.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then report three numbers instead of one:&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;judge_pair&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&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;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;new_wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;old_wins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tie&lt;/span&gt;&lt;span class="sh"&gt;"&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  old &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  tie &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ties&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&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;print&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;new win rate on decisive pairs: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_wins&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;old_wins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tie rate is the important number here. It tells you how much of your eval set the judge can't actually separate. If it's 30%, a 5-point swing in your headline doesn't mean much.&lt;/p&gt;

&lt;p&gt;Some other things that helped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Give the judge a TIE option.&lt;/strong&gt; Without it, you're forcing a coin flip on every near-equal pair, and the position prior is what decides the flip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for reasoning before the verdict.&lt;/strong&gt; Put the verdict line last so the model writes its comparison before it commits to a letter. It doesn't remove the bias, but it cut my flip rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use logprobs if your API exposes them.&lt;/strong&gt; Take P("A") in one order and P("B") in the swapped order, then average. You get a soft score that's symmetric by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Isn't randomizing the A/B order enough?
&lt;/h2&gt;

&lt;p&gt;Randomizing the order per pair is better than a fixed order, but it doesn't fix the problem. Random assignment spreads the position bias evenly across both systems, so your aggregate win rate stops being skewed in one direction. That's real progress.&lt;/p&gt;

&lt;p&gt;What it hides is the noise. Those 87 coin-flip pairs are still in your data, now split randomly between "new won" and "old won." Your headline drifts toward 50% and looks like an honest close race, when actually a third of your eval set says nothing at all. Rerun it with a different random seed and the number moves.&lt;/p&gt;

&lt;p&gt;Running both orders costs twice the judge calls. On 300 pairs, that's 300 extra API calls. Shipping a prompt that was never better costs more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which other LLM judge biases show up alongside position bias?
&lt;/h2&gt;

&lt;p&gt;Position bias rarely travels alone. Watch for these two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verbosity bias.&lt;/strong&gt; Judges tend to favor the longer answer, even when the extra length is padding. If your new prompt makes answers 40% longer, some of its "wins" are word count. Check the win rate split by length difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-preference.&lt;/strong&gt; A judge can favor outputs that sound like its own writing. If you're comparing Model X against Model Y and Model X is also the judge, be suspicious of a big X win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swapping the order fixes neither of these. They need their own checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was my new prompt actually better?
&lt;/h2&gt;

&lt;p&gt;Yes, a little. On the 213 pairs where the judge agreed with itself, the new prompt won 120 to 93, which is 56%. That's a real but modest improvement. It isn't the 66% blowout I nearly announced. I shipped it with a much quieter message and a note that 29% of the eval set was a toss-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does LLM-as-judge position bias distort pairwise evals?
&lt;/h2&gt;

&lt;p&gt;LLM-as-judge position bias makes a pairwise judge favor whichever answer occupies a particular slot, usually the first. It mostly decides near-tie comparisons, which can quietly inflate or deflate a win rate by double digits. In my eval, swapping A and B flipped 87 of 300 verdicts and turned a 66% win into 43%. The fix is to judge every pair in both orders, count only verdicts that agree, treat contradictions as ties, and report the tie rate next to the win rate. Run the mirror test, with the identical answer in both slots, before you trust any judge.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>python</category>
    </item>
    <item>
      <title>git push --force-with-lease Is Unsafe If Your Editor Auto-Fetches</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:13:30 +0000</pubDate>
      <link>https://dev.to/ji_ai/git-push-force-with-lease-is-unsafe-if-your-editor-auto-fetches-37ja</link>
      <guid>https://dev.to/ji_ai/git-push-force-with-lease-is-unsafe-if-your-editor-auto-fetches-37ja</guid>
      <description>&lt;p&gt;I have typed &lt;code&gt;git push --force-with-lease&lt;/code&gt; for years with the smug calm of someone wearing a seatbelt. Then one afternoon it overwrote two of my teammate's commits on our shared feature branch, and git didn't print a single warning.&lt;/p&gt;

&lt;p&gt;No typo, no stale alias, no plain &lt;code&gt;--force&lt;/code&gt;. The lease checked out fine. The problem was my editor, quietly running &lt;code&gt;git fetch&lt;/code&gt; in the background every few minutes, which turned my seatbelt into a decorative strap.&lt;/p&gt;

&lt;p&gt;This post explains exactly why &lt;code&gt;git push --force-with-lease&lt;/code&gt; is unsafe when something auto-fetches, how to reproduce it in two minutes, and the flag that actually closes the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git push --force-with-lease&lt;/code&gt; with no argument compares the remote branch to &lt;strong&gt;your remote-tracking ref&lt;/strong&gt; (&lt;code&gt;origin/feature&lt;/code&gt;), not to the commit you actually rebased on.&lt;/li&gt;
&lt;li&gt;Any background &lt;code&gt;git fetch&lt;/code&gt; (VS Code's &lt;code&gt;git.autofetch&lt;/code&gt;, GitHub Desktop, a cron job, a prompt script) updates &lt;code&gt;origin/feature&lt;/code&gt; to include your teammate's new commits. The lease now matches, and your push deletes their work.&lt;/li&gt;
&lt;li&gt;The fix is &lt;code&gt;--force-if-includes&lt;/code&gt; (Git 2.30+), which refuses the push if the remote tip was never part of your local branch's history.&lt;/li&gt;
&lt;li&gt;Set it once: &lt;code&gt;git config --global push.useForceIfIncludes true&lt;/code&gt;. It only kicks in alongside &lt;code&gt;--force-with-lease&lt;/code&gt;, so plain &lt;code&gt;--force&lt;/code&gt; stays dangerous.&lt;/li&gt;
&lt;li&gt;Lost commits are usually recoverable from &lt;code&gt;git reflog show origin/&amp;lt;branch&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does git push --force-with-lease actually check?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git push --force-with-lease&lt;/code&gt; checks one thing: that the branch on the remote still points to the same commit your local remote-tracking ref points to. If they match, the force push goes through. If they differ, git rejects it with "stale info".&lt;/p&gt;

&lt;p&gt;That's it. It doesn't know which commit you started your rebase from. It doesn't know what you've looked at. It reads &lt;code&gt;refs/remotes/origin/feature&lt;/code&gt; and assumes that's the last state you saw.&lt;/p&gt;

&lt;p&gt;That assumption holds when you are the only thing that runs &lt;code&gt;git fetch&lt;/code&gt;. It quietly breaks the moment anything else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a background fetch break the lease?
&lt;/h2&gt;

&lt;p&gt;A background fetch breaks the lease because it updates the exact ref the lease compares against, without touching your branch. Your view of the world goes stale, but git's record of "what you last saw" gets refreshed behind your back.&lt;/p&gt;

&lt;p&gt;Here's the timeline from my afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;14:02. I start an interactive rebase on &lt;code&gt;feature/billing&lt;/code&gt; to squash some fixup commits. My local branch and &lt;code&gt;origin/feature/billing&lt;/code&gt; both point at commit &lt;code&gt;A&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;14:09. My teammate pushes two commits, &lt;code&gt;B&lt;/code&gt; and &lt;code&gt;C&lt;/code&gt;, to the same branch.&lt;/li&gt;
&lt;li&gt;14:11. VS Code's autofetch runs. &lt;code&gt;origin/feature/billing&lt;/code&gt; now points at &lt;code&gt;C&lt;/code&gt;. My local branch is still my rewritten history on top of the old base.&lt;/li&gt;
&lt;li&gt;14:15. I finish the rebase and run &lt;code&gt;git push --force-with-lease&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Git compares: remote says &lt;code&gt;C&lt;/code&gt;, my tracking ref says &lt;code&gt;C&lt;/code&gt;. Lease matches. Push accepted. &lt;code&gt;B&lt;/code&gt; and &lt;code&gt;C&lt;/code&gt; are gone from the remote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lease worked perfectly. It protected me against a state I had technically "seen". I hadn't seen anything. My editor had.&lt;/p&gt;

&lt;p&gt;The git documentation actually warns about this in the &lt;code&gt;--force-with-lease&lt;/code&gt; section: if something updates your remote-tracking refs in the background, the default lease form gives you no protection. Almost nobody reads that paragraph, because the flag's name sounds like it already handled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reproduce the force-with-lease overwrite?
&lt;/h2&gt;

&lt;p&gt;You can reproduce it locally with a bare repo and two clones, no network needed. Paste this into a scratch directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init &lt;span class="nt"&gt;--bare&lt;/span&gt; origin.git
git clone origin.git me
git clone origin.git them

&lt;span class="c"&gt;# me: create a branch with one commit&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;me
&lt;span class="nb"&gt;echo &lt;/span&gt;a &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; f &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add f &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"base"&lt;/span&gt;
git push origin HEAD:main
git branch &lt;span class="nt"&gt;-u&lt;/span&gt; origin/main

&lt;span class="c"&gt;# them: pull it, add a fix, push&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../them
git pull origin main
&lt;span class="nb"&gt;echo &lt;/span&gt;fix &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; g &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git add g &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"teammate fix"&lt;/span&gt;
git push origin HEAD:main

&lt;span class="c"&gt;# me: rewrite my history, then a "background" fetch happens&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../me
git commit &lt;span class="nt"&gt;--amend&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"base (amended)"&lt;/span&gt;
git fetch            &lt;span class="c"&gt;# pretend this was your editor&lt;/span&gt;

git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; origin/main   &lt;span class="c"&gt;# "teammate fix" is gone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push succeeds. Now repeat the last section with the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; &lt;span class="nt"&gt;--force-if-includes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time git rejects the push, because the remote tip (&lt;code&gt;teammate fix&lt;/code&gt;) never appeared in the history of your local branch. The rejection reason says the remote ref was updated since checkout, which is precisely the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does --force-if-includes do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--force-if-includes&lt;/code&gt; makes git verify that the commit your remote-tracking ref points to is something your local branch has actually included at some point. It checks the reflog of your local branch: if the remote tip isn't reachable from any entry, git assumes you never integrated it and refuses the force push.&lt;/p&gt;

&lt;p&gt;That's the missing check. The lease asks "did the remote change since my last fetch?" The include check asks "did I ever incorporate what's on the remote?" A background fetch can fake the first answer. It can't fake the second, because it never touches your local branch.&lt;/p&gt;

&lt;p&gt;A few rules worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only works together with &lt;code&gt;--force-with-lease&lt;/code&gt;. With plain &lt;code&gt;--force&lt;/code&gt; it does nothing.&lt;/li&gt;
&lt;li&gt;It's ignored if you pass an explicit lease value, like &lt;code&gt;--force-with-lease=feature:abc123&lt;/code&gt;, since you've already told git exactly what to expect.&lt;/li&gt;
&lt;li&gt;It requires Git 2.30 or newer. Run &lt;code&gt;git --version&lt;/code&gt; before you rely on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I make --force-if-includes the default?
&lt;/h2&gt;

&lt;p&gt;Set one config option and keep typing what you already type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; push.useForceIfIncludes &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every &lt;code&gt;git push --force-with-lease&lt;/code&gt; also runs the include check. I also added an alias so my muscle memory never reaches for plain &lt;code&gt;--force&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--global&lt;/span&gt; alias.pushf &lt;span class="s2"&gt;"push --force-with-lease --force-if-includes"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The explicit flag in the alias is redundant with the config, but it means the alias still works on a machine where I forgot to set the config. Cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does --force-if-includes still let you overwrite work?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--force-if-includes&lt;/code&gt; still lets you overwrite work if your teammate's commits were in your local branch at any point, even briefly. Because the check looks at the reflog, "I pulled it once" counts as "I included it".&lt;/p&gt;

&lt;p&gt;The classic case: you &lt;code&gt;git pull --rebase&lt;/code&gt;, see your teammate's commits, then start an interactive rebase and drop them (on purpose or by fat-fingering &lt;code&gt;d&lt;/code&gt; instead of &lt;code&gt;p&lt;/code&gt;). The tip was in your reflog, so the check passes, and the push removes their work. Git is trusting that you made a decision. It can't tell the difference between a decision and an accident.&lt;/p&gt;

&lt;p&gt;If you want the strongest guarantee, use an explicit lease captured before you start rewriting:&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="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse origin/feature/billing&lt;span class="si"&gt;)&lt;/span&gt;
git rebase &lt;span class="nt"&gt;-i&lt;/span&gt; main
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;feature/billing:&lt;span class="nv"&gt;$BASE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the push only succeeds if the remote is exactly the commit you started from. Background fetches can't touch a shell variable. This is what I use for long-lived shared branches. For my own short branches, the config option is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I recover commits lost to a force push?
&lt;/h2&gt;

&lt;p&gt;You can usually recover them from the reflog of your remote-tracking ref, because the background fetch that caused the problem also recorded your teammate's commits locally. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog show origin/feature/billing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see an entry from the fetch pointing at the old remote tip (in my case, &lt;code&gt;C&lt;/code&gt;). Cherry-pick the lost commits back onto your branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git cherry-pick &amp;lt;sha-of-B&amp;gt; &amp;lt;sha-of-C&amp;gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; &lt;span class="nt"&gt;--force-if-includes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your reflog doesn't have them, your teammate's local clone almost certainly does. That's how we recovered: a slightly awkward "hey, can you push again?" message and five minutes of cleanup. Lucky. On a branch with three people and a CI bot, it would have been a much longer afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I stop auto-fetching instead?
&lt;/h2&gt;

&lt;p&gt;No. Auto-fetch is genuinely useful: it keeps your branch status accurate and shows incoming changes before you start a rebase. Turning it off trades a precise fix for a vague one, and you'd still get bitten by the next tool that fetches on your behalf.&lt;/p&gt;

&lt;p&gt;The better move is to stop trusting the default lease form. Fetching should never be what decides whether a destructive push is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is git push --force-with-lease safe?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git push --force-with-lease&lt;/code&gt; is only safe if nothing else runs &lt;code&gt;git fetch&lt;/code&gt; in your repo, because by default it compares the remote against your remote-tracking ref, and a background fetch silently updates that ref to include commits you never saw. With VS Code autofetch, GitHub Desktop, or any scheduled fetch, the lease will happily approve a push that deletes your teammate's work. Add &lt;code&gt;--force-if-includes&lt;/code&gt; (or set &lt;code&gt;git config --global push.useForceIfIncludes true&lt;/code&gt; on Git 2.30+) so git also checks that the remote tip was ever part of your local branch, and use an explicit &lt;code&gt;--force-with-lease=branch:sha&lt;/code&gt; when you need a hard guarantee on shared branches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>cli</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Python ThreadPoolExecutor Swallowed 212 Claude API Errors Silently</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:11:49 +0000</pubDate>
      <link>https://dev.to/ji_ai/python-threadpoolexecutor-swallowed-212-claude-api-errors-silently-e3j</link>
      <guid>https://dev.to/ji_ai/python-threadpoolexecutor-swallowed-212-claude-api-errors-silently-e3j</guid>
      <description>&lt;p&gt;My Claude labeling job finished in 14 minutes, printed &lt;code&gt;done&lt;/code&gt;, and exited with code 0. Every log line was green. Three days later a dashboard said I had 3,788 labeled documents. I had sent 4,000.&lt;/p&gt;

&lt;p&gt;Nothing crashed. Nothing warned. Python's &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; swallowed exceptions from 212 API calls and kept them in objects I never looked at. This is the autopsy, the two wrong fixes I tried first, and the 15 lines that ended it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ThreadPoolExecutor.submit()&lt;/code&gt; stores any exception inside the returned &lt;code&gt;Future&lt;/code&gt;. If you never call &lt;code&gt;.result()&lt;/code&gt;, the exception is never raised and never logged.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;with ThreadPoolExecutor()&lt;/code&gt; block waits for all tasks, then exits cleanly even if every task failed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;executor.map()&lt;/code&gt; is not the fix: it raises the first error and cancels every task that hasn't started yet. Mine killed 2,906 documents.&lt;/li&gt;
&lt;li&gt;The fix: keep a &lt;code&gt;{future: input}&lt;/code&gt; dict, loop with &lt;code&gt;as_completed()&lt;/code&gt;, call &lt;code&gt;.result()&lt;/code&gt; inside &lt;code&gt;try/except&lt;/code&gt;, retry failures at lower concurrency, and exit non-zero if anything is still missing.&lt;/li&gt;
&lt;li&gt;Always reconcile counts. &lt;code&gt;saved == sent&lt;/code&gt; would have caught this on night one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What did the broken code look like?
&lt;/h2&gt;

&lt;p&gt;It was the kind of code you write in five minutes and trust forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;label_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&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;role&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;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROMPT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&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;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&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;Eight threads, 4,000 short documents, one label each. The worker writes its own result to the database, so the main thread has nothing to collect. That's the trap. Because I didn't need the return value, I threw away the &lt;code&gt;Future&lt;/code&gt;, and the &lt;code&gt;Future&lt;/code&gt; was the only thing holding the error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does ThreadPoolExecutor swallow exceptions?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ThreadPoolExecutor&lt;/code&gt; swallows exceptions because a worker's exception is captured and stored on its &lt;code&gt;Future&lt;/code&gt; object, and it only re-raises when someone calls &lt;code&gt;future.result()&lt;/code&gt; or &lt;code&gt;future.exception()&lt;/code&gt;. No caller, no raise. There's no "exception was never retrieved" warning when the &lt;code&gt;Future&lt;/code&gt; is garbage collected.&lt;/p&gt;

&lt;p&gt;That last part surprised me most. &lt;code&gt;asyncio&lt;/code&gt; does warn you: an unawaited failed task prints &lt;code&gt;Task exception was never retrieved&lt;/code&gt; when it gets collected. &lt;code&gt;concurrent.futures&lt;/code&gt; has no such safety net. The exception sits on an object with zero references and quietly disappears.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;with&lt;/code&gt; block makes it feel safer than it is. Leaving the block calls &lt;code&gt;shutdown(wait=True)&lt;/code&gt;, which waits for every task to finish. It does not check how they finished. Waiting looks like supervision. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What were the 212 errors?
&lt;/h2&gt;

&lt;p&gt;I added error capture (more on how below), replayed the same 4,000 documents against a fresh table, and grouped what came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;429 rate limit&lt;/td&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;td&gt;Burst exceeded my tokens-per-minute limit, survived the SDK's 2 default retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;529 overloaded&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;Transient server overload, also out of retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyError: 'label'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Model returned &lt;code&gt;{"category": ...}&lt;/code&gt; instead of &lt;code&gt;{"label": ...}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;212&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.3% of the run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 429s were my fault. Eight threads start at the same moment, all hit the limit at the same moment, and the SDK's backoff retries them at roughly the same moment. Two retries weren't enough to spread them out. The Anthropic Python client defaults to &lt;code&gt;max_retries=2&lt;/code&gt;, which is reasonable for one call and thin for a burst of eight.&lt;/p&gt;

&lt;p&gt;The 12 &lt;code&gt;KeyError&lt;/code&gt;s were the scariest. Rate limits are noisy in the API console. A parser bug on a model's slightly different JSON shows up nowhere except your own missing rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is executor.map safer than submit?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;executor.map()&lt;/code&gt; is louder than &lt;code&gt;submit()&lt;/code&gt; but not safer: it re-raises the first exception when your loop reaches that item, and then cancels every task that hasn't started yet. You trade silent data loss for loud data loss.&lt;/p&gt;

&lt;p&gt;That was my first "fix":&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;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next run crashed at document 517 with a &lt;code&gt;RateLimitError&lt;/code&gt; traceback. Great, I thought, now I see errors. Then I counted rows. &lt;code&gt;map()&lt;/code&gt; submits everything upfront, yields results in input order, and when the iterator dies it cancels all pending futures in a &lt;code&gt;finally&lt;/code&gt; block. 2,906 documents were cancelled before they ever started. One rate limit took out 73% of the job.&lt;/p&gt;

&lt;p&gt;My second "fix" was wrapping &lt;code&gt;label_one&lt;/code&gt; in a giant &lt;code&gt;try/except&lt;/code&gt; that logged and returned. That works, technically, but it moves error handling into every worker function I'll ever write and gives the main thread no idea how many things failed. The job still exited 0. I'd just made the silence more verbose.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I catch exceptions from ThreadPoolExecutor properly?
&lt;/h2&gt;

&lt;p&gt;Map each future back to its input, iterate with &lt;code&gt;as_completed()&lt;/code&gt;, call &lt;code&gt;.result()&lt;/code&gt; inside &lt;code&gt;try/except&lt;/code&gt;, and collect failures in the main thread. Then retry the failures and exit with a non-zero code if any remain.&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_completed&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&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;fut&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fut&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="n"&gt;fut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label failed doc=%s err=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;

&lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&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;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retrying %d docs at low concurrency&lt;/span&gt;&lt;span class="sh"&gt;"&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;failed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_labeled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch_id&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;failed&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&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;docs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="si"&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;failed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed, saved &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&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;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&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;Four things are doing the work here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The dict.&lt;/strong&gt; &lt;code&gt;futures[fut]&lt;/code&gt; tells you &lt;em&gt;which&lt;/em&gt; document failed. A bare list of futures gives you a traceback with no ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;as_completed()&lt;/code&gt;.&lt;/strong&gt; Errors surface as they happen, and one failure doesn't cancel anything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retry pass at 2 workers.&lt;/strong&gt; Most 429s and 529s are about burst shape, not volume. Fewer threads on the second pass means fewer collisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The count check.&lt;/strong&gt; Even if my error handling has a bug, &lt;code&gt;saved != len(docs)&lt;/code&gt; catches it. This is the line I wish I'd written on day one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I also bumped the client to &lt;code&gt;Anthropic(max_retries=5)&lt;/code&gt; and replaced the free-text JSON with a forced tool schema so the model can't rename &lt;code&gt;label&lt;/code&gt; to &lt;code&gt;category&lt;/code&gt;. Those fixed the causes. The loop above fixes the blindness, which matters more, because next month's cause will be something I haven't thought of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened after the fix?
&lt;/h2&gt;

&lt;p&gt;The next full run of 4,000 documents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First pass, 8 workers:&lt;/strong&gt; 3,959 succeeded, 41 failed (all 429 or 529, zero &lt;code&gt;KeyError&lt;/code&gt;s after the schema change)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry pass, 2 workers:&lt;/strong&gt; 41 of 41 succeeded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count check:&lt;/strong&gt; 4,000 of 4,000 saved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wall time:&lt;/strong&gt; 14 minutes to 16 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two extra minutes for a job that tells the truth. The failure rate in the first pass dropped from 5.3% to 1%, mostly from the extra SDK retries. But the number I care about is the one that went from "unknown" to zero: rows lost without a log line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a batch job check before printing "done"?
&lt;/h2&gt;

&lt;p&gt;A batch job should verify that the number of outputs it wrote equals the number of inputs it was given, and exit non-zero if not. Exit code 0 is a claim. Make the code earn it.&lt;/p&gt;

&lt;p&gt;My checklist for any threaded API job now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never call &lt;code&gt;pool.submit()&lt;/code&gt; without keeping the returned &lt;code&gt;Future&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never use &lt;code&gt;pool.map()&lt;/code&gt; on calls that can fail transiently.&lt;/li&gt;
&lt;li&gt;Log the input ID with every exception, not just the traceback.&lt;/li&gt;
&lt;li&gt;Retry failures in a separate pass with lower concurrency.&lt;/li&gt;
&lt;li&gt;Reconcile counts against the source of truth, not against your own success counter.&lt;/li&gt;
&lt;li&gt;Make cron or CI treat a non-zero exit as an alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original bug ran for nine nights before I noticed. Nobody ignored an alert. There was no alert to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why did ThreadPoolExecutor swallow my Claude API errors?
&lt;/h2&gt;

&lt;p&gt;Python's &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; swallowed 212 of my Claude API errors because &lt;code&gt;submit()&lt;/code&gt; stores each worker's exception on the returned &lt;code&gt;Future&lt;/code&gt;, and I never kept or checked those futures. Without a call to &lt;code&gt;future.result()&lt;/code&gt;, the exception is never raised and never logged, and the &lt;code&gt;with&lt;/code&gt; block exits cleanly once all threads finish, so the script printed &lt;code&gt;done&lt;/code&gt; with exit code 0. Switching to &lt;code&gt;executor.map()&lt;/code&gt; made errors visible but cancelled every unstarted task after the first failure. The reliable pattern is a &lt;code&gt;{future: input}&lt;/code&gt; dict, an &lt;code&gt;as_completed()&lt;/code&gt; loop with &lt;code&gt;.result()&lt;/code&gt; inside &lt;code&gt;try/except&lt;/code&gt;, a low-concurrency retry pass, and a final &lt;code&gt;saved == sent&lt;/code&gt; count check that fails the job loudly when the numbers don't match.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>AWS ALB Idle Timeout Killed 14% of My Voice WebSockets at 60s</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:10:05 +0000</pubDate>
      <link>https://dev.to/ji_ai/aws-alb-idle-timeout-killed-14-of-my-voice-websockets-at-60s-27ec</link>
      <guid>https://dev.to/ji_ai/aws-alb-idle-timeout-killed-14-of-my-voice-websockets-at-60s-27ec</guid>
      <description>&lt;p&gt;A user emailed me a screen recording. The AI voice agent asks a hard question. The user goes quiet for about a minute to think. Then they start talking, and nothing happens. A second later the app reconnects on its own and the agent cheerfully asks the &lt;strong&gt;first&lt;/strong&gt; question again.&lt;/p&gt;

&lt;p&gt;That loop came from the AWS ALB idle timeout on WebSocket connections. No exceptions, no 5xx in the dashboards, just a load balancer doing exactly what it was configured to do. It dropped 58 of my 412 voice sessions in one week. That's 14%.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS Application Load Balancer closes any connection with no data in either direction for 60 seconds.&lt;/strong&gt; That default applies to WebSockets too.&lt;/li&gt;
&lt;li&gt;Voice apps that only stream mic audio while the user is speaking (VAD-gated) go fully silent during long pauses, and the ALB cuts them.&lt;/li&gt;
&lt;li&gt;The browser sees close code &lt;strong&gt;1006&lt;/strong&gt; with no close frame, which looks like a random network error.&lt;/li&gt;
&lt;li&gt;Fix: send a WebSocket ping from the server every 25 seconds, raise the ALB &lt;code&gt;idle_timeout&lt;/code&gt;, and make reconnects resume the session instead of restarting it.&lt;/li&gt;
&lt;li&gt;After the fix: 2 drops in 436 sessions, both real network loss.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does the ALB idle timeout actually do to a WebSocket?
&lt;/h2&gt;

&lt;p&gt;The ALB idle timeout is a timer on the connection that resets whenever a byte moves in either direction. If 60 seconds pass with zero traffic, the ALB closes both sides. It doesn't care that the HTTP request upgraded to a WebSocket. A quiet socket is a quiet socket.&lt;/p&gt;

&lt;p&gt;Most WebSocket apps never hit this because something is always chatting: a chat app's typing indicators, a dashboard's price ticks. My app had long, legitimate silences. That was the whole product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that made it fail
&lt;/h2&gt;

&lt;p&gt;The system is a voice interview simulator. Browser mic goes over a WebSocket to a Node server running the &lt;code&gt;ws&lt;/code&gt; library behind an ALB. The server transcribes, calls the LLM, and streams TTS audio back.&lt;/p&gt;

&lt;p&gt;To save bandwidth and transcription cost, the client runs voice activity detection locally and only sends audio frames while the user is speaking. Silence sends nothing.&lt;/p&gt;

&lt;p&gt;So the traffic looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agent asks a question. Server streams TTS audio. Bytes flowing.&lt;/li&gt;
&lt;li&gt;User answers. Client streams mic frames. Bytes flowing.&lt;/li&gt;
&lt;li&gt;User pauses to think. Client sends nothing. Server is waiting. &lt;strong&gt;Zero bytes.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is where the timer runs. On easy questions people pause for 5 seconds. On a hard system design question, they think for 70.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did only some sessions drop?
&lt;/h2&gt;

&lt;p&gt;Only sessions with a silent gap longer than 60 seconds dropped, and one interviewer style accidentally protected itself. That's the detail that took me longest to spot.&lt;/p&gt;

&lt;p&gt;The product has 3 interviewer styles. The "friendly" style sends a gentle "take your time" nudge after 45 seconds of silence. That nudge is TTS audio, which is traffic, which resets the ALB timer. The "strict" style sits in silence on purpose, because that's what a strict interviewer does.&lt;/p&gt;

&lt;p&gt;From one week of logs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total sessions&lt;/td&gt;
&lt;td&gt;412&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions with a silent gap over 60s&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Of those, sessions using the friendly style&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropped sessions&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropped sessions with a silent gap over 60s&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 58 drops happened during a gap over 60 seconds. All 13 sessions that survived a long gap were friendly-style ones where the 45-second nudge kept the pipe warm. I had shipped a load balancer workaround as a personality trait and didn't know it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it: subtract two timestamps
&lt;/h2&gt;

&lt;p&gt;I was logging the close event, but I wasn't logging &lt;em&gt;when the last frame moved&lt;/em&gt;. Once I added &lt;code&gt;lastFrameAt&lt;/code&gt; (updated on every message in either direction) the pattern fell out of one query:&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="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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;code&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;idleMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastFrameAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idleMs&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ws closed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every abnormal close had &lt;code&gt;code: 1006&lt;/code&gt; and &lt;code&gt;idleMs&lt;/code&gt; between 60,000 and 61,400. Not "around a minute". A cliff at exactly 60 seconds with about a second of jitter. When your failure distribution has a cliff at a round number, look for a config default before you look for a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the browser report WebSocket close code 1006?
&lt;/h2&gt;

&lt;p&gt;Code 1006 means the connection closed without a close frame. The ALB drops the TCP connection rather than performing a WebSocket close handshake, so neither the browser nor the &lt;code&gt;ws&lt;/code&gt; server ever receives a proper close frame. Both sides report 1006, the same code you get when Wi-Fi dies.&lt;/p&gt;

&lt;p&gt;That's why this hid in my error tracking for weeks. It was bucketed with "flaky user networks", a category everyone learns to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: reconnecting made it worse
&lt;/h2&gt;

&lt;p&gt;My client had auto-reconnect with backoff, which felt responsible. But the server treated every new socket as a new session. So the user experience was: you think hard, you start answering, and the agent restarts from question 1 like nothing happened.&lt;/p&gt;

&lt;p&gt;That's worse than a visible error. A visible error at least tells you something broke.&lt;/p&gt;

&lt;p&gt;Full disclosure: I built this. It's &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, a voice interview practice app with 3 interviewer styles that scores a resume or portfolio and returns a written report. Long thinking pauses are normal there, which is exactly why this timeout bit so hard.&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%2Ftd5zed80xfjpv7qjalsn.jpg" 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%2Ftd5zed80xfjpv7qjalsn.jpg" alt="Preterview — an interview session in progress" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep a WebSocket alive behind an AWS ALB?
&lt;/h2&gt;

&lt;p&gt;Send traffic more often than the idle timeout, from the server, using WebSocket ping frames. The browser answers pings with pongs automatically, both frames count as traffic for the ALB, and you get dead-connection detection for free.&lt;/p&gt;

&lt;p&gt;One trap: the browser &lt;code&gt;WebSocket&lt;/code&gt; API cannot send protocol-level ping frames. If you want a client-side heartbeat, you have to send an application message. Server-side ping is simpler and covers every client.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ws&lt;/code&gt; library doesn't ping by default. Here's the heartbeat I added:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connection&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;ws&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="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pong&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="nf"&gt;setInterval&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="k"&gt;for &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;ws&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAlive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ping&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="nx"&gt;HEARTBEAT_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;25 seconds gives two chances to land a ping inside one 60-second window, so a single delayed frame doesn't kill the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you also raise the ALB idle timeout?
&lt;/h2&gt;

&lt;p&gt;Yes, but as a safety margin, not as the fix. The ALB idle timeout can go up to 4,000 seconds. I set it to 300:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"voice"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"voice-ws"&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;idle_timeout&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relying on the timeout alone is fragile. There may be other hops in the path (a corporate proxy, a CDN, a mobile carrier NAT) with their own idle timers you don't control. The heartbeat survives all of them. The bigger timeout just means one missed ping isn't fatal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making reconnects resume instead of restart
&lt;/h2&gt;

&lt;p&gt;The third change: the server now issues a resume token on connect. The client stores it in memory and sends it on reconnect. If the session is still in the server's state store, the client rejoins at the same question with the transcript intact. Tokens expire after 5 minutes.&lt;/p&gt;

&lt;p&gt;Now a real network blip costs the user a second of audio, not their whole session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results after one week
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;412&lt;/td&gt;
&lt;td&gt;436&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropped sessions&lt;/td&gt;
&lt;td&gt;58 (14.1%)&lt;/td&gt;
&lt;td&gt;2 (0.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drops with &lt;code&gt;idleMs&lt;/code&gt; near 60,000&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drops from real network loss&lt;/td&gt;
&lt;td&gt;0 logged&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The heartbeat cost is a ping and a pong every 25 seconds, a few bytes each. Against a stream of audio frames, it doesn't show up in any bill I can read.&lt;/p&gt;

&lt;p&gt;Caveat: two weeks of traffic at this volume is a small sample. The 0 after-fix timeout drops is convincing because the before-fix failures were so mechanical, not because 436 is a big number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check in any voice or long-lived WebSocket app
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Log &lt;code&gt;lastFrameAt&lt;/code&gt; for every socket and compute idle time at close. This one number would have found the bug on day one.&lt;/li&gt;
&lt;li&gt;Treat close code 1006 as "investigate", not "user's Wi-Fi".&lt;/li&gt;
&lt;li&gt;Never let "silence" mean "no bytes". VAD-gated audio is great for cost, but pair it with a heartbeat.&lt;/li&gt;
&lt;li&gt;Make reconnect resume state. Auto-reconnect into a fresh session hides failures from you and makes them worse for users.&lt;/li&gt;
&lt;li&gt;Test with a deliberate 90-second pause. My QA runs never paused that long, so they never hit the timer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So why does the AWS ALB idle timeout kill WebSocket connections?
&lt;/h2&gt;

&lt;p&gt;The AWS ALB idle timeout closes any connection, including an upgraded WebSocket, after 60 seconds with no data in either direction. Apps that go genuinely quiet, like VAD-gated voice streams during a user's pause, cross that line and get dropped with close code 1006 and no close frame. The fix is a server-side WebSocket ping every 20 to 30 seconds, an &lt;code&gt;idle_timeout&lt;/code&gt; raised as a margin, and reconnect logic that resumes the session. For me that took drops from 58 of 412 sessions to 2 of 436.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Measuring What My Electron App Sends: 94 Sockets, 0 Out</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 30 Oct 2026 18:46:27 +0000</pubDate>
      <link>https://dev.to/ji_ai/measuring-what-my-electron-app-sends-94-sockets-0-out-5cek</link>
      <guid>https://dev.to/ji_ai/measuring-what-my-electron-app-sends-94-sockets-0-out-5cek</guid>
      <description>&lt;p&gt;On September 29, my Electron app opened 94 sockets across four runs, and every one of them pointed at 127.0.0.1. The same runs also caused 20 connections to the outside world from a single one-line task. Both numbers are true.&lt;/p&gt;

&lt;p&gt;This is part 10 of a series about Refmade, a desktop app I'm building alone. It lets people who don't use a terminal hand work to the Claude Code or Codex they already have installed, and shows the agents as an office you can watch. It is an independent app, not affiliated with Anthropic or OpenAI. Claude Code and Codex agents wrote most of the code at my direction; I planned, decided and reviewed. The UI is Korean right now; that changes later in this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sentence I refused to write
&lt;/h2&gt;

&lt;p&gt;From the first product plan on September 21, I had a rule for the copy: never say "your data never leaves your PC." Refmade starts a CLI on your machine, and that CLI sends your prompt and the file contents it needs to a model provider. A claim that sounds like "nothing leaves" would be false on the first task.&lt;/p&gt;

&lt;p&gt;What I could say was narrower. The app itself should send nothing, and the gap between that and the CLI's traffic is what I measured. Whatever the CLI sends is the CLI's business, and the app should say so. That is a claim you can measure, so on September 29 I measured it, against the 0.2.x Mac build.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured
&lt;/h2&gt;

&lt;p&gt;Four runs: two plain smoke launches, one Claude task, one Codex task. During each run I took a snapshot of the socket table every 0.25 seconds, ran &lt;code&gt;nettop&lt;/code&gt; next to it, and collected the DNS records. A snapshot only sees sockets that are open at that instant, so a short connection can fall between two samples. The short interval, &lt;code&gt;nettop&lt;/code&gt; and the DNS records are three views of the same run, and each covers a gap in the others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I looked at&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;Sockets opened by the app's own processes&lt;/td&gt;
&lt;td&gt;94, all on 127.0.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS lookups from the app's own processes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connections during a one-line Claude task&lt;/td&gt;
&lt;td&gt;20, all from the CLI the app started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packet contents&lt;/td&gt;
&lt;td&gt;not inspected (needs admin rights)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login flow, long-running tasks&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I take the 94 local sockets to be the app's own pieces talking to each other, such as the window and its office server. I didn't trace each one. The 20 outside connections come from Claude Code. My own Claude setup had MCP servers enabled, and Claude Code also sends usage statistics. A person without those servers would probably see fewer connections, but I didn't measure that.&lt;/p&gt;

&lt;p&gt;I'd skip "0 external sends" as a headline. The accurate version is "0 from the app's processes, 20 from the tool it launched, content not inspected."&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard in the window
&lt;/h2&gt;

&lt;p&gt;The renderer has its own rule. Every request a page in the app's window makes goes through one function, and only four kinds get through: the app's own files, DevTools, inline data, and the office server on &lt;code&gt;127.0.0.1&lt;/code&gt; at its one port.&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="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;APP_SCHEME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isAppUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&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="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;devtools:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chrome-devtools:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;devtools&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blob:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isOfficeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;officePort&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="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;office&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;network&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;network&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Content-Security-Policy with &lt;code&gt;connect-src 'self'&lt;/code&gt; sits behind it. A page script that tries to reach another host is cancelled by the filter and refused again by the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first screen says
&lt;/h2&gt;

&lt;p&gt;I wrote the measurement into the first screen of the app, in plain Korean.&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%2Fk5srazmib0w58p51v691.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%2Fk5srazmib0w58p51v691.png" alt="First-screen setup card with privacy notes" width="800" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The first screen as I prepared 0.2.3, captured at 01:20 on Sept 30 in preview mode (Korean UI, nothing from real work). Heading: "Let's get an AI ready first". Two cards: Claude Code "Ready", Codex "Login needed". The first note under them reads: "The Refmade app itself sends nothing over the internet. When you hand it work, the Claude Code or Codex on this computer sends the request and the file contents it needs, under your account, to Anthropic, OpenAI, or wherever you connected the AI. Even when checking that the AI is ready, Claude Code sometimes connects to Anthropic." Below the crop, two more notes say that logins are managed by each AI and that usage counts against your plan.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A reader who sees only "the app sends nothing" might assume a one-line task is silent. It isn't: the capture showed 20 connections from the CLI, which is why the note names the CLI's own traffic in its second and third sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the 0 stopped being true
&lt;/h2&gt;

&lt;p&gt;The first note in that screenshot stopped being accurate less than 24 hours after I captured it. I built 0.3.0 the next night, and it went live at 00:35 on October 1, with a new-version notice. About ten seconds after launch, and once a day after that, the app makes a GET request for a small version file on my own site. A menu switch turns it off.&lt;/p&gt;

&lt;p&gt;On October 8, the Mac build 0.3.3 added billboards in the office view. The app fetches banners for my other services from my site about every 60 seconds, up to six at a time, each image capped at 350 KB. There is no switch to turn this off. The request carries no work data or identifiers, and I don't count impressions or clicks. A request to a web server still shows your IP address to that server, and I haven't checked how long my hosting provider keeps request logs.&lt;/p&gt;

&lt;p&gt;Here is the whole history in one table.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Requests the app makes to Refmade's site&lt;/th&gt;
&lt;th&gt;Basis&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.2.x, Sept 29&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;measured, 4 runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3.0, Oct 1 (built Sept 30)&lt;/td&gt;
&lt;td&gt;version file, 10 s after launch then daily&lt;/td&gt;
&lt;td&gt;code reading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3.3, Oct 8 (Mac)&lt;/td&gt;
&lt;td&gt;the same, plus the billboard feed every 60 s&lt;/td&gt;
&lt;td&gt;code reading&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both calls run in the main process through Node's &lt;code&gt;https&lt;/code&gt; module, which the window's request filter above never sees. A comment at the top of that filter said "the app sends nothing to any server." That stopped being true when 0.3.0 went out, which is a good reminder that comments don't get tested.&lt;/p&gt;

&lt;p&gt;The first note on the screen changed. This is the current Korean text, translated (the original names the site's domain, which I left out here): "The Refmade app fetches the new version number and the office ad banners from Refmade's site. These requests don't send your work or files. You can turn off update notices from the menu."&lt;/p&gt;

&lt;p&gt;I haven't repeated the socket capture on 0.3.x. What I say about those two requests comes from reading the code, not from a new measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;p&gt;A measurement belongs to a build number and a date. "0" was the right answer for one version and the wrong one for the next, and nothing in the app failed when that happened. The only thing that went stale was the sentence next to the number.&lt;/p&gt;

&lt;p&gt;If you ship a desktop app that talks to your own server, the part to automate is the comparison between what the screen promises and what the code requests.&lt;/p&gt;

&lt;p&gt;How do you verify what an Electron app sends: a proxy and packet capture, OS tools like &lt;code&gt;nettop&lt;/code&gt;, or reading the code? And does anything fail in your CI when the privacy copy and the network calls drift apart?&lt;/p&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>networking</category>
      <category>electron</category>
      <category>macos</category>
    </item>
    <item>
      <title>vLLM Speculative Decoding: 1.8x Faster Alone, Slower at 32 Users</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:08:39 +0000</pubDate>
      <link>https://dev.to/ji_ai/vllm-speculative-decoding-18x-faster-alone-slower-at-32-users-5f16</link>
      <guid>https://dev.to/ji_ai/vllm-speculative-decoding-18x-faster-alone-slower-at-32-users-5f16</guid>
      <description>&lt;p&gt;I turned on vLLM speculative decoding on a Friday afternoon, ran my usual one-request benchmark, and watched tokens per second jump from about 48 to about 88. I wrote "1.8x free speedup" in the team channel. On Monday, with real traffic hitting the box at around 32 concurrent requests, aggregate throughput was lower than before I touched anything, and p50 inter-token latency had gone up.&lt;/p&gt;

&lt;p&gt;Nothing was broken. Speculative decoding did exactly what it is designed to do. I just measured it in the one regime where it always looks good.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speculative decoding&lt;/strong&gt; has a small draft model guess the next k tokens, then the big model checks all k in one forward pass and keeps the prefix it agrees with.&lt;/li&gt;
&lt;li&gt;It is fast at low concurrency because decoding is &lt;strong&gt;memory-bandwidth-bound&lt;/strong&gt;: checking 5 tokens costs roughly the same as generating 1.&lt;/li&gt;
&lt;li&gt;The whole win depends on the &lt;strong&gt;acceptance rate&lt;/strong&gt;. Below roughly 0.5 per token (k=4 with a 1B draft on an 8B target), the draft work eats the gain.&lt;/li&gt;
&lt;li&gt;Under load the GPU drifts toward &lt;strong&gt;compute-bound&lt;/strong&gt;, so rejected draft tokens become real wasted FLOPs, and the draft model's weights and KV cache take memory from other requests.&lt;/li&gt;
&lt;li&gt;Measure acceptance on production traffic, use n-gram (prompt lookup) drafting for copy-heavy workloads, lower k, and keep speculation off for high-concurrency deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually happened on my server?
&lt;/h2&gt;

&lt;p&gt;The setup: Llama 3.1 8B Instruct as the target, Llama 3.2 1B Instruct as the draft (same tokenizer, which is required), one 48GB GPU, &lt;code&gt;num_speculative_tokens&lt;/code&gt; set to 4.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--speculative-config&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "meta-llama/Llama-3.2-1B-Instruct", "num_speculative_tokens": 4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two workloads share this box: a JSON extraction service (temperature 0, lots of text copied from the input) and an internal chat assistant (temperature 0.8). My Friday benchmark was a single extraction prompt. Monday was both workloads mixed at around 32 concurrent sequences.&lt;/p&gt;

&lt;p&gt;Roughly, on my hardware:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Without spec decoding&lt;/th&gt;
&lt;th&gt;With spec decoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 request, extraction&lt;/td&gt;
&lt;td&gt;~48 tok/s&lt;/td&gt;
&lt;td&gt;~88 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 request, chat (temp 0.8)&lt;/td&gt;
&lt;td&gt;~48 tok/s&lt;/td&gt;
&lt;td&gt;~47 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~32 concurrent, mixed&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;~15% lower aggregate throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row was the first clue. Even with a single user, chat gained nothing. To see why, you need the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does speculative decoding actually work?
&lt;/h2&gt;

&lt;p&gt;Normal decoding runs the big model once per token. Speculative decoding splits each step in two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;draft&lt;/strong&gt; model (small and cheap) generates k tokens autoregressively: t1, t2, t3, t4.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;target&lt;/strong&gt; model runs one forward pass over all k positions at once and computes its own distribution at each.&lt;/li&gt;
&lt;li&gt;Walk left to right. Accept t1 if the target agrees, then t2, and so on. At the first rejection, throw away that token and everything after it, and take the target's own token at that position instead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So every step yields at least 1 token (the target's correction) and at most k+1 (all drafts accepted, plus one bonus token from the last position).&lt;/p&gt;

&lt;p&gt;At temperature 0, "agrees" means the target's argmax matches the draft token. At temperature above 0, vLLM uses rejection sampling: accept with probability min(1, p_target / p_draft), and on rejection resample from the leftover distribution. This keeps the output distribution identical to the target model alone. Speculative decoding changes speed, never quality. That guarantee is what makes it easy to turn on without thinking, which is how I ended up here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is speculative decoding faster at batch size 1?
&lt;/h2&gt;

&lt;p&gt;Because generating one token for one user barely uses the GPU's math units. Every decode step has to stream all of the model's weights from memory. An 8B model in fp16 is about 16GB. On a GPU with around 860 GB/s of memory bandwidth, that is about 19ms per step as a floor, no matter how little math you do.&lt;/p&gt;

&lt;p&gt;Verifying 5 positions instead of 1 reuses the same weight read. The extra matrix math is tiny next to the memory traffic, so the verification pass costs close to the same 19ms. The draft model reads its own 2.5GB of weights per token, about 3ms each.&lt;/p&gt;

&lt;p&gt;That gives a simple cost model for one step with k=4:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4 draft forwards: ~12ms&lt;/li&gt;
&lt;li&gt;1 target verification: ~19ms&lt;/li&gt;
&lt;li&gt;Total: ~31ms, versus 19ms for one plain token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You pay 31ms per step. You break even only if you get more than 31/19 ≈ 1.6 tokens per step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the acceptance rate, and why does it decide everything?
&lt;/h2&gt;

&lt;p&gt;Acceptance rate (α) is the probability the target accepts any given draft token. If you treat each position as independent, the expected tokens per step is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E[tokens per step] = (1 - α^(k+1)) / (1 - α)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plug in k=4 and the timings above:&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;def&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&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;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t_target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;19.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;step_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t_target&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t_draft&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;tokens_per_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t_target&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;step_ms&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.9&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tokens_per_step&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;speedup&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;α&lt;/th&gt;
&lt;th&gt;tokens per step&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;1.42&lt;/td&gt;
&lt;td&gt;0.87x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;1.94&lt;/td&gt;
&lt;td&gt;1.19x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;td&gt;2.77&lt;/td&gt;
&lt;td&gt;1.70x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;4.10&lt;/td&gt;
&lt;td&gt;2.51x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Below about 0.45, you lose. My extraction traffic sat near 0.7: the output is mostly field values copied from the input, and a 1B model guesses those easily. My chat traffic at temperature 0.8 sat near 0.4. Higher temperature flattens both distributions, the draft and target disagree more often, and the sampled acceptance drops. That explains row two of my table without any load at all.&lt;/p&gt;

&lt;p&gt;One trap in reading vLLM's own numbers: the &lt;code&gt;/metrics&lt;/code&gt; endpoint exposes &lt;code&gt;spec_decode_*&lt;/code&gt; counters (exact names shift between versions, so grep for &lt;code&gt;spec_decode&lt;/code&gt;). The ratio accepted tokens / draft tokens is &lt;strong&gt;not&lt;/strong&gt; α. With k=4 and α=0.7, you accept about 1.77 of 4 drafts on average, so that ratio reads 0.44 and looks terrible even though the speedup is 1.7x. Compute mean accepted length instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:8000/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;spec_decode
&lt;span class="c"&gt;# mean accepted per step = num_accepted_tokens / num_drafts&lt;/span&gt;
&lt;span class="c"&gt;# tokens per step        = that + 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare tokens per step against your own break-even (step cost divided by plain token cost).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does vLLM speculative decoding get slower under load?
&lt;/h2&gt;

&lt;p&gt;Under load, the memory-bound assumption stops holding, and three costs show up at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Verification is no longer free.&lt;/strong&gt; With 32 sequences and k=4, the target's verification pass processes 32 × 5 = 160 token positions per forward instead of 32. In fp16, each token does about 2 FLOPs per parameter while the weights cost 2 bytes each, so arithmetic intensity is roughly 1 FLOP per byte per token in the batch. A modern GPU's ridge point (peak FLOPs divided by bandwidth) sits in the hundreds. Real kernels hit the compute wall well before the theoretical ridge, so 160 positions per pass is already in the zone where extra positions cost real time. Every rejected draft token is now math you paid for and threw away, and that math was taken from other users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The draft model scales too.&lt;/strong&gt; Four sequential draft forwards per step don't disappear at batch 32. They're small, but they're four extra kernel rounds per step in the critical path for every sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Memory goes to the draft.&lt;/strong&gt; The draft's weights and its own KV cache come out of the same GPU memory pool. Fewer KV cache blocks means fewer sequences fit at once, so under real traffic more requests wait in the queue. Throughput can drop even before any compute math enters the picture.&lt;/p&gt;

&lt;p&gt;Without speculation, batching already solved the memory-bandwidth problem. Thirty-two users share one weight read per step. Speculative decoding attacks the same inefficiency, and once batching has eaten it, you're left with only the overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use speculative decoding in vLLM?
&lt;/h2&gt;

&lt;p&gt;Use it when concurrency is low and acceptance is high. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure α on production traffic, not on your favorite prompt.&lt;/strong&gt; My benchmark prompt was the best case I could have chosen by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try n-gram drafting first for copy-heavy work.&lt;/strong&gt; Extraction, code edits, and RAG answers that quote the context repeat input spans. Prompt lookup proposes tokens by matching n-grams from the prompt, so it needs no draft model, no extra weights, and no extra KV cache:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--speculative-config&lt;/span&gt; &lt;span class="s1"&gt;'{"method": "ngram", "num_speculative_tokens": 4, "prompt_lookup_max": 4}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lower k when α is mediocre.&lt;/strong&gt; Each additional draft position is worth α^i, which decays fast. At α=0.5, the 4th draft token adds about 0.06 expected tokens for a full draft forward of cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split deployments by traffic shape.&lt;/strong&gt; I moved extraction to a small low-concurrency instance with n-gram speculation on, and kept the chat pool on plain decoding with a bigger batch. Some older vLLM versions had a setting to disable speculation above a batch size threshold; check what your version supports before relying on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at trained draft heads&lt;/strong&gt; like EAGLE or Medusa if you need speculation on chat-style traffic. They predict from the target's own hidden states and usually agree with it more often than a separate small model. Measure them the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So, does vLLM speculative decoding make inference faster?
&lt;/h2&gt;

&lt;p&gt;vLLM speculative decoding makes inference faster only when the GPU is memory-bound and the acceptance rate is high: low concurrency, low temperature, and outputs that repeat or closely follow the input. In that regime, verifying k draft tokens costs about the same as one normal step, so a per-token acceptance of 0.7 with k=4 gives roughly 1.7x. Under load, batching has already used up the idle memory bandwidth, verification turns into real compute, rejected drafts waste it, and the draft model takes KV cache memory from other requests, so aggregate throughput can drop. Measure mean accepted tokens per step from &lt;code&gt;/metrics&lt;/code&gt; on real traffic, compare it to your break-even, and turn speculation on only where the numbers clear it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Claude Code Deny Rules Block Read(.env) But Not cat .env</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:06:44 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-code-deny-rules-block-readenv-but-not-cat-env-1of4</link>
      <guid>https://dev.to/ji_ai/claude-code-deny-rules-block-readenv-but-not-cat-env-1of4</guid>
      <description>&lt;p&gt;I watched my production database password scroll past in a Claude Code transcript. I had a deny rule for &lt;code&gt;.env&lt;/code&gt;. I had copied it straight from the docs. Claude never touched the Read tool. It ran &lt;code&gt;grep -r DATABASE_URL .&lt;/code&gt; because I asked it why the app couldn't connect, and grep did exactly what grep does.&lt;/p&gt;

&lt;p&gt;Claude Code deny rules are real and they work. They just guard a narrower door than most people think. This post covers what &lt;code&gt;Read(./.env)&lt;/code&gt; actually blocks, the 7 commands I tested against it, and the setup that closed the gap on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code deny rules like &lt;code&gt;Read(./.env)&lt;/code&gt; apply to Claude's &lt;strong&gt;built-in file tools&lt;/strong&gt;. They are not a filesystem lock.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Bash tool&lt;/strong&gt; is matched by its own rules. &lt;code&gt;cat .env&lt;/code&gt; or &lt;code&gt;grep -r&lt;/code&gt; gets through if your Bash allow list lets those commands run.&lt;/li&gt;
&lt;li&gt;Bash deny patterns like &lt;code&gt;Bash(cat .env)&lt;/code&gt; are plain prefix matches, so &lt;code&gt;cat ./.env&lt;/code&gt; or &lt;code&gt;head .env&lt;/code&gt; gets past them.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;PreToolUse&lt;/code&gt; hook that exits with code &lt;strong&gt;2&lt;/strong&gt; blocks the obvious Bash routes. The sandbox or keeping plaintext secrets out of the project is the only real boundary.&lt;/li&gt;
&lt;li&gt;Secrets exported into the shell you launch &lt;code&gt;claude&lt;/code&gt; from are inherited by every Bash command it runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does a Claude Code deny rule actually block?
&lt;/h2&gt;

&lt;p&gt;A Claude Code deny rule blocks a specific &lt;strong&gt;tool call pattern&lt;/strong&gt;, not a file. &lt;code&gt;Read(./.env)&lt;/code&gt; means "refuse when the Read tool targets this path." The rule says nothing about what a shell process does with that path.&lt;/p&gt;

&lt;p&gt;This is the snippet almost everyone has in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&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;span class="nl"&gt;"deny"&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;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./secrets/**)"&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;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;It does its job. Ask Claude to open &lt;code&gt;.env&lt;/code&gt; with the Read tool and it gets refused, every time. The trouble is the mental model. I read that config as "Claude cannot see &lt;code&gt;.env&lt;/code&gt;." What it really says is "this one tool cannot open &lt;code&gt;.env&lt;/code&gt;." Those are very different promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Claude Code's Bash tool ignore Read deny rules?
&lt;/h2&gt;

&lt;p&gt;Bash is a separate tool with separate rules. Permission checks happen per tool: a Bash call is compared against &lt;code&gt;Bash(...)&lt;/code&gt; patterns, and a Read call is compared against &lt;code&gt;Read(...)&lt;/code&gt; patterns. A Read deny rule never sees a Bash call.&lt;/p&gt;

&lt;p&gt;Once a shell command runs, it is just a process on your machine with your user's permissions. &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt;: none of them know a JSON file somewhere said &lt;code&gt;.env&lt;/code&gt; was off limits.&lt;/p&gt;

&lt;p&gt;So the question becomes: what does your Bash allow list look like? Mine had grown the way everyone's does. I got tired of approving searches, so I clicked "don't ask again" a few times. Here's what had piled up:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&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;span class="nl"&gt;"allow"&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;span class="s2"&gt;"Bash(grep:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(cat:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(ls:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npm run test:*)"&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;span class="nl"&gt;"deny"&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;span class="s2"&gt;"Read(./.env)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read(./.env.*)"&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;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;Every one of those allows felt harmless when I added it.&lt;/p&gt;

&lt;h2&gt;
  
  
  I tested 7 ways to read .env. The deny rule stopped 2.
&lt;/h2&gt;

&lt;p&gt;I put a fake &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.local&lt;/code&gt; in a scratch repo, with the settings above, and asked Claude Code to run each of these as a separate step. You can repeat this in two minutes on your own install, and you should, because your allow list is not my allow list.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;What ran&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;1&lt;/td&gt;
&lt;td&gt;Read tool on &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Denied&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Read tool on &lt;code&gt;.env.local&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Denied&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cat .env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ran, no prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep -r DATABASE_URL .&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ran, no prompt, printed the secret line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep KEY .env.local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ran, no prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;head -3 .env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asked me for permission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python3 -c "print(open('.env').read())"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Asked me for permission&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two blocked. Three ran silently. Two depended on me reading a permission prompt carefully at 11pm.&lt;/p&gt;

&lt;p&gt;Number 4 is the scary one, because nobody asked for &lt;code&gt;.env&lt;/code&gt;. Claude was doing reasonable debugging. Plain &lt;code&gt;grep -r&lt;/code&gt; does not care about &lt;code&gt;.gitignore&lt;/code&gt;, so it walks straight into the file your &lt;code&gt;.gitignore&lt;/code&gt; was hiding from git. The secret lands in the tool output, which lands in the conversation context, which lands in your session log on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I fix it with a Bash deny rule like Bash(cat .env)?
&lt;/h2&gt;

&lt;p&gt;Partially, and not reliably. Bash permission patterns are &lt;strong&gt;prefix string matches&lt;/strong&gt; on the command. &lt;code&gt;Bash(cat .env)&lt;/code&gt; stops exactly &lt;code&gt;cat .env&lt;/code&gt;. It does not stop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cat ./.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cat  .env&lt;/code&gt; with two spaces&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;head .env&lt;/code&gt;, &lt;code&gt;tail .env&lt;/code&gt;, &lt;code&gt;less .env&lt;/code&gt;, &lt;code&gt;awk 1 .env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -r&lt;/code&gt; from the project root&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cp .env /tmp/x &amp;amp;&amp;amp; cat /tmp/x&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code is smart about shell operators. An allow rule for &lt;code&gt;Bash(safe-cmd:*)&lt;/code&gt; won't approve &lt;code&gt;safe-cmd &amp;amp;&amp;amp; something-else&lt;/code&gt;. But no string pattern can enumerate every program that can open a file. The docs themselves warn that Bash patterns trying to constrain arguments are fragile, and this is why.&lt;/p&gt;

&lt;p&gt;The honest takeaway: deny rules are for steering, not for security. Use them, but don't stop there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I block .env reads in Claude Code Bash commands?
&lt;/h2&gt;

&lt;p&gt;Use a &lt;code&gt;PreToolUse&lt;/code&gt; hook on the Bash tool that inspects the command and &lt;strong&gt;exits with code 2&lt;/strong&gt;. Exit 2 blocks the call and sends your stderr message back to Claude, which then picks a different approach. Exit 1 only logs an error and lets the command run, which surprises a lot of people.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;.claude/hooks/no-dotenv.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_input&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;command&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;touches_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\.env\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;blind_grep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\bgrep\s+(-\w*r|--recursive)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--exclude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;touches_env&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;blind_grep&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Blocked: this command can read .env. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ask the user for the specific value you need, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;or use grep with --exclude=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.env*&lt;/span&gt;&lt;span class="sh"&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="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it up in &lt;code&gt;.claude/settings.json&lt;/code&gt;:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;span class="nl"&gt;"PreToolUse"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3 &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;$CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/.claude/hooks/no-dotenv.py"&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;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;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;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;I reran the 7 tests with the hook in place. All five Bash variants were blocked before they ran, including the grep that only reached &lt;code&gt;.env&lt;/code&gt; by accident. Claude's next move on the grep case was to rerun it with &lt;code&gt;--exclude='.env*'&lt;/code&gt;, which is exactly the behaviour I wanted.&lt;/p&gt;

&lt;p&gt;Better still, the stderr message is a nudge. Claude reads it and adjusts, so the hook teaches as it blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a hook enough to protect secrets from Claude Code?
&lt;/h2&gt;

&lt;p&gt;No. A hook is still string matching, and string matching loses to creativity. &lt;code&gt;cat .e*&lt;/code&gt; slips past my regex. So does &lt;code&gt;python3 -c "open('.'+'env')"&lt;/code&gt;. Claude isn't trying to break in, so in practice the hook catches the accidents, which are the real risk. But "catches accidents" is not a security boundary.&lt;/p&gt;

&lt;p&gt;Here's the layering I ended up with, from weakest to strongest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read deny rules.&lt;/strong&gt; Keep them. They stop the built-in tools and signal intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim the Bash allow list.&lt;/strong&gt; I removed &lt;code&gt;Bash(cat:*)&lt;/code&gt; and &lt;code&gt;Bash(grep:*)&lt;/code&gt;. Searches go through Claude's built-in search tools, which are covered by the Read rules. Approving the occasional &lt;code&gt;cat&lt;/code&gt; costs me a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;PreToolUse&lt;/code&gt; hook.&lt;/strong&gt; Catches accidental reads and redirects Claude.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sandbox.&lt;/strong&gt; Claude Code's sandbox enforces filesystem and network rules at the OS level for Bash and every child process it spawns. That is the first layer where &lt;code&gt;python3&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt; and &lt;code&gt;cat&lt;/code&gt; all hit the same wall. Check the sandbox docs for how your version maps deny paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No plaintext secrets in the project at all.&lt;/strong&gt; This is the only layer that can't be bypassed. A secrets manager that injects values at process start means there is no file to leak.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The leak nobody checks: your shell environment
&lt;/h2&gt;

&lt;p&gt;One more trap. The Bash tool inherits the environment of the shell you started &lt;code&gt;claude&lt;/code&gt; from. If you ran &lt;code&gt;export OPENAI_API_KEY=...&lt;/code&gt; in that terminal, or your &lt;code&gt;.zshrc&lt;/code&gt; exports production credentials, then &lt;code&gt;printenv&lt;/code&gt; or &lt;code&gt;env&lt;/code&gt; prints them, no file involved. Not a single rule above helps.&lt;/p&gt;

&lt;p&gt;I now start Claude Code from a terminal with nothing sensitive exported. Inject credentials into the command that needs them, not into the parent shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  So do Claude Code deny rules protect my .env file?
&lt;/h2&gt;

&lt;p&gt;Claude Code deny rules protect &lt;code&gt;.env&lt;/code&gt; from Claude's built-in file tools only. &lt;code&gt;Read(./.env)&lt;/code&gt; is matched against Read tool calls, so the Bash tool can still read the file with &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;grep -r&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt; or any script whenever your Bash allow list or a hurried approval lets the command run. Bash deny patterns are prefix matches and easy to sidestep. To actually keep secrets out of the transcript, trim broad Bash allows, add a &lt;code&gt;PreToolUse&lt;/code&gt; hook that exits with code 2 on commands touching &lt;code&gt;.env&lt;/code&gt;, turn on the sandbox for OS-level enforcement, and keep plaintext secrets out of the project directory and out of the shell environment you launch &lt;code&gt;claude&lt;/code&gt; from.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>claude</category>
      <category>tooling</category>
      <category>cli</category>
    </item>
    <item>
      <title>pgvector HNSW Filtering Returned 0 Rows for 52 of My 400 Queries</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:04:57 +0000</pubDate>
      <link>https://dev.to/ji_ai/pgvector-hnsw-filtering-returned-0-rows-for-52-of-my-400-queries-ghp</link>
      <guid>https://dev.to/ji_ai/pgvector-hnsw-filtering-returned-0-rows-for-52-of-my-400-queries-ghp</guid>
      <description>&lt;p&gt;A customer asked our docs assistant how to rotate an API key. Claude answered politely and with total confidence: "I couldn't find anything about key rotation in your documentation."&lt;/p&gt;

&lt;p&gt;The answer was on page two of their docs. The chunk was in my database. The embedding was fine. Postgres just never handed it over.&lt;/p&gt;

&lt;p&gt;This post is about pgvector HNSW filtering: one innocent &lt;code&gt;WHERE tenant_id = $1&lt;/code&gt; that made my retrieval return zero rows for 52 of 400 eval queries, with no error, no warning, and nothing on any dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;pgvector's HNSW index collects &lt;code&gt;hnsw.ef_search&lt;/code&gt; candidates first (default 40), and Postgres applies your WHERE filter afterwards. If 2% of rows match the filter, you get about 0.8 rows back, not 10.&lt;/li&gt;
&lt;li&gt;Nothing errors. &lt;code&gt;LIMIT 10&lt;/code&gt; just quietly returns fewer rows.&lt;/li&gt;
&lt;li&gt;In my multi-tenant RAG (1.2M chunks, 40 tenants), 147 of 400 queries got fewer than 10 chunks. 52 got zero.&lt;/li&gt;
&lt;li&gt;Raising &lt;code&gt;ef_search&lt;/code&gt; to 400 cut the short queries to 19 but tripled p95 latency. pgvector 0.8's &lt;code&gt;hnsw.iterative_scan = relaxed_order&lt;/code&gt; cut them to 3 at almost no cost.&lt;/li&gt;
&lt;li&gt;The last 3 came from a 600-row tenant. For tenants that small, skip the vector index entirely: an exact scan took 4ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A docs assistant for 40 customer workspaces. One &lt;code&gt;chunks&lt;/code&gt; table, 1.2M rows, &lt;code&gt;embedding vector(1024)&lt;/code&gt;, an HNSW index with &lt;code&gt;vector_cosine_ops&lt;/code&gt;, and a plain btree on &lt;code&gt;tenant_id&lt;/code&gt;. The biggest tenant had 410,000 chunks (34% of the table). The smallest had 600.&lt;/p&gt;

&lt;p&gt;Retrieval was the query every pgvector tutorial shows you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top 10 went into Claude with the usual instruction: answer only from the provided context, and if the answer isn't there, say so.&lt;/p&gt;

&lt;p&gt;That instruction is why this bug lived for weeks. Claude did exactly what I asked. Empty context in, honest refusal out. A grounded "I don't know" looks like correct behavior on every metric I was tracking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does pgvector HNSW filtering return fewer rows than LIMIT?
&lt;/h2&gt;

&lt;p&gt;Because the filter runs after the index scan, not inside it. HNSW walks its graph and keeps a candidate list of size &lt;code&gt;hnsw.ef_search&lt;/code&gt;, which defaults to 40. Those 40 nearest neighbors come from the entire table. Postgres then discards every candidate where &lt;code&gt;tenant_id&lt;/code&gt; doesn't match, and whatever survives is your result set.&lt;/p&gt;

&lt;p&gt;So the expected number of rows you get back is roughly &lt;code&gt;ef_search × p&lt;/code&gt;, where &lt;code&gt;p&lt;/code&gt; is the share of the table your filter matches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenant share of rows&lt;/th&gt;
&lt;th&gt;Expected matches in 40 candidates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;34% (410k rows)&lt;/td&gt;
&lt;td&gt;13.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.05% (600 rows)&lt;/td&gt;
&lt;td&gt;0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My biggest tenant was fine. Everyone else was eating crumbs.&lt;/p&gt;

&lt;p&gt;It was worse than the table suggests, because chunks aren't spread uniformly. Generic questions like "how do I reset my password" land in a region of the graph packed with the big tenant's auth docs. A small tenant asking that question was competing with hundreds of near-identical chunks from someone else's workspace, all of them filtered out after winning the race.&lt;/p&gt;

&lt;p&gt;Here's the rule of thumb I wish I'd known on day one: &lt;strong&gt;with default settings and &lt;code&gt;LIMIT 10&lt;/code&gt;, any filter matching less than 25% of your table can come back short.&lt;/strong&gt; That's just 40 × 0.25 = 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured the damage
&lt;/h2&gt;

&lt;p&gt;I had been evaluating answer quality, never retrieval count. So I built a retrieval-only eval: 400 queries, 10 per tenant, each paired with a gold chunk I knew existed. Then I added the one log line that should have been there from the start:&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RETRIEVE_SQL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qvec&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&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;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;short_retrieval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="o"&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;rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results with defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;253 of 400 queries returned the full 10 rows&lt;/li&gt;
&lt;li&gt;147 returned fewer than 10&lt;/li&gt;
&lt;li&gt;52 returned zero&lt;/li&gt;
&lt;li&gt;Every zero-row query came from a tenant holding under 3% of the table&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those 52 empty contexts, Claude said "not in your docs" 49 times. The other 3 times it ignored my instruction and answered from general knowledge, inventing a settings page that didn't exist. Honestly, I'm not sure which failure is worse. The refusals were wrong but at least looked like a safe system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does raising hnsw.ef_search fix it?
&lt;/h2&gt;

&lt;p&gt;Partly, and you pay for it in latency. A bigger candidate list means more survivors after filtering, but every query walks a bigger chunk of the graph, including queries from the big tenant that never needed help.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;ef_search&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Short queries (of 400)&lt;/th&gt;
&lt;th&gt;Zero-row queries&lt;/th&gt;
&lt;th&gt;p95 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;40 (default)&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;18ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;29ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;61ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 (max)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;140ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even at the maximum value pgvector allows, 9 queries still came back short. You can't buy your way out of a 0.05% filter with a bigger list.&lt;/p&gt;

&lt;p&gt;There was also a dumber problem. My first "fix" was &lt;code&gt;SET hnsw.ef_search = 400&lt;/code&gt; at the start of the request, and the numbers didn't move at all. We run PgBouncer in transaction mode, so the &lt;code&gt;SET&lt;/code&gt; ran on one backend and the query ran on another. Session settings and transaction pooling don't mix. Use &lt;code&gt;SET LOCAL&lt;/code&gt; inside the same transaction as the query, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a pgvector iterative index scan?
&lt;/h2&gt;

&lt;p&gt;An iterative index scan, added in pgvector 0.8.0, keeps walking the HNSW graph when filtering leaves too few rows, instead of stopping after the first &lt;code&gt;ef_search&lt;/code&gt; candidates. It continues until it has enough rows to satisfy the &lt;code&gt;LIMIT&lt;/code&gt; or hits a cap set by &lt;code&gt;hnsw.max_scan_tuples&lt;/code&gt; (default 20,000).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relaxed_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&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;relaxed_order&lt;/code&gt; means results can come back slightly out of distance order, which is why the materialized CTE re-sorts them. There's also &lt;code&gt;strict_order&lt;/code&gt;, which keeps exact ordering and is slower. For a top-10 that gets stuffed into a prompt anyway, relaxed plus a re-sort is fine.&lt;/p&gt;

&lt;p&gt;Results with ef_search back at 40 and iterative scan on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Short queries: 147 down to 3&lt;/li&gt;
&lt;li&gt;Zero-row queries: 52 down to 0&lt;/li&gt;
&lt;li&gt;p95 latency: 24ms, up from 18ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big tenant's queries didn't get slower at all, because they fill 10 rows on the first pass and never iterate. The cost lands only on the queries that needed the extra work. That's the shape you want from a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you skip the HNSW index entirely?
&lt;/h2&gt;

&lt;p&gt;When the filter is so selective that an approximate index is pointless. All 3 remaining short queries came from my 600-row tenant. Do the math: scanning the 20,000-tuple cap at 0.05% selectivity yields about 10 matching rows on average, so roughly half the time you land just under the limit.&lt;/p&gt;

&lt;p&gt;But computing 600 exact cosine distances is nothing. So small tenants now skip the vector index:&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;EXACT_SCAN_MAX_ROWS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qvec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;EXACT_SCAN_MAX_ROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# tenant_id btree via bitmap scan, then exact sort
&lt;/span&gt;            &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL enable_indexscan = off&lt;/span&gt;&lt;span class="sh"&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="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL hnsw.iterative_scan = relaxed_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RETRIEVE_SQL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qvec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning off &lt;code&gt;enable_indexscan&lt;/code&gt; stops the planner from picking HNSW, but bitmap scans are a separate setting, so Postgres still uses the &lt;code&gt;tenant_id&lt;/code&gt; btree to fetch that tenant's rows and sorts them exactly. I confirmed the plan with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; before trusting it. Exact scan for the 600-row tenant: 4ms, 100% recall.&lt;/p&gt;

&lt;p&gt;If you have a few huge tenants and many tiny ones, partitioning by tenant or partial HNSW indexes on the largest tenants are the heavier, cleaner versions of the same idea. My routing hack was 6 lines and solved the problem I actually had.&lt;/p&gt;

&lt;p&gt;Final scoreboard on the same 400 queries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Short&lt;/th&gt;
&lt;th&gt;Zero-row&lt;/th&gt;
&lt;th&gt;Gold chunk in top 10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Defaults&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;52&lt;/td&gt;
&lt;td&gt;241&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterative scan + exact for small tenants&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;368&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;127 more questions where the right chunk actually reached Claude. No model change, no prompt change, no reranker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you check in any pgvector RAG pipeline?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log rows returned vs. rows requested.&lt;/strong&gt; If you only log answer quality, a retrieval shortfall looks like a careful model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute your filter selectivity.&lt;/strong&gt; If &lt;code&gt;ef_search × share_of_rows &amp;lt; k&lt;/code&gt;, that filter value will come back short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;SET LOCAL&lt;/code&gt;, not &lt;code&gt;SET&lt;/code&gt;,&lt;/strong&gt; if anything sits between your app and Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-sort after &lt;code&gt;relaxed_order&lt;/code&gt;.&lt;/strong&gt; It's one CTE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eval retrieval separately from generation.&lt;/strong&gt; A grounded refusal on an empty context is a retrieval bug dressed as correct behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So why does pgvector HNSW filtering return fewer rows than your LIMIT? Because HNSW grabs only &lt;code&gt;hnsw.ef_search&lt;/code&gt; nearest candidates (40 by default) from the whole table, and Postgres applies your WHERE clause afterwards, so a filter matching a small share of rows leaves almost nothing behind. Fix it by upgrading to pgvector 0.8+ and enabling &lt;code&gt;hnsw.iterative_scan = relaxed_order&lt;/code&gt; with &lt;code&gt;SET LOCAL&lt;/code&gt;, re-sorting the results, and routing very selective filters to an exact scan. Then add a log line for &lt;code&gt;len(rows) &amp;lt; k&lt;/code&gt;, because Postgres will never tell you on its own.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>python</category>
    </item>
    <item>
      <title>frequency_penalty Broke My JSON: 47 of 88 Long Invoices Lost Items</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 29 Oct 2026 00:02:58 +0000</pubDate>
      <link>https://dev.to/ji_ai/frequencypenalty-broke-my-json-47-of-88-long-invoices-lost-items-5h7h</link>
      <guid>https://dev.to/ji_ai/frequencypenalty-broke-my-json-47-of-88-long-invoices-lost-items-5h7h</guid>
      <description>&lt;p&gt;The invoice had 34 line items. The JSON my extractor returned had 19. It parsed. It passed schema validation. Nobody noticed for two weeks.&lt;/p&gt;

&lt;p&gt;The cause was one line in a shared client config: &lt;code&gt;frequency_penalty=0.3&lt;/code&gt;. I had added it to stop a summarizer from looping. It was also quietly applied to my invoice extraction endpoint, and &lt;strong&gt;frequency_penalty breaks JSON output&lt;/strong&gt; in a way that gets worse with every item in the list. Short invoices were fine. Long ones fell apart.&lt;/p&gt;

&lt;p&gt;Here's the mechanism, the math, and what I changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;frequency_penalty&lt;/code&gt; subtracts &lt;code&gt;alpha * count&lt;/code&gt; from the logit of every token that has already appeared in the output. The penalty has no idea what JSON is.&lt;/li&gt;
&lt;li&gt;JSON repeats the same tokens constantly: quotes, colons, commas, braces, and every key name once per array item. Those counts grow linearly with array length.&lt;/li&gt;
&lt;li&gt;Eventually the penalized "continue" token (&lt;code&gt;,&lt;/code&gt;) loses to the never-used "stop" token (&lt;code&gt;]&lt;/code&gt;). The model closes the array early. The output is valid JSON with missing items.&lt;/li&gt;
&lt;li&gt;Constrained decoding (JSON schema / grammar mode) fixes renamed keys but &lt;strong&gt;does not&lt;/strong&gt; fix truncation, because &lt;code&gt;]&lt;/code&gt; is a legal token at every item boundary.&lt;/li&gt;
&lt;li&gt;Fix: set &lt;code&gt;frequency_penalty=0&lt;/code&gt;, &lt;code&gt;presence_penalty=0&lt;/code&gt;, &lt;code&gt;repetition_penalty=1.0&lt;/code&gt; for any structured output request, and validate item counts against something independent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually happened?
&lt;/h2&gt;

&lt;p&gt;Setup: a vLLM server running an open-weights 7B instruct model, called through the OpenAI-compatible API. One endpoint summarized support threads. Another extracted line items from invoices into this shape:&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;"items"&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="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A-1001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hex bolt M8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.14&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;The summarizer had a habit of repeating the same sentence three times at the end of long threads. I added this to the shared client wrapper:&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;DEFAULT_SAMPLING&lt;/span&gt; &lt;span class="o"&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&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 looping stopped. I moved on.&lt;/p&gt;

&lt;p&gt;Two weeks later, accounting flagged an invoice where our extracted total was way below the printed total. I pulled every extraction from that window and compared item counts against a separate OCR line count. These are my numbers from that batch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Invoice size&lt;/th&gt;
&lt;th&gt;Invoices&lt;/th&gt;
&lt;th&gt;Wrong item count or invalid JSON&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1-10 items&lt;/td&gt;
&lt;td&gt;290&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11-25 items&lt;/td&gt;
&lt;td&gt;122&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26+ items&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of the 47 long-invoice failures, most were valid JSON with the array closed early. A smaller group had keys that drifted mid-array: &lt;code&gt;"sku"&lt;/code&gt; became &lt;code&gt;"SKU"&lt;/code&gt; or &lt;code&gt;"sku_id"&lt;/code&gt;, or &lt;code&gt;"unit_price"&lt;/code&gt; turned into &lt;code&gt;"price"&lt;/code&gt;. Those failed Pydantic validation, so at least they were loud. The truncated ones were silent.&lt;/p&gt;

&lt;p&gt;The failure rate tracking array length was the clue. Prompt problems don't usually care how long your output is. A per-token counter does.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does frequency_penalty actually work?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;frequency_penalty&lt;/code&gt; is a logit adjustment applied at every decoding step: for each token already in the generated output, subtract &lt;code&gt;alpha&lt;/code&gt; times the number of times it has appeared. &lt;code&gt;presence_penalty&lt;/code&gt; subtracts a flat amount once a token has appeared at all.&lt;/p&gt;

&lt;p&gt;The OpenAI-style formula, which vLLM implements for these two parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight stan"&gt;&lt;code&gt;&lt;span class="nv"&gt;logit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;logit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;frequency_penalty&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;presence_penalty&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It operates on tokens, not words or meaning.&lt;/strong&gt; It doesn't know &lt;code&gt;,&lt;/code&gt; is syntax. It just sees a token with a high count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The count never resets.&lt;/strong&gt; Item 30 is penalized for every comma in items 1 through 29.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For prose that's the point. You want the model to stop reaching for the same phrase. For JSON it's poison, because JSON is &lt;em&gt;supposed&lt;/em&gt; to repeat. An array of 30 objects with 4 keys repeats each key 30 times and each separator over a hundred times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the model close the array early?
&lt;/h2&gt;

&lt;p&gt;At the end of every array item, the model makes one choice: emit &lt;code&gt;,&lt;/code&gt; and keep going, or emit &lt;code&gt;]&lt;/code&gt; and stop. The penalty tilts that choice further toward &lt;code&gt;]&lt;/code&gt; on every item.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;,&lt;/code&gt; has been emitted many times. &lt;code&gt;]&lt;/code&gt; has been emitted zero times, because the array hasn't closed yet. So &lt;code&gt;,&lt;/code&gt; carries a large penalty and &lt;code&gt;]&lt;/code&gt; carries none. At some item, the gap the model "wanted" between them gets erased.&lt;/p&gt;

&lt;p&gt;Here's a toy simulation. These logits are made up to show the shape of the curve. They are not measured from any model:&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;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;p_continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_gap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;9.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;commas_per_item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Probability of &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; vs &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; after n_items, two-token toy model.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;comma_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n_items&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;commas_per_item&lt;/span&gt;
    &lt;span class="n"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_gap&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;comma_count&lt;/span&gt;  &lt;span class="c1"&gt;# ']' has count 0, so no penalty
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;gap&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;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;p_continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 1.0
5 0.953
8 0.354
10 0.047
15 0.0
20 0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the shape. Nothing happens for a while, then the probability drops off a cliff over two or three items. That matches what I saw: invoices were either complete or badly truncated, rarely off by one.&lt;/p&gt;

&lt;p&gt;Real models have larger logit gaps for syntax than my toy, and tokenizers often merge &lt;code&gt;",&lt;/code&gt; or &lt;code&gt;},&lt;/code&gt; into single tokens, which spreads the count across several token IDs. That's why my real failures started in the 20s instead of at item 8. The curve is the same. Only where it starts moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do the keys get renamed?
&lt;/h2&gt;

&lt;p&gt;Key drift is the same math applied to key tokens. By item 25, the token for &lt;code&gt;sku&lt;/code&gt; has a count of 25. With &lt;code&gt;alpha=0.3&lt;/code&gt; that's a 7.5 logit penalty, which is roughly a 1,800x hit to its relative probability. Meanwhile &lt;code&gt;SKU&lt;/code&gt;, &lt;code&gt;sku&lt;/code&gt;, and &lt;code&gt;sku_id&lt;/code&gt; are different token IDs with a count of zero.&lt;/p&gt;

&lt;p&gt;The model still "knows" the key is &lt;code&gt;sku&lt;/code&gt;. It just picks the closest unpenalized spelling. You get a valid-looking object with a key your schema has never seen.&lt;/p&gt;

&lt;p&gt;Values suffer too. A column where every row says &lt;code&gt;"EA"&lt;/code&gt; or &lt;code&gt;"USD"&lt;/code&gt; gets penalized into &lt;code&gt;"each"&lt;/code&gt; or &lt;code&gt;"usd"&lt;/code&gt;. Digits get counted, so long numbers with repeated digits are under pressure as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does structured output or guided decoding fix this?
&lt;/h2&gt;

&lt;p&gt;Partly. Constrained decoding (vLLM's guided JSON, &lt;code&gt;response_format&lt;/code&gt; with a JSON schema, grammar-based samplers) masks out any token the schema doesn't allow. That kills key renaming, because &lt;code&gt;"SKU"&lt;/code&gt; isn't a legal token sequence when the schema says &lt;code&gt;"sku"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It does not fix truncation. At the end of every array item, both &lt;code&gt;,&lt;/code&gt; and &lt;code&gt;]&lt;/code&gt; are legal under the grammar. The mask lets both through, and the penalty still decides between them. If your schema has no &lt;code&gt;minItems&lt;/code&gt;, a 19-item array is just as valid as a 34-item one.&lt;/p&gt;

&lt;p&gt;So the order of fixes is: penalty off first, constraints second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about repetition_penalty in vLLM and Hugging Face?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;repetition_penalty&lt;/code&gt; is a different knob with a different failure mode, and it's arguably worse for structured output. Instead of subtracting a count, it scales any token that has already appeared: positive logits get divided by the penalty, negative logits get multiplied by it. It's binary per token, not count-based.&lt;/p&gt;

&lt;p&gt;The catch: in both Hugging Face &lt;code&gt;generate&lt;/code&gt; and vLLM, &lt;code&gt;repetition_penalty&lt;/code&gt; looks at the &lt;strong&gt;prompt tokens too&lt;/strong&gt;, not just the output. If your system prompt contains an example JSON object, every key and every brace in it is penalized before the model writes its first token. A value of 1.15 sounds tiny. On a token with logit 20, it's a drop to about 17.4, a 2.6 logit hit on the exact tokens your schema needs.&lt;/p&gt;

&lt;p&gt;If you put a schema example in your prompt and run with &lt;code&gt;repetition_penalty &amp;gt; 1&lt;/code&gt;, you are penalizing the model for following your example.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix frequency_penalty breaking JSON?
&lt;/h2&gt;

&lt;p&gt;Stop sharing sampling parameters between prose and structured endpoints. Here's what I run now:&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;PROSE_SAMPLING&lt;/span&gt; &lt;span class="o"&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;STRUCTURED_SAMPLING&lt;/span&gt; &lt;span class="o"&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extra_body&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repetition_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;# vLLM-specific, explicit on purpose
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I set the zeros explicitly instead of omitting them. If someone changes server-side defaults or a wrapper adds a penalty later, the explicit value wins.&lt;/p&gt;

&lt;p&gt;Then, the checks that would have caught this in a day instead of two weeks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate counts against an independent signal.&lt;/strong&gt; For invoices: sum of &lt;code&gt;qty * unit_price&lt;/code&gt; should match the printed total within rounding. For any list extraction, compare against a cheap heuristic count (OCR rows, regex hits, bullet count).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;minItems&lt;/code&gt; or an expected count to the schema when you know it.&lt;/strong&gt; If the upstream system knows there are 34 rows, say so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log &lt;code&gt;finish_reason&lt;/code&gt; and output token count per request.&lt;/strong&gt; Truncation from the penalty shows &lt;code&gt;finish_reason: "stop"&lt;/code&gt;, not &lt;code&gt;"length"&lt;/code&gt;, which is exactly why it hides. A sudden drop in tokens per item is the tell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a length sweep test.&lt;/strong&gt; Generate synthetic inputs with 5, 10, 20, 40, 80 items and assert the output count matches. Run it whenever sampling config changes. My failures only showed up past 25 items; a test suite of 3-item fixtures would never catch them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The summarizer looping problem I originally added the penalty for? It went away after I lowered &lt;code&gt;max_tokens&lt;/code&gt; for that endpoint and added a stop sequence. The penalty was the wrong tool even there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does frequency_penalty break JSON output?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;frequency_penalty&lt;/code&gt; breaks JSON output because it subtracts &lt;code&gt;alpha * count&lt;/code&gt; from every token already generated, and JSON is built from tokens that must repeat: quotes, colons, commas, braces, and the same key names in every array item. As an array grows, the continue token &lt;code&gt;,&lt;/code&gt; piles up penalty while the closing token &lt;code&gt;]&lt;/code&gt; has none, so the model ends the array early and returns valid but incomplete JSON. Key tokens get the same treatment and drift to unpenalized spellings like &lt;code&gt;SKU&lt;/code&gt; or &lt;code&gt;sku_id&lt;/code&gt;. Constrained decoding blocks the renamed keys but not the early &lt;code&gt;]&lt;/code&gt;. Set &lt;code&gt;frequency_penalty&lt;/code&gt;, &lt;code&gt;presence_penalty&lt;/code&gt;, and &lt;code&gt;repetition_penalty&lt;/code&gt; to neutral values on every structured output request, and check extracted item counts against a source that doesn't come from the model.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the developer behind &lt;a href="https://preterview.com/en" rel="noopener noreferrer"&gt;Preterview&lt;/a&gt;, an interview prep platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>Wrapping Claude Code and Codex in Electron: A Stop Bug</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:01:46 +0000</pubDate>
      <link>https://dev.to/ji_ai/wrapping-claude-code-and-codex-in-electron-a-stop-bug-ple</link>
      <guid>https://dev.to/ji_ai/wrapping-claude-code-and-codex-in-electron-a-stop-bug-ple</guid>
      <description>&lt;p&gt;I pressed Stop on a task. When I looked 35 seconds later, a file in my test folder was gone.&lt;/p&gt;

&lt;p&gt;The file was &lt;code&gt;keep.txt&lt;/code&gt;, the folder was throwaway, and I had told the agent to set the trap itself: &lt;code&gt;nohup sh -c 'sleep 20; rm keep.txt' &amp;gt; /dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&lt;/code&gt;. Stop was supposed to end everything the task had started. It ended Claude Code, and the &lt;code&gt;rm&lt;/code&gt; ran anyway. Of the bugs my end-to-end runs found in the wrapper app, this is the one that deleted a file.&lt;/p&gt;

&lt;p&gt;This is part 9 of a series about Refmade, a desktop app I'm building alone. Refmade lets people who don't live in a terminal hand work to the Claude Code or Codex they have already installed, and shows the agents as an office you can watch. It is an independent app, not affiliated with Anthropic or OpenAI. Claude Code and Codex agents wrote most of the code at my direction; I planned, decided and reviewed. The UI is Korean right now; that changes later in this series. This part covers the evening of September 27 through September 29, when the app went from a web dashboard to an Electron shell around two command-line tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spawn the CLI, don't call the model
&lt;/h2&gt;

&lt;p&gt;A wrapper that calls the model API itself needs a key, and then it is a different product with a different bill. Refmade starts the &lt;code&gt;claude&lt;/code&gt; or &lt;code&gt;codex&lt;/code&gt; binary you installed, in the folder you picked, under your login. The header of the Codex runner in the current source says it plainly: the user's own binary and config are used as-is, and the app never reads auth or tokens.&lt;/p&gt;

&lt;p&gt;Both tools turned out to have a machine-readable mode with a channel for permission questions. That channel is the whole trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code: JSON lines in both directions
&lt;/h2&gt;

&lt;p&gt;The runner starts one long-lived &lt;code&gt;claude -p&lt;/code&gt; per task. Everything on stdin and stdout is one JSON object per line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude -p --input-format stream-json --output-format stream-json \
  --verbose --include-partial-messages --replay-user-messages \
  --permission-prompt-tool stdio
# stdin, first line:
{"type":"control_request","request_id":"init_1","request":{"subtype":"initialize","hooks":null}}
# answers to the CLI's questions reuse the question's id:
{"type":"control_response","response":{"subtype":"success","request_id":"&amp;lt;id&amp;gt;","response":{...}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--permission-prompt-tool stdio&lt;/code&gt; is what replaces the terminal prompt. When Claude Code wants to run a tool that its settings don't already allow, it sends a &lt;code&gt;can_use_tool&lt;/code&gt; request on stdout. The app shows a card, waits for a click, and sends back a control response. &lt;code&gt;--replay-user-messages&lt;/code&gt; makes the CLI echo each message it takes up, which is how the app knows whose turn it is when a background job finishes on its own. I pass no permission mode. Your &lt;code&gt;settings.json&lt;/code&gt; rules apply, and a tool you allowed there runs without a card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Codex: JSON-RPC over stdio
&lt;/h2&gt;

&lt;p&gt;Codex goes through &lt;code&gt;codex app-server&lt;/code&gt;, newline-delimited JSON-RPC on stdio. My runner was written against the schema of Codex CLI 0.156.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Refmade window
      |  IPC
 Refmade main process ---- JSON lines ----&amp;gt; claude -p            (your install, your login)
      |                 \-- JSON-RPC  ----&amp;gt; codex app-server     (your install, your login)
      '-- plain-words card &amp;lt;---- permission request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Claude Code&lt;/th&gt;
&lt;th&gt;Codex&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Process&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude -p&lt;/code&gt;, stream-json&lt;/td&gt;
&lt;td&gt;&lt;code&gt;codex app-server&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission asks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;can_use_tool&lt;/code&gt; control request&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;item/commandExecution/requestApproval&lt;/code&gt;, &lt;code&gt;item/fileChange/requestApproval&lt;/code&gt;, &lt;code&gt;item/permissions/requestApproval&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions to the person&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AskUserQuestion&lt;/code&gt; through the same channel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;item/tool/requestUserInput&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A request the runner doesn't know gets a JSON-RPC error back, &lt;code&gt;-32601&lt;/code&gt;, with the text "Refmade does not handle" and the method name. I would rather fail loudly on a request I don't understand than guess an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cards in words, not command lines
&lt;/h2&gt;

&lt;p&gt;A raw &lt;code&gt;rm -rf&lt;/code&gt; prompt is useless to a person who has never used a shell. &lt;code&gt;main/plain.mjs&lt;/code&gt; turns a request into a title, one plain sentence, a place (inside or outside the folder you picked), and chips for what changes. It is over 5,000 lines now.&lt;/p&gt;

&lt;p&gt;For shell commands it splits on &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt; and newlines, reads each part by its first word, skips prefixes like &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;nohup&lt;/code&gt;, and reads &lt;code&gt;bash -c '...'&lt;/code&gt; from the inside. The sentence is always the app's own reading of the command. The AI's description is shown separately as "AI explanation", so a soft description can never hide what the command does. A program it can't read with confidence gets the &lt;code&gt;maybe&lt;/code&gt; chip, and "don't ask again" is not offered for 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%2Frr26wrhu1tctmbtdn1a9.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%2Frr26wrhu1tctmbtdn1a9.png" alt="Approval card for creating a file outside the chosen folder" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A Codex permission card from my Sept 29 test run, in a throwaway test folder (Korean UI). Title: "May I create a new file?" What: "Creates the file c2.txt in the w-codex-outside folder." Where, in red: "Outside the folder you picked (your user folder)". Changes: "A new file appears". Buttons: "Yes, go ahead", "No, don't", "No, do it differently".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The two runners never answer a permission request by themselves. "Don't ask again" only forwards the CLI's own suggestion, scoped to the session. Later versions add an auto mode that answers light multiple-choice questions for you. It is not a safety guarantee, and a later post covers where I got that wrong in my own copy.&lt;/p&gt;

&lt;p&gt;None of this is a sandbox. It is a reading of requests, and the reading can be wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nohup bug
&lt;/h2&gt;

&lt;p&gt;Claude Code 2.1.283 runs each Bash command in a detached shell of its own. When I stopped a task, the app ended the CLI and its process group, and that did not reach a process the command had pushed into the background. &lt;code&gt;nohup sh -c 'sleep 20; rm keep.txt' &amp;amp;&lt;/code&gt; outlived the Stop button and deleted the file.&lt;/p&gt;

&lt;p&gt;The fix is in &lt;code&gt;main/procs.mjs&lt;/code&gt;, and it is a heuristic. Linux can read another process's environment, so every CLI session gets a &lt;code&gt;REFMADE_TASK&lt;/code&gt; variable and leftovers are found by it. macOS &lt;code&gt;ps&lt;/code&gt; will not show another process's environment. There the app looks for processes that were orphaned (parent pid 1), started while that task's CLI was running, and whose working folder is the task folder or inside it. It leaves apps and system programs alone.&lt;/p&gt;

&lt;p&gt;Known limit, written in the file: a dev server that you or another program started in the same folder while the task ran, and left running, looks identical. On Windows the function returns an empty list. Stop ends only the CLI's own process tree there, and the public Windows build is older than the Mac one. After the fix, the same test left &lt;code&gt;keep.txt&lt;/code&gt; in place 35 seconds after Stop.&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%2Fshc21bm84ctbrsf2j32l.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%2Fshc21bm84ctbrsf2j32l.png" alt="Stop card after the fix" width="799" height="308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same test after the fix, Sept 28, 10:05 to 10:08 PM (Korean UI). My message asks for the &lt;code&gt;nohup&lt;/code&gt; command and then a &lt;code&gt;sleep 40&lt;/code&gt;. The row says "Thought it through, ran 1 command on the computer, may have deleted 1 file". The status lines read "Task stopped" and "Work running in the background stopped too".&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A race I found by launching in the wrong order
&lt;/h2&gt;

&lt;p&gt;Start a task right after launching the app, and the team did not appear in the mini office within 60 seconds. If I opened the office first, it appeared in about a second. The collector's first read of the session logs takes around 4 seconds, and a session folder created during that read was never picked up. After the fix, a first task showed up in 6 seconds.&lt;/p&gt;

&lt;p&gt;An end-to-end run against the real app found it, because the run sent a task in the first seconds after launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was checked
&lt;/h2&gt;

&lt;p&gt;Three rounds of adversarial review by separate agents went over this code. Round 1 ended with 36 fixes and round 2 with 35 more. After round 3 the app tests passed 394 of 394, three runs in a row. Those numbers say the tests agree with the code. They say nothing about whether anyone finds the app easy to use, and I haven't run a usability test with a non-developer.&lt;/p&gt;

&lt;p&gt;The app link comes in the last post of the series.&lt;/p&gt;

&lt;p&gt;If you wrap an agent CLI, how do you decide that a stopped session is really stopped: process-group kill, an environment tag like mine, or something else?&lt;/p&gt;

</description>
      <category>electron</category>
      <category>claudecode</category>
      <category>ai</category>
      <category>node</category>
    </item>
  </channel>
</rss>
