<?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: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</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%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>Reporting UI Errors Without Blowing Up Prometheus</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 16 Oct 2026 05:40:14 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/reporting-ui-errors-without-blowing-up-prometheus-17cc</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/reporting-ui-errors-without-blowing-up-prometheus-17cc</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>react</category>
      <category>observability</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Alert on Brand-New Errors, Not Thresholds, with Redis</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:58:00 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/alert-on-brand-new-errors-not-thresholds-with-redis-3742</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/alert-on-brand-new-errors-not-thresholds-with-redis-3742</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>observability</category>
      <category>redis</category>
      <category>grafana</category>
      <category>java</category>
    </item>
    <item>
      <title>A Vulnerability Disclosure Policy for a Solo SaaS</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:09:40 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/a-vulnerability-disclosure-policy-for-a-solo-saas-43m4</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/a-vulnerability-disclosure-policy-for-a-solo-saas-43m4</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>security</category>
      <category>saas</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Pacing Bulk Async Jobs Under an LLM Rate Limit</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:16:03 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/pacing-bulk-async-jobs-under-an-llm-rate-limit-4j6m</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/pacing-bulk-async-jobs-under-an-llm-rate-limit-4j6m</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>java</category>
      <category>api</category>
      <category>performance</category>
      <category>ai</category>
    </item>
    <item>
      <title>Versioned Content Fingerprints for Change Detection</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:02:52 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/versioned-content-fingerprints-for-change-detection-5eme</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/versioned-content-fingerprints-for-change-detection-5eme</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>java</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Rate-Limit Failed Logins Without Locking Out Victims</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:29:53 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/rate-limit-failed-logins-without-locking-out-victims-594j</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/rate-limit-failed-logins-without-locking-out-victims-594j</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>security</category>
      <category>redis</category>
      <category>java</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Closing the SSRF DNS-Rebinding Hole with a Custom Resolver</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:33:13 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/closing-the-ssrf-dns-rebinding-hole-with-a-custom-resolver-44mn</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/closing-the-ssrf-dns-rebinding-hole-with-a-custom-resolver-44mn</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>security</category>
      <category>java</category>
      <category>springboot</category>
      <category>webdev</category>
    </item>
    <item>
      <title>OAuth client_credentials in Spring Boot for Prometheus Scrapes</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 25 Sep 2026 06:00:33 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/oauth-clientcredentials-in-spring-boot-for-prometheus-scrapes-27gh</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/oauth-clientcredentials-in-spring-boot-for-prometheus-scrapes-27gh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/oauth-client-credentials-spring-prometheus-scrape/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We stood up Prometheus on a self-hosted DigitalOcean droplet and pointed it at our Spring Boot API's &lt;code&gt;/actuator/prometheus&lt;/code&gt; endpoint. The obvious question came next: how does Prometheus authenticate?&lt;/p&gt;

&lt;p&gt;Until that moment, our OAuth server had spoken exactly two grants — &lt;code&gt;authorization_code&lt;/code&gt; (with PKCE) and &lt;code&gt;refresh_token&lt;/code&gt;. Both assume a user behind the request. Zapier, Make, Pipedream, the Chrome extension, the MCP clients — every existing integration represents a &lt;em&gt;person&lt;/em&gt; who clicked "Connect". Prometheus represents nobody. It's a process on a host scraping a metrics endpoint every 30 seconds. There is no consenting user.&lt;/p&gt;

&lt;p&gt;The RFC has a grant for exactly this — &lt;code&gt;client_credentials&lt;/code&gt;, §4.4. The client authenticates itself to the token endpoint with its credentials, gets back a bearer, and presents that bearer on subsequent calls. No user, no consent screen, no refresh token. Here's how we slotted it into an existing Spring Security stack without regressing a single user-bound integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client_credentials Fits
&lt;/h2&gt;

&lt;p&gt;Three properties of the Prometheus scrape are worth being explicit about, because each maps directly to a §4.4 design choice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no user.&lt;/strong&gt; Prometheus is a daemon. It has no Auth0 identity, no email, no role assignments. Forcing it through &lt;code&gt;authorization_code&lt;/code&gt; would mean creating a synthetic "Prometheus user" — a pattern that's been a security footgun every time we've seen it (service accounts that drift into having too many permissions because they're shaped like real users).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The credentials live on the server, rotatably.&lt;/strong&gt; Prometheus reads its OAuth &lt;code&gt;client_secret&lt;/code&gt; from a file on disk that the deployment script writes. Rotating means redeploying the secret file and restarting Prometheus — same operational model as any other server-side credential. No browser, no consent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The blast radius is one endpoint.&lt;/strong&gt; Prometheus has exactly one thing it's allowed to do: GET &lt;code&gt;/actuator/prometheus&lt;/code&gt;. Not list URLs, not read analytics, not touch user data. The credential should be incapable of doing anything else even if it leaks.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;client_credentials&lt;/code&gt; is the RFC-defined shape for all three. The wire flow is two HTTP calls: &lt;code&gt;POST /oauth/token&lt;/code&gt; with HTTP Basic-Auth-encoded client credentials returns an &lt;code&gt;access_token&lt;/code&gt;; every subsequent scrape carries &lt;code&gt;Authorization: Bearer &amp;lt;access_token&amp;gt;&lt;/code&gt; until the token expires. Per §4.4.3 there is no refresh token — when the access token expires, the client re-authenticates by hitting &lt;code&gt;/oauth/token&lt;/code&gt; again. Prometheus's built-in &lt;code&gt;oauth2:&lt;/code&gt; block does this transparently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Schema Relaxations
&lt;/h2&gt;

&lt;p&gt;Before any of the Java could change, the database had to admit the shape of a userless token and the shape of a redirect-URI-less client. Liquibase changeset 356 made three modifications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;changeSet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;356-add-oauth-client-credentials-grant&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anand&lt;/span&gt;
    &lt;span class="na"&gt;changes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;addColumn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_clients&lt;/span&gt;
          &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;column&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grant_types&lt;/span&gt;
                &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VARCHAR(100)&lt;/span&gt;
                &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization_code,refresh_token"&lt;/span&gt;
                &lt;span class="na"&gt;constraints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
                &lt;span class="na"&gt;remarks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Comma-separated&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;RFC&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6749&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;client&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;permitted&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;use."&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dropNotNullConstraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_clients&lt;/span&gt;
          &lt;span class="na"&gt;columnName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redirect_uris&lt;/span&gt;
          &lt;span class="na"&gt;columnDataType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TEXT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dropNotNullConstraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oauth_access_tokens&lt;/span&gt;
          &lt;span class="na"&gt;columnName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user_id&lt;/span&gt;
          &lt;span class="na"&gt;columnDataType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BIGINT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each change has a specific reason:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_clients.grant_types&lt;/code&gt; (NEW column).&lt;/strong&gt; Previously, the grant type set was implicit — every client got AC + RT. Now each client carries the exact list of grants it's allowed to request. Existing rows get backfilled to &lt;code&gt;"authorization_code,refresh_token"&lt;/code&gt; via the column default during the &lt;code&gt;addColumn&lt;/code&gt; step, which is the property that keeps every existing integration green. Zapier's row, Make's row, both Pipedream rows, the Chrome extension's row — all get the same string they were implicitly assuming. The new CC-only Prometheus client gets &lt;code&gt;"client_credentials"&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_clients.redirect_uris&lt;/code&gt; nullable.&lt;/strong&gt; A CC-only client legitimately has no browser callback. Without this relaxation, every CC client registration would have to invent a fake URL just to satisfy a &lt;code&gt;NOT NULL&lt;/code&gt; constraint that was modeling the wrong invariant. Enforcement now moves to the service layer — it's still required for &lt;code&gt;authorization_code&lt;/code&gt; clients, but the rule is "required when grant_types includes AC", not "required always".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;oauth_access_tokens.user_id&lt;/code&gt; nullable.&lt;/strong&gt; A CC token represents the client, not a user. The foreign key to &lt;code&gt;users(id) ON DELETE CASCADE&lt;/code&gt; stays — &lt;code&gt;NULL&lt;/code&gt; becomes the signal for "service-principal token, no user lookup needed". Token validation branches on this: if &lt;code&gt;userId&lt;/code&gt; is null, skip the user-load step.&lt;/p&gt;

&lt;p&gt;The changeset uses &lt;code&gt;addColumn&lt;/code&gt; and &lt;code&gt;dropNotNullConstraint&lt;/code&gt; rather than raw SQL, which sidesteps the Liquibase PL/pgSQL footgun (folded-block YAML corrupting dollar quotes). All three changes are reversible and the rollback is provided explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token Issuance
&lt;/h2&gt;

&lt;p&gt;The token endpoint is a switch on &lt;code&gt;grant_type&lt;/code&gt;. We added a third branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;issueClientCredentialsToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                                 &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestedResource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validateTokenEndpointClient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPublicClient&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_INVALID_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"client_credentials grant requires a confidential client"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;supportsGrant&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_credentials"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_CLIENT_DISABLED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Client is not authorized to use client_credentials grant"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Default to the client's full allowed scope set if none requested; otherwise&lt;/span&gt;
    &lt;span class="c1"&gt;// intersect requested scopes with allowed (RFC 6749 §3.3 narrowing).&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopesSet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedScope&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parseScopes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedScope&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UrlService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;OAUTH_INVALID_SCOPE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"One or more requested scopes are not granted to this client"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;grantedScopeString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;join&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Mint access token only — no refresh token per RFC 6749 §4.4.3.&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generateToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;TOKEN_LENGTH&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accessTokenHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;accessExpiresAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAccessTokenLifetimeSeconds&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessTokenEntity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tokenHash&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessTokenHash&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// CC tokens have no user&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grantedScopeString&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessExpiresAt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTenantId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestedResource&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;replace&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;accessTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessTokenEntity&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...returns TokenResponse with no refresh_token&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this is doing right:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public-client rejection.&lt;/strong&gt; A public client (&lt;code&gt;token_endpoint_auth_method = "none"&lt;/code&gt;) is one that authenticates via PKCE only. CC has no PKCE story — its security model is "the client knows the secret". Public clients can never use CC, full stop. We reject before issuing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-client grant gating.&lt;/strong&gt; &lt;code&gt;supportsGrant("client_credentials")&lt;/code&gt; checks the row's &lt;code&gt;grant_types&lt;/code&gt; column. A client registered for AC + RT cannot suddenly switch grants on the wire. The grant set is part of the client identity, not a request-time choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope narrowing.&lt;/strong&gt; RFC 6749 §3.3 says the authorization server MAY issue a narrower scope than requested, MUST NOT issue a wider one. We default to "narrowest of allowed and requested", and reject if the request asks for anything outside &lt;code&gt;allowed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The token gets persisted with &lt;code&gt;userId = null&lt;/code&gt; — the column relaxation from migration 356 is what allows that row to exist at all. No refresh token row is created.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication Subclass
&lt;/h2&gt;

&lt;p&gt;The hardest design decision wasn't on the token endpoint. It was on the validation side. When a bearer comes in on &lt;code&gt;/actuator/prometheus&lt;/code&gt;, the existing filter chain inspects the token and constructs a Spring Security &lt;code&gt;Authentication&lt;/code&gt; object to attach to the request. The existing code expects every authentication to have a user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// OAuthTokenAuthentication.java (existing — unchanged)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OAuthTokenAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildAuthorities&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// would NPE for CC tokens&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive fix is to make &lt;code&gt;user&lt;/code&gt; nullable on &lt;code&gt;OAuthTokenAuthentication&lt;/code&gt; and sprinkle null-checks across every consumer. We considered that and rejected it — the codebase has dozens of &lt;code&gt;instanceof OAuthTokenAuthentication&lt;/code&gt; checks across &lt;code&gt;AuthContext&lt;/code&gt;, &lt;code&gt;OAuthScopeEnforcementFilter&lt;/code&gt;, &lt;code&gt;Jo4McpTools&lt;/code&gt;, and elsewhere. Making &lt;code&gt;user&lt;/code&gt; nullable on the existing type would mean auditing every single one of them and adding null guards, with the constant risk of missing one and getting an NPE in production six weeks later.&lt;/p&gt;

&lt;p&gt;We introduced a separate authentication type instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Getter&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractAuthenticationToken&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OAuthAccessTokenEntity&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientEntity&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buildAuthorities&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopesSet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;setAuthenticated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * The "principal" of a client_credentials token is the OAuth client itself.
     */&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt; &lt;span class="nf"&gt;getPrincipal&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys us is &lt;em&gt;type-driven correctness&lt;/em&gt;. Every existing &lt;code&gt;instanceof OAuthTokenAuthentication&lt;/code&gt; check naturally evaluates false for a CC token. Code that asks "who is the user behind this request?" gets &lt;code&gt;Optional.empty()&lt;/code&gt; from &lt;code&gt;AuthContext.getActualUserOptional()&lt;/code&gt;. Controllers that call &lt;code&gt;authContext.getCurrentUser()&lt;/code&gt; throw &lt;code&gt;UNAUTHORIZED&lt;/code&gt; automatically. The MCP tools that filter by &lt;code&gt;userId&lt;/code&gt; never even see CC requests. We didn't have to audit dozens of sites; the type system did it for us.&lt;/p&gt;

&lt;p&gt;The validation filter branches once, at the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside OAuthTokenAuthenticationFilter, paraphrased&lt;/span&gt;
&lt;span class="nc"&gt;ValidatedToken&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oauthService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validateAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bearer&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuthTokenAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClient&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;validateAccessToken&lt;/code&gt; itself has exactly one change — the user lookup becomes conditional on &lt;code&gt;userId != null&lt;/code&gt;. That's the entire blast radius on the validation side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Path Allowlist
&lt;/h2&gt;

&lt;p&gt;Scope-based authorization gets us most of the way. The Prometheus client has &lt;code&gt;metrics:read&lt;/code&gt; scope only — not &lt;code&gt;read&lt;/code&gt;, not &lt;code&gt;write&lt;/code&gt;. Endpoints requiring &lt;code&gt;read&lt;/code&gt;/&lt;code&gt;write&lt;/code&gt; reject it because the bearer lacks those scopes.&lt;/p&gt;

&lt;p&gt;But "lacks the right scope" is one layer. We wanted defense-in-depth, because scope sets get edited in admin UIs by humans and a future "let's give the metrics client &lt;code&gt;read&lt;/code&gt; to test something" five-second mistake should not silently grant CC tokens access to user data. So we added a second filter that runs immediately after the token filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="nd"&gt;@Profile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClientCredentialsTokenGateFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AntPathMatcher&lt;/span&gt; &lt;span class="n"&gt;pathMatcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AntPathMatcher&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;@Value&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${app.security.cc-token-paths:/actuator/prometheus}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;ccTokenPaths&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;doFilterInternal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpServletResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FilterChain&lt;/span&gt; &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;ServletException&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="nc"&gt;Authentication&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getAuthentication&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// Only act on CC tokens. Everything else (JWT, API key, user-bound OAuth, anonymous) passes through.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;OAuthClientCredentialsAuthentication&lt;/span&gt; &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRequestURI&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Arrays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ccTokenPaths&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;","&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;String:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;pathAllowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;anyMatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pathMatcher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pathAllowed&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_credentials token attempted to access non-allowlisted path: clientId={}, path={}, scopes={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClientId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requestPath&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ccAuth&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getScopes&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="c1"&gt;// RFC 6750 §3.1: insufficient_scope on a Bearer-protected resource.&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FORBIDDEN&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WWW-Authenticate"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Bearer error=\"insufficient_scope\", error_description=\"client_credentials tokens are not valid for this endpoint\""&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getWriter&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"{\"error\":\"insufficient_scope\",\"error_description\":\"client_credentials tokens are not valid for this endpoint\"}"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The instanceof guard is the no-op short-circuit.&lt;/strong&gt; JWT requests, API-key requests, user-bound OAuth requests, anonymous requests — all four pass through the filter with one type check and zero allocations. The cost is paid only on the small set of CC requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config-driven allowlist.&lt;/strong&gt; &lt;code&gt;app.security.cc-token-paths&lt;/code&gt; is comma-separated and &lt;code&gt;AntPathMatcher&lt;/code&gt;-evaluated. Adding a future endpoint (say, an alertmanager receiver) is a config edit, not a code change. The default — &lt;code&gt;/actuator/prometheus&lt;/code&gt; — is the only thing CC tokens can reach out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application-security.yaml&lt;/span&gt;
&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Paths where client_credentials OAuth tokens are accepted. CSV; AntPathMatcher syntax.&lt;/span&gt;
    &lt;span class="na"&gt;cc-token-paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/actuator/prometheus"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;WWW-Authenticate: Bearer error="insufficient_scope"&lt;/code&gt;&lt;/strong&gt; is the RFC 6750 §3.1 shape. Prometheus and other OAuth-aware clients can read this and surface a meaningful error, rather than getting a generic 403 with no clue what went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never throws.&lt;/strong&gt; Filters that throw can mask the underlying error or tear down the chain in surprising ways. This one always writes a response or delegates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the Scrape
&lt;/h2&gt;

&lt;p&gt;On the Prometheus side, the entire setup is one block in &lt;code&gt;prometheus.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-api&lt;/span&gt;
    &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
    &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;oauth2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__PROMETHEUS_OAUTH_CLIENT_ID__&lt;/span&gt;
      &lt;span class="na"&gt;client_secret_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/prometheus/oauth-client-secret&lt;/span&gt;
      &lt;span class="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://10.108.0.3:8080/oauth/token&lt;/span&gt;
      &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;metrics:read&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.108.0.3:8080'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alertstage&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from setting this up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;client_id&lt;/code&gt; inline, &lt;code&gt;client_secret_file&lt;/code&gt; from disk.&lt;/strong&gt; Per OAuth 2.0, the client ID is public — fine to bake into the config and sed-substitute at deploy time. The secret stays in a separate file with restrictive permissions, written by the bootstrap script from a GitHub Secret. Never co-located, never logged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private VPC URLs.&lt;/strong&gt; &lt;code&gt;token_url&lt;/code&gt; and the scrape target both use the DigitalOcean private IP &lt;code&gt;10.108.0.3&lt;/code&gt;. Traffic never leaves the VPC, which is the primary security boundary; the OAuth check is defense-in-depth on top of that. The token endpoint accepts plain HTTP only because it's on a private subnet; this would be HTTPS for any path that crosses the public internet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;scopes: ["metrics:read"]&lt;/code&gt;.&lt;/strong&gt; Prometheus's &lt;code&gt;oauth2:&lt;/code&gt; block converts this into the &lt;code&gt;scope&lt;/code&gt; form field of the &lt;code&gt;/oauth/token&lt;/code&gt; request. Our &lt;code&gt;issueClientCredentialsToken&lt;/code&gt; narrows it against the client's allowed set — which happens to be just &lt;code&gt;metrics:read&lt;/code&gt; — and stamps the result on the issued access token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;refresh_token&lt;/code&gt; config.&lt;/strong&gt; Prometheus knows §4.4.3 — when it sees a token response with no &lt;code&gt;refresh_token&lt;/code&gt;, it just re-runs the token flow when the current access token nears expiry. No refresh logic on either side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing It End-To-End
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;curl&lt;/code&gt; sequence to verify the whole loop, against a freshly registered Prometheus client (client ID &lt;code&gt;jo4_Xxxx&lt;/code&gt;, client secret captured at registration):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Token exchange — HTTP Basic-Auth-encoded client credentials.&lt;/span&gt;
&lt;span class="nv"&gt;ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jo4-api.jo4.io/oauth/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"jo4_Xxxx:secret_XXXXXXXX"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=client_credentials"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"scope=metrics:read"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .access_token&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Scrape — bearer on the request.&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://jo4-api.jo4.io/actuator/prometheus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# → HTTP/2 200&lt;/span&gt;
&lt;span class="c"&gt;# → # HELP http_server_requests_seconds ...&lt;/span&gt;

&lt;span class="c"&gt;# 3. Negative test — same token against a user endpoint.&lt;/span&gt;
curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://jo4-api.jo4.io/api/v1/urls/me &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# → HTTP/2 403&lt;/span&gt;
&lt;span class="c"&gt;# → WWW-Authenticate: Bearer error="insufficient_scope", ...&lt;/span&gt;
&lt;span class="c"&gt;# → {"error":"insufficient_scope","error_description":"client_credentials tokens are not valid for this endpoint"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third call is the critical one to verify. The same bearer, presented at a non-allowlisted path, must be rejected — even though the bearer is technically valid. If you see anything other than 403 + &lt;code&gt;insufficient_scope&lt;/code&gt; there, the gate filter isn't wired correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A new grant type is mostly a schema problem.&lt;/strong&gt; The Java for &lt;code&gt;client_credentials&lt;/code&gt; is small. The work was three column relaxations and an audit of every codepath that assumed &lt;code&gt;userId&lt;/code&gt; was non-null.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate Authentication subclass beats nullable fields on the existing one.&lt;/strong&gt; &lt;code&gt;instanceof&lt;/code&gt; checks across the codebase become the type-system's audit for you. Sprinkling null-checks would have been an ongoing tax forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always backfill new NOT NULL columns via a default.&lt;/strong&gt; Adding &lt;code&gt;grant_types NOT NULL&lt;/code&gt; would have failed on every existing row without &lt;code&gt;defaultValue: "authorization_code,refresh_token"&lt;/code&gt;. The default is what makes the migration zero-downtime and the rollout silent for every existing client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense-in-depth means two independent layers, not one layer twice.&lt;/strong&gt; Scope-based authorization handles "the client wasn't granted this scope". The path-allowlist filter handles "even if someone misconfigures the scope set tomorrow, this token still can't hit user data". Both layers must agree before access is granted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WWW-Authenticate&lt;/code&gt; on 401/403 from a bearer-protected resource is RFC 6750 §3.1.&lt;/strong&gt; Clients can react to &lt;code&gt;error="insufficient_scope"&lt;/code&gt; programmatically. A naked 403 with no header forces humans into the loop for every failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No refresh token for CC, period.&lt;/strong&gt; RFC 6749 §4.4.3. Prometheus and every other OAuth-aware client know how to re-run the token flow on expiry. Issuing a refresh token would be inventing capability the spec explicitly forbids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate every existing integration before shipping.&lt;/strong&gt; We scanned ten risk categories across the codebase before touching a line — Zapier, Make, Pipedream, Chrome extension, MCP clients, OIDC discovery, &lt;code&gt;/oauth/userinfo&lt;/code&gt;, the scope filter, public endpoints, the previously-public &lt;code&gt;/actuator/prometheus&lt;/code&gt; path. The migration changed default-value-backfilled columns; behavior changed for exactly one path, and it was the one we intended.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Adding CC grant to an existing AC flow?&lt;/strong&gt; What schema migration bit you? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>springboot</category>
      <category>security</category>
      <category>prometheus</category>
    </item>
    <item>
      <title>Bound Spring MVC's Async Executor or Pay for It Under Load</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:43:18 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/bound-spring-mvcs-async-executor-or-pay-for-it-under-load-49ck</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/bound-spring-mvcs-async-executor-or-pay-for-it-under-load-49ck</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/mvc-async-executor-saturation-service-saturated/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Spring MVC's default async task executor spawns a brand new thread for every async request. No queue. No ceiling. No back-pressure. Spring itself logs a WARN on first use: &lt;em&gt;"This executor is not suitable for production use under load."&lt;/em&gt; That's a load-bearing warning, and most teams scroll past it.&lt;/p&gt;

&lt;p&gt;We learned the hard way and ended up wiring a bounded &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;, a typed rejection path, and a Micrometer saturation gauge. Here's the whole arc.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spring MVC Defaults To
&lt;/h2&gt;

&lt;p&gt;When a controller method returns a &lt;code&gt;DeferredResult&lt;/code&gt;, &lt;code&gt;Callable&lt;/code&gt;, &lt;code&gt;WebAsyncTask&lt;/code&gt;, &lt;code&gt;Flux&lt;/code&gt;, or &lt;code&gt;Mono&lt;/code&gt;, Spring MVC needs an executor to run the async dispatch. If you don't configure one, you get &lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt; does exactly what its name says: every submission spawns a fresh thread. There is no pool. There is no queue. There is no rejection policy, because there is no ceiling to reject against. Send it a thousand concurrent requests and it will cheerfully start a thousand threads, each with its own ~1 MB stack, and watch your container OOM.&lt;/p&gt;

&lt;p&gt;Spring 6 logs this WARN on first use:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This executor is not suitable for production use under load. Consider configuring a ThreadPoolTaskExecutor via WebMvcConfigurer.configureAsyncSupport.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you ignore the warning, the failure mode is exotic: latency stays fine until traffic crosses some invisible threshold, at which point the JVM either runs out of native threads (Linux &lt;code&gt;pthread_create&lt;/code&gt; returns EAGAIN) or runs out of heap because every parked thread is holding stack frames and a Tomcat request scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why That's Wrong For Real Apps
&lt;/h2&gt;

&lt;p&gt;Three patterns push you onto this executor whether you realize it or not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DeferredResult&lt;/code&gt; / &lt;code&gt;Callable&lt;/code&gt; / &lt;code&gt;WebAsyncTask&lt;/code&gt;&lt;/strong&gt; returns. Anyone using the classic async-servlet pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Flux&lt;/code&gt; / &lt;code&gt;Mono&lt;/code&gt; returns from a Spring MVC controller.&lt;/strong&gt; Spring MVC (not WebFlux) bridges reactive types onto the async-dispatch path. Every chunk emission round-trips through the executor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSE endpoints (&lt;code&gt;SseEmitter&lt;/code&gt;).&lt;/strong&gt; Long-lived streams that hold a dispatch thread for the entire lifetime of the connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We ship all three. Our analytics SSE stream in particular pins one async thread per subscriber for up to five minutes (our &lt;code&gt;ASYNC_TIMEOUT&lt;/code&gt;). With &lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt;, 500 concurrent dashboard users would mean 500 unbounded threads with no warning sign before the container falls over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounded Pool Setup
&lt;/h2&gt;

&lt;p&gt;The fix is a real &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; with explicit core, max, and queue settings. Here's the actual bean from &lt;code&gt;WebMvcAsyncConfig.java&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mvcAsyncExecutor"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destroyMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"shutdown"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="nf"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCorePoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// queueCapacity = 0 → Spring uses SynchronousQueue: direct hand-off, no buffering.&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQueueCapacity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setThreadNamePrefix&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jo4-mvc-async-"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setRejectedExecutionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AbortPolicy&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setWaitForTasksToCompleteOnShutdown&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAwaitTerminationSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureAsyncSupport&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AsyncSupportConfigurer&lt;/span&gt; &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDefaultTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ASYNC_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMillis&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;configurer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setTaskExecutor&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth calling out, because the obvious choices are wrong for long-lived async:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;queueCapacity = 0&lt;/code&gt;.&lt;/strong&gt; Spring's &lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt; interprets &lt;code&gt;queueCapacity = 0&lt;/code&gt; as a &lt;code&gt;SynchronousQueue&lt;/code&gt; — a direct hand-off with no buffering. The instinct is to "set a big queue, it's free." For short-lived tasks that's right. For SSE streams (which hold the thread for up to 5 minutes), a non-zero queue would &lt;em&gt;park&lt;/em&gt; new connections waiting for an existing core thread to free — effectively "hang the client until someone else disconnects." With a 0-capacity queue, the pool grows from &lt;code&gt;corePoolSize=8&lt;/code&gt; toward &lt;code&gt;maxPoolSize=200&lt;/code&gt; as new subscribers arrive, and only true saturation hits the rejection handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AbortPolicy&lt;/code&gt;, not &lt;code&gt;CallerRunsPolicy&lt;/code&gt;.&lt;/strong&gt; This one we learned by pain. Our first attempt used &lt;code&gt;CallerRunsPolicy&lt;/code&gt; (the seemingly-graceful "if the pool is full, run inline on the caller's thread"). Under load that pins the Tomcat request thread executing the task synchronously — which turned multi-minute Playwright teardowns into multi-minute hangs because the test runner's "close all contexts" call was racing pool saturation. Fail-fast with &lt;code&gt;AbortPolicy&lt;/code&gt; is preferable: back-pressure surfaces as a &lt;code&gt;RejectedExecutionException&lt;/code&gt; that the caller can translate into an HTTP 503, and the Tomcat thread is freed immediately for the next request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;waitForTasksToCompleteOnShutdown = true&lt;/code&gt; + &lt;code&gt;awaitTerminationSeconds = 30&lt;/code&gt;.&lt;/strong&gt; SSE streams need time to flush their final event and let the client see the close cleanly. Hard-killing the pool on shutdown means subscribers get a TCP reset instead of a proper stream-end signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;destroyMethod = "shutdown"&lt;/code&gt;.&lt;/strong&gt; Pairs the bean lifecycle with the executor lifecycle so Spring's context shutdown drains the pool. Without it you leak threads on every context refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejection With Meaning
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbortPolicy&lt;/code&gt; throws &lt;code&gt;RejectedExecutionException&lt;/code&gt;. By default that propagates out of the async-dispatch machinery, hits the generic exception handler, and becomes a &lt;code&gt;500 Internal Server Error&lt;/code&gt; — which is a lie. It's not an internal error; it's capacity. Calling clients, CDN edges, and retry-aware HTTP libraries all behave differently against 500 vs 503: 500 says "give up, this is broken"; 503 says "back off and try again."&lt;/p&gt;

&lt;p&gt;We give it a typed home in our &lt;code&gt;ErrorCode&lt;/code&gt; enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="no"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SERVICE_SATURATED"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Server is temporarily at capacity. Please retry."&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we catch it explicitly in &lt;code&gt;GlobalExceptionHandler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RejectedExecutionException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleRejectedExecution&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RejectedExecutionException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;requestUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getRequestUri&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Async executor saturated on {}: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestUri&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Fault&lt;/span&gt; &lt;span class="n"&gt;fault&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fault&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCode&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_SATURATED&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ResponseBody&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EmptyResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;faults&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fault&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;RETRY_AFTER&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"5"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here. First, the response is &lt;code&gt;503 SERVICE_UNAVAILABLE&lt;/code&gt; — semantically correct for "we're at capacity," not "we're broken." Second, the &lt;code&gt;Retry-After: 5&lt;/code&gt; header is honored by &lt;code&gt;fetch&lt;/code&gt; retry middlewares, the AWS SDK, and most CDN edges, which means callers self-throttle without us writing client code. Third, the structured fault carries &lt;code&gt;SERVICE_SATURATED&lt;/code&gt; so the SPA can render a friendly "we're at capacity, try again" toast instead of a generic 5xx splash.&lt;/p&gt;

&lt;p&gt;The mapping back to HTTP status lives in &lt;code&gt;mapErrorCodeToStatus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;SERVICE_SATURATED&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SERVICE_UNAVAILABLE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that explicit handler, the rejection would fall through to the catch-all &lt;code&gt;Exception&lt;/code&gt; handler and become a 500. Same root cause, completely different operational signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make It Observable
&lt;/h2&gt;

&lt;p&gt;Bounded pools are useless if you can't see how close you are to the wall. We expose five gauges via a &lt;code&gt;MeterBinder&lt;/code&gt;, scraped from &lt;code&gt;/actuator/prometheus&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;MeterBinder&lt;/span&gt; &lt;span class="nf"&gt;mvcAsyncExecutorMetrics&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ThreadPoolTaskExecutor&lt;/span&gt; &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.active"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ThreadPoolTaskExecutor:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getActiveCount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Threads currently executing tasks"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.pool.size"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;ThreadPoolTaskExecutor:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getPoolSize&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Current pool size (grows from core toward max under load)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.pool.max"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Configured maxPoolSize ceiling"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUnit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"threads"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.queue.size"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getThreadPoolExecutor&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tpe&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getQueue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="o"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Queued tasks waiting for a thread"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// The headline metric: active / max. 0.8 = alert; 1.0 = saturated.&lt;/span&gt;
        &lt;span class="nc"&gt;Gauge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"mvc.async.executor.saturation"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;mvcAsyncExecutor&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxPoolSize&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActiveCount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                &lt;span class="o"&gt;})&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Active-to-max ratio (0..1). &amp;gt;0.8 = capacity warning, 1.0 = rejecting"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;};&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three (&lt;code&gt;active&lt;/code&gt;, &lt;code&gt;pool.size&lt;/code&gt;, &lt;code&gt;pool.max&lt;/code&gt;) are diagnostic — useful in a Grafana dashboard to see how the pool actually grows. &lt;code&gt;queue.size&lt;/code&gt; is included for forward compatibility; with &lt;code&gt;queueCapacity = 0&lt;/code&gt; it's always zero, but if we ever tune the queue we don't want to be the team that forgot to publish the gauge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mvc.async.executor.saturation&lt;/code&gt; is the headline. It's a single derived ratio (active / max) bounded between 0 and 1, which means one alert threshold works regardless of how we resize the pool over time. Cardinality of one, semantics of "how close are we to rejecting" — exactly what a pager wants to wake you up about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerts That Actually Mean Something
&lt;/h2&gt;

&lt;p&gt;Two thresholds, both honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WARN&lt;/strong&gt; when &lt;code&gt;mvc_async_executor_saturation &amp;gt; 0.8&lt;/code&gt; for 5 minutes. We're at 80% of the ceiling for long enough to mean it's not a spike. Likely time to bump &lt;code&gt;maxPoolSize&lt;/code&gt;, investigate slow async handlers, or check if SSE clients are leaking connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRIT&lt;/strong&gt; when &lt;code&gt;mvc_async_executor_saturation == 1.0&lt;/code&gt;. We're rejecting. &lt;code&gt;SERVICE_SATURATED&lt;/code&gt; 503s are going out the door. Page on-call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The warning level matters because saturation is a leading indicator. Once you're rejecting, customers have already seen 503s. Once you're at 80%, you have minutes to act before that happens — assuming you alert on it. The corollary: a Grafana dashboard with saturation as the headline tile (not request rate, not latency) tells you at a glance whether you're about to have a bad day.&lt;/p&gt;

&lt;p&gt;We pair saturation with the &lt;code&gt;Retry-After&lt;/code&gt; header on the 503 so even when we &lt;em&gt;are&lt;/em&gt; rejecting, well-behaved clients smooth it out and we recover without a thundering herd hammering us the moment the pool drains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SimpleAsyncTaskExecutor&lt;/code&gt; is a development convenience, not a production executor.&lt;/strong&gt; If you return &lt;code&gt;DeferredResult&lt;/code&gt;, &lt;code&gt;Flux&lt;/code&gt;, &lt;code&gt;Mono&lt;/code&gt;, or use &lt;code&gt;SseEmitter&lt;/code&gt; from a Spring MVC controller, configure &lt;code&gt;AsyncSupportConfigurer.setTaskExecutor&lt;/code&gt; with a real bounded pool. The default's WARN log message is correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue capacity is a function of task duration, not of "more is better."&lt;/strong&gt; For sub-second tasks, a generous queue smooths spikes. For long-lived tasks like SSE, a queue silently hangs new connections behind old ones. Use &lt;code&gt;queueCapacity = 0&lt;/code&gt; + grow the pool to &lt;code&gt;maxPoolSize&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AbortPolicy&lt;/code&gt; over &lt;code&gt;CallerRunsPolicy&lt;/code&gt; for HTTP work.&lt;/strong&gt; &lt;code&gt;CallerRunsPolicy&lt;/code&gt; pins your Tomcat request threads under load and turns a capacity problem into a hung-process problem. Fail fast, return 503.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map rejection to a typed error code, not a generic 500.&lt;/strong&gt; &lt;code&gt;RejectedExecutionException → SERVICE_SATURATED → HTTP 503 + Retry-After&lt;/code&gt; tells callers something actionable. 500 tells them nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saturation is the gauge worth alerting on.&lt;/strong&gt; A derived &lt;code&gt;active/max&lt;/code&gt; ratio is dimensionless, bounded 0..1, survives pool resizing, and gives you a single threshold to define "we're about to have a bad day."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The WARN threshold is the one that prevents pages.&lt;/strong&gt; Alert at 0.8 with 5-minute hysteresis to get warned &lt;em&gt;before&lt;/em&gt; you reject. Alert at 1.0 to know you're already rejecting. Both, not either.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What's your async-executor war story?&lt;/strong&gt; Tail-latency, thread leaks, OOM? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>performance</category>
      <category>observability</category>
    </item>
    <item>
      <title>How One Spring Boot Default Killed Our SSE Endpoint Under Load</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:02:31 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-one-spring-boot-default-killed-our-sse-endpoint-under-load-1681</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-one-spring-boot-default-killed-our-sse-endpoint-under-load-1681</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/sse-osiv-pool-leak-spring/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our staging environment had ten browser tabs open with the notification bell. The eleventh person to open the app got an API that just sat there. Not a 500. Not a timeout (yet). Just a silent stall on every endpoint, not only the SSE one. The pod looked healthy. CPU flat, memory fine, logs quiet.&lt;/p&gt;

&lt;p&gt;We'd turned our notification bell into a Server-Sent Events stream while leaving one Spring Boot default in place. For a request-response API, that default is fine. For a 30-minute SSE connection, it's lethal: &lt;code&gt;spring.jpa.open-in-view=true&lt;/code&gt; pins a Hikari connection per open tab for the entire lifetime of the stream.&lt;/p&gt;

&lt;p&gt;Here's the bug, the math behind the symptom, and the one-line fix — plus the precondition the endpoint has to satisfy for the fix to be safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Endpoint
&lt;/h2&gt;

&lt;p&gt;The SSE endpoint lives in &lt;code&gt;NotificationController.streamNotifications()&lt;/code&gt;. It returns a &lt;code&gt;Flux&amp;lt;ServerSentEvent&amp;lt;NotificationStreamEvent&amp;gt;&amp;gt;&lt;/code&gt; to the client and stays open for 30 minutes, emitting the initial unread count, live updates from the event bus, and a heartbeat every 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/stream"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_EVENT_STREAM_VALUE&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;streamNotifications&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireScope&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authContext&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCurrentUser&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;
        &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unread-count"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;notificationEventBus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;HEARTBEAT_INTERVAL&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ping"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;concat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;just&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Flux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heartbeat&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;STREAM_LIFETIME&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers matter: &lt;code&gt;STREAM_LIFETIME = Duration.ofMinutes(30)&lt;/code&gt; and &lt;code&gt;HEARTBEAT_INTERVAL = Duration.ofSeconds(30)&lt;/code&gt;. The stream stays open for half an hour. The client reconnects on close with a fresh JWT. The heartbeat keeps it alive through any proxy idle-timeout in between.&lt;/p&gt;

&lt;p&gt;From the controller's perspective this looks innocuous. It's a Flux. It's reactive. It touches JPA &lt;em&gt;once&lt;/em&gt;, synchronously, at the top, to seed the initial count. The Flux body that streams for the next 30 minutes never touches the database.&lt;/p&gt;

&lt;p&gt;That's what we &lt;em&gt;thought&lt;/em&gt; the runtime behavior was. The runtime had other ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  open-in-view: Spring's Helpful Default
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;spring.jpa.open-in-view=true&lt;/code&gt; is Spring Boot's default. It enables OSIV — Open Session In View. The mechanism is an interceptor that opens a Hibernate &lt;code&gt;Session&lt;/code&gt; (and, through it, borrows a database connection from the Hikari pool) at the &lt;em&gt;start&lt;/em&gt; of an HTTP request, and holds it open until the response is fully written.&lt;/p&gt;

&lt;p&gt;For traditional MVC this is convenient. Lazy-loaded JPA associations still resolve when your Thymeleaf template or JSON serializer touches them after the controller returns. No &lt;code&gt;LazyInitializationException&lt;/code&gt; — at the cost of holding a DB connection for the lifetime of the HTTP response.&lt;/p&gt;

&lt;p&gt;For a request-response endpoint serving JSON in 50ms, "lifetime of the HTTP response" is 50ms. The connection round-trips back to the pool before the next request even arrives. Pool pressure is essentially zero and the convenience is large enough that the Spring team made it default-on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It's Lethal for SSE
&lt;/h2&gt;

&lt;p&gt;Server-Sent Events flips the lifetime assumption on its head.&lt;/p&gt;

&lt;p&gt;The HTTP response on an SSE endpoint is &lt;em&gt;not&lt;/em&gt; 50ms. It's the entire duration of the open stream — 30 minutes in our case. As long as the tab is open and the stream is live, the response is still being written one event at a time, and the servlet container hasn't released the request or the response.&lt;/p&gt;

&lt;p&gt;OSIV doesn't know any of that. It just sees a request that's still in flight, so the session must still be open, so the connection it borrowed from Hikari must still be held.&lt;/p&gt;

&lt;p&gt;One open SSE stream = one Hikari connection pinned for 30 minutes. The connection isn't being &lt;em&gt;used&lt;/em&gt;. Nothing is running queries on it. It's reserved, sitting idle, unavailable to anyone else, until the response finally ends — at which point the user reconnects with a fresh JWT and the pinned-connection clock resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom Math
&lt;/h2&gt;

&lt;p&gt;Hikari's default &lt;code&gt;maximumPoolSize&lt;/code&gt; is 10. Spring Boot doesn't override it. Most apps don't either, because for a normal API the default is more than enough — connections cycle back in tens of milliseconds and you'd need hundreds of concurrent requests to feel any pressure.&lt;/p&gt;

&lt;p&gt;Now run the math with OSIV + SSE:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Open SSE tabs&lt;/th&gt;
&lt;th&gt;Hikari connections held&lt;/th&gt;
&lt;th&gt;Connections free for other work&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;1&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0 (request 11 waits)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 11th SSE subscriber's request asks OSIV for a connection. There are none. Hikari blocks the request thread on the pool, waiting for one to free up. Nothing will free up for the next 29 minutes and 50 seconds. Eventually Hikari hits its connection-timeout (default 30 seconds) and throws &lt;code&gt;SQLTransientConnectionException: HikariPool-1 - Connection is not available&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cruel part: it's not just the SSE endpoint that hangs. &lt;em&gt;Every&lt;/em&gt; endpoint that needs a DB connection — login, the click handler, the dashboard fetch — competes for the same pool. Ten pinned SSE tabs from a single demo session, and the whole API is wedged. The JVM is alive, nothing is crashing, everything is just queuing on an empty pool. You stare at flat CPU graphs for an hour and feel personally betrayed by your observability stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;One line in &lt;code&gt;application-database.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jpa&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;open-in-view&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That disables the OSIV interceptor. Connections are no longer held for the lifetime of the HTTP response — they're borrowed when a transaction starts, returned when it ends. The 30-minute SSE stream now holds &lt;em&gt;zero&lt;/em&gt; connections for the 30 minutes it's open. Hikari pool is unblocked. The 11th tab works. The 1000th tab works.&lt;/p&gt;

&lt;p&gt;Spring's startup log even warns about OSIV being on by default, but it's easy to scroll past in a noisy log. On a normal MVC app it's not a problem worth acting on. On an app with long-lived reactive endpoints, it absolutely is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Precondition That Makes the Fix Safe
&lt;/h2&gt;

&lt;p&gt;You can't just flip &lt;code&gt;open-in-view: false&lt;/code&gt; and call it done. Turning OSIV off changes a semantic: lazy-loaded JPA associations outside an explicit transaction now throw &lt;code&gt;LazyInitializationException&lt;/code&gt;. The endpoint has to be structured so it doesn't depend on that lazy-load convenience.&lt;/p&gt;

&lt;p&gt;Our SSE endpoint already is, intentionally. Look at where it touches JPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ServerSentEvent&lt;/span&gt;
    &lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unread-count"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationStreamEvent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;unreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;notificationService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUnreadCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- only JPA touch&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one call. It runs synchronously at subscribe-time inside the &lt;code&gt;@Transactional&lt;/code&gt; boundary of &lt;code&gt;NotificationService.getUnreadCount(...)&lt;/code&gt;. The transaction opens, borrows a connection, runs the count query, returns a &lt;code&gt;long&lt;/code&gt;, commits, releases the connection. Total time: a few milliseconds.&lt;/p&gt;

&lt;p&gt;After that, every event flowing through the Flux body — live events from &lt;code&gt;notificationEventBus.subscribe(userId)&lt;/code&gt;, the heartbeat pings, the close after 30 minutes — emits a plain in-memory &lt;code&gt;NotificationStreamEvent&lt;/code&gt; record. No JPA. No lazy proxies. Nothing the persistence context needs to resolve.&lt;/p&gt;

&lt;p&gt;The Javadoc on the endpoint spells out this contract explicitly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pool contract:&lt;/strong&gt; this endpoint relies on &lt;code&gt;spring.jpa.open-in-view=false&lt;/code&gt; (set in &lt;code&gt;application-database.yaml&lt;/code&gt;). All JPA access happens at subscribe time via &lt;code&gt;notificationService.getUnreadCount(...)&lt;/code&gt; inside its own transaction. The Flux body emits the in-memory &lt;code&gt;NotificationStreamEvent&lt;/code&gt; record only — no JPA touch — so no Hikari connection is held for the &lt;code&gt;STREAM_LIFETIME&lt;/code&gt; window. If OSIV is ever re-enabled, every open browser tab pins a connection until the stream closes, exhausting the pool at low concurrency. Do not re-enable OSIV.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That paragraph exists because the fix is one config flag away from being silently undone by a well-meaning future contributor who reads "Spring Boot default" and decides to align. The Javadoc is the load-bearing comment for the entire endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OSIV is a request-response convenience.&lt;/strong&gt; It pays for itself in a 50ms JSON endpoint. It bankrupts you in a 30-minute SSE stream. The cost is "connection held for the lifetime of the HTTP response," and on a long-lived response that's the whole stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hikari's default pool of 10 is generous for request-response and tiny for anything that pins connections.&lt;/strong&gt; The default implicitly assumes you're returning connections quickly. If you're not, you usually only learn this in production at the eleventh concurrent subscriber.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool exhaustion makes &lt;em&gt;everything&lt;/em&gt; hang, not just the slow endpoint.&lt;/strong&gt; The DB pool is shared. SSE pinning it starves every other endpoint. The trail leads back to SSE only after you check pool stats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;spring.jpa.open-in-view: false&lt;/code&gt; is right for any app with long-lived reactive endpoints,&lt;/strong&gt; but only safe if those endpoints don't depend on lazy-loading outside an explicit transaction. Audit first; don't flip the flag blind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the precondition where the bug would re-enter.&lt;/strong&gt; A one-paragraph "Pool contract" Javadoc on the endpoint is the difference between a fix that holds and one someone undoes in six months while "cleaning up config to match Spring defaults."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactive controllers on a servlet stack are a special kind of trap.&lt;/strong&gt; Reactive types make you &lt;em&gt;think&lt;/em&gt; in non-blocking lifetimes; the underlying servlet container is still tracking the HTTP request as in-flight for the full duration, and request-scoped infrastructure like OSIV happily plays along.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Has OSIV bitten you somewhere unexpected?&lt;/strong&gt; Drop the symptom in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>java</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Grafana as Code: Surviving Destroy-Recreate With Provisioning Files</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:43:16 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/grafana-as-code-surviving-destroy-recreate-with-provisioning-files-406c</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/grafana-as-code-surviving-destroy-recreate-with-provisioning-files-406c</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>grafana</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>automation</category>
    </item>
    <item>
      <title>Watchtower Tried to Upgrade Itself. It Didn't Come Back.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:50:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/watchtower-tried-to-upgrade-itself-it-didnt-come-back-15pk</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/watchtower-tried-to-upgrade-itself-it-didnt-come-back-15pk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/watchtower-self-stop-deadlock-pin-versions/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On May 19 we pushed a fix to &lt;code&gt;main&lt;/code&gt;. GitHub Actions built the image, tagged it &lt;code&gt;:latest&lt;/code&gt;, shoved it into GHCR, and went home. Our droplet was supposed to pick that up within sixty seconds. It didn't. Not in sixty minutes, not in two days.&lt;/p&gt;

&lt;p&gt;When we finally SSH'd in to ask why, the answer was in &lt;code&gt;docker ps&lt;/code&gt;: our auto-update tool wasn't running. It had tried to update &lt;em&gt;itself&lt;/em&gt;, succeeded at the "stop" half, and never made it to the "start" half. Nobody noticed for forty-eight hours because the API was up; only the deploy pipeline was silently dead.&lt;/p&gt;

&lt;p&gt;This is the story of that incident and the two-line fix that makes it structurally impossible to repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Watchtower Is For
&lt;/h2&gt;

&lt;p&gt;If you've never used &lt;a href="https://github.com/nicholas-fedor/watchtower" rel="noopener noreferrer"&gt;Watchtower&lt;/a&gt;, it solves a small, useful problem: you push a new image to a registry, and Watchtower running on your host detects the new digest, pulls it, gracefully stops the old container, and starts a fresh one. No SSH, no &lt;code&gt;docker compose pull &amp;amp;&amp;amp; up -d&lt;/code&gt;, no human in the loop. For a tiny team on a single droplet, it's a five-line replacement for an entire CD pipeline. You point it at GHCR, give it the docker socket, and forget it exists.&lt;/p&gt;

&lt;p&gt;Forgetting it exists is exactly the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Incident
&lt;/h2&gt;

&lt;p&gt;Here's what we pieced together after the fact.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;watchtower&lt;/code&gt; service was running &lt;code&gt;:latest&lt;/code&gt; — the version everyone copy-pastes from blog posts. On May 19, upstream pushed a new &lt;code&gt;:latest&lt;/code&gt;. Watchtower, doing its job, polled the registry, saw the new digest, and decided to update &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;To update a container, Watchtower stops it, then starts the replacement. The replacement is launched by Watchtower. Which is the container being stopped.&lt;/p&gt;

&lt;p&gt;You can see the deadlock from orbit. Watchtower issued &lt;code&gt;docker stop&lt;/code&gt; against its own container. The Docker socket connection it was using to issue the "now start the new one" command got severed the instant the container died. The new container never launched. The host was left with no Watchtower at all — just the old API container, happily serving stale code, and no process polling the registry for new builds.&lt;/p&gt;

&lt;p&gt;The comment we eventually added to &lt;code&gt;docker-compose.yml&lt;/code&gt; captures it best:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;image is PINNED, not :latest. On 2026-05-19 Watchtower self-detected an updated :latest, tried to stop itself to upgrade, cut its own Docker-socket connection mid-stop, and never restarted — leaving this droplet on stale code for 2 days until manual intervention. Bump this tag explicitly to upgrade Watchtower itself; do NOT track :latest.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two days. We only caught it because someone noticed a bug fix that was definitely in &lt;code&gt;main&lt;/code&gt; was definitely &lt;em&gt;not&lt;/em&gt; in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Couldn't Recover
&lt;/h2&gt;

&lt;p&gt;Natural question: why didn't &lt;code&gt;restart: unless-stopped&lt;/code&gt; save us? It's right there in the compose file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;restart&lt;/code&gt; policies fire when a container &lt;em&gt;crashes&lt;/em&gt; — exits with a non-zero code, or the daemon restarts. They do not fire when a container is &lt;em&gt;intentionally stopped&lt;/em&gt; via the API. From Docker's perspective, somebody asked Watchtower to stop. It stopped. Nothing to restart. The fact that the "somebody" was Watchtower itself — and that the next command in the sequence never ran because the issuer evaporated — is invisible to the daemon.&lt;/p&gt;

&lt;p&gt;The deeper problem is that nothing was watching the watcher. Every other container on the host had Watchtower as its safety net. Watchtower had nothing. It was the bottom turtle, and when the bottom turtle quietly walks off, the whole stack just sits there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Layer Fix
&lt;/h2&gt;

&lt;p&gt;We fixed it twice, on purpose. The full Watchtower service now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# Watchtower - Auto-updates containers when new images are pushed to GHCR&lt;/span&gt;
  &lt;span class="c1"&gt;# Eliminates need for SSH-based deployments&lt;/span&gt;
  &lt;span class="c1"&gt;# NOTE: Using nickfedor/watchtower fork (containrrr/watchtower is archived&lt;/span&gt;
  &lt;span class="c1"&gt;# and incompatible with Docker 24+)&lt;/span&gt;
  &lt;span class="c1"&gt;#&lt;/span&gt;
  &lt;span class="c1"&gt;# IMPORTANT: image is PINNED, not :latest. On 2026-05-19 Watchtower&lt;/span&gt;
  &lt;span class="c1"&gt;# self-detected an updated :latest, tried to stop itself to upgrade, cut&lt;/span&gt;
  &lt;span class="c1"&gt;# its own Docker-socket connection mid-stop, and never restarted —&lt;/span&gt;
  &lt;span class="c1"&gt;# leaving this droplet on stale code for 2 days until manual intervention.&lt;/span&gt;
  &lt;span class="c1"&gt;# Bump this tag explicitly to upgrade Watchtower itself; do NOT track :latest.&lt;/span&gt;
  &lt;span class="na"&gt;watchtower&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nickfedor/watchtower:1.17.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4-watchtower&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/root/.docker/config.json:/config.json:ro&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_CLEANUP=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_POLL_INTERVAL=60&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_INCLUDE_STOPPED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_REVIVE_STOPPED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_ROLLING_RESTART=false&lt;/span&gt;
      &lt;span class="c1"&gt;# Opt-in mode: only watch containers labeled&lt;/span&gt;
      &lt;span class="c1"&gt;# com.centurylinklabs.watchtower.enable=true (i.e. jo4-api).&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WATCHTOWER_LABEL_ENABLE=true&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the app service we want it to actually watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/${GITHUB_REPOSITORY:-rathnasorg/jo4}/alertstage-api:${APP_VERSION:-latest}&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.centurylinklabs.watchtower.enable=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Pin Watchtower's own image
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;image: nickfedor/watchtower:1.17.0&lt;/code&gt;. Not &lt;code&gt;:latest&lt;/code&gt;. Not &lt;code&gt;:stable&lt;/code&gt;. A specific tag we wrote down on purpose.&lt;/p&gt;

&lt;p&gt;This single change makes the May 19 sequence impossible. When Watchtower asks "is there a newer image for &lt;code&gt;nickfedor/watchtower:1.17.0&lt;/code&gt;?" the answer is, by definition, no. The only way Watchtower gets a new image is when &lt;em&gt;we&lt;/em&gt; edit this file and bump the version — a human in the loop who can verify the new container actually came up.&lt;/p&gt;

&lt;p&gt;(We also moved off &lt;code&gt;containrrr/watchtower&lt;/code&gt; while here — that repo is archived and broke on Docker 24+. &lt;code&gt;nickfedor/watchtower&lt;/code&gt; is the maintained fork.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Opt-in label mode
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;WATCHTOWER_LABEL_ENABLE=true&lt;/code&gt; flips Watchtower from "watch every container on the host" to "watch only containers carrying the label &lt;code&gt;com.centurylinklabs.watchtower.enable=true&lt;/code&gt;." We add that label to &lt;code&gt;jo4-api&lt;/code&gt;. We do &lt;em&gt;not&lt;/em&gt; add it to &lt;code&gt;jo4-watchtower&lt;/code&gt;. The Watchtower container is now structurally invisible to itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Both Layers Matter
&lt;/h2&gt;

&lt;p&gt;You could argue either layer alone is enough. Pin the image, no more updates, no more deadlock. Or use label-mode, Watchtower can't pick itself up regardless of tag. Pick one and move on.&lt;/p&gt;

&lt;p&gt;We didn't, because each layer protects against a different human error six months from now.&lt;/p&gt;

&lt;p&gt;Layer 1 fails the day somebody — maybe one of us, on autopilot — edits the compose file and changes &lt;code&gt;1.17.0&lt;/code&gt; back to &lt;code&gt;:latest&lt;/code&gt;. It's the kind of "cleanup" that looks reasonable in a PR diff. If layer 2 isn't there, that edit reintroduces the exact bug.&lt;/p&gt;

&lt;p&gt;Layer 2 fails the day somebody adds the &lt;code&gt;watchtower.enable=true&lt;/code&gt; label to the Watchtower container itself — maybe they're labelling everything for an inventory script, maybe a tutorial says "label all your containers." If layer 1 isn't there, Watchtower starts watching itself again.&lt;/p&gt;

&lt;p&gt;Either failure alone is a foot. Both simultaneously, on the same PR, by the same engineer, is a foot wrapped in a kevlar boot. Defense in depth is cheap when the depth is two YAML lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons For Any Auto-Update Tool
&lt;/h2&gt;

&lt;p&gt;The Watchtower-specific story generalizes. Anything that auto-updates itself — your dependency bot, your CI runner image, your sidecar agent, your secrets-rotator — has the same shape of bug latent in it. Some patterns we now apply across the board:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin the version of anything that can mutate itself.&lt;/strong&gt; Auto-update tools should not auto-update. Their version belongs in source control where a human has to type the new digit. &lt;code&gt;:latest&lt;/code&gt; is for things being watched, not for things doing the watching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer opt-in over opt-out for blast radius.&lt;/strong&gt; Default-watch-everything tools sweep up the watcher itself, your debug containers, your one-off &lt;code&gt;docker run&lt;/code&gt; experiments. Opt-in lists are slightly more verbose and dramatically harder to misfire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt; is not a safety net for orchestration bugs.&lt;/strong&gt; It catches crashes. It does not catch "I stopped myself on purpose and then died." If something in your stack has the authority to stop itself, give it a watchdog or accept it will walk off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent CD failure is the worst kind.&lt;/strong&gt; The API was up the whole time. Health checks green. Monitoring happy. The only symptom was "fixes don't reach production," which nobody catches until they go looking. Add an alert on "no successful deploy in N hours" — it would have paged us at hour 13 instead of hour 48.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the comment you'd want six months from now.&lt;/strong&gt; The block we added to &lt;code&gt;docker-compose.yml&lt;/code&gt; is the entire postmortem in five sentences. The next operator who looks at that file knows exactly why the version is pinned and what happens if they unpin it. More durable than any wiki page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footgun isn't gone — we still trust an external tool to mutate our running containers. But the specific path where the trust loops back on itself is closed off twice. That's enough.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by a self-updating tool?&lt;/strong&gt; What recovered you? Drop the war story in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics for developers who ship.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
