<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ai_budget]]></title><description><![CDATA[ai_budget]]></description><link>https://nseglins.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 13:47:48 GMT</lastBuildDate><atom:link href="https://nseglins.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I built a budget circuit-breaker for OpenAI using Rails and Fly.io]]></title><description><![CDATA[A Rails proxy that sits in front of OpenAI and stops requests when your budget is hit. One URL, two headers, no SDK. Open source and free to use.]]></description><link>https://nseglins.hashnode.dev/how-i-built-a-budget-circuit-breaker-for-openai-using-rails-and-fly-io</link><guid isPermaLink="true">https://nseglins.hashnode.dev/how-i-built-a-budget-circuit-breaker-for-openai-using-rails-and-fly-io</guid><category><![CDATA[Rails]]></category><category><![CDATA[openai]]></category><category><![CDATA[webdev]]></category><category><![CDATA[showdev]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Nauris Seglins]]></dc:creator><pubDate>Wed, 13 May 2026 11:56:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a0415e052a9c969d60bb2e1/0f3e8685-717c-41ce-a3f9-43bb7e99f04b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<h2>The problem: runaway AI agents and surprise bills</h2>
<p>There's a pattern that keeps showing up in developer communities: someone runs an AI agent overnight, something loops, and they wake up to a bill they didn't expect. A recent case that got attention involved a multi-agent LangChain setup — an analyzer and a verifier stuck in a loop for 11 days, burning $47,000 before anyone noticed. The cost curve looked like normal product growth until it very much wasn't.</p>
<p>The OpenAI platform has soft cap alerts. You can set a notification at 80% of a monthly limit. But that's reactive — it tells you after money is spent, not before. And in an agentic workflow where a loop can double your spend every few hours, "we'll notify you at 80%" is not a safety mechanism. It's a receipt.</p>
<p>What I wanted was simple: a hard stop. Not "warn me when I'm close." Stop the requests. Enforce the number. Make it impossible to overspend by more than one request worth.</p>
<p>So I built a proxy.</p>
<hr />
<h2>What I wanted: a kill-switch, not a dashboard</h2>
<p>The distinction matters. A dashboard gives you visibility. A kill-switch gives you control.</p>
<p>Most cost management tools in the AI space are dashboards — they show you what you spent, maybe break it down by model or endpoint, maybe send an alert. All useful. None of them stop a runaway loop.</p>
<p>What I wanted was a single URL I could point my OpenAI calls at. I'd set a dollar cap when I create a token. Every request would check that cap before it forwarded. When it's hit, requests stop — not with a warning, with a 429. The loop fails. The bill stops.</p>
<p>No dashboard to babysit. No webhook to configure. No "you've reached 80% of your limit" email to check on a Saturday morning. Just: you set a number, and that number holds.</p>
<hr />
<h2>The architecture: one Rails app, one Postgres row</h2>
<p>The whole thing is simpler than it sounds.</p>
<pre><code class="language-plaintext">your app
   │
   ▼
POST /proxy          ← ai_budget proxy (Rails + Fly.io)
   │
   ├── 1. Authenticate caller (Bearer token → ProxyToken row)
   ├── 2. Check budget (row lock → exceeded? → 429 or continue)
   ├── 3. Forward to OpenAI (Faraday, caller's key in-memory)
   ├── 4. Record cost (atomic UPDATE on usage_millicents)
   └── 5. Return response
</code></pre>
<p>The data model is one table:</p>
<pre><code class="language-ruby">create_table "proxy_tokens" do |t|
  t.string  "token",            null: false
  t.string  "email"
  t.string  "label"
  t.integer "limit_millicents", default: 0, null: false
  t.integer "usage_millicents", default: 0, null: false
  t.boolean "revoked",          default: false, null: false
end
</code></pre>
<p>Amounts are stored in millicents (1/100,000 of a dollar) to avoid floating-point arithmetic entirely. A $10 cap is stored as <code>1_000_000_000</code>. When you hit the limit, <code>usage_millicents &gt;= limit_millicents</code> and the request stops.</p>
<p>No Redis. No Sidekiq. No pub/sub. One Postgres row per token, one request path, one machine on Fly.io in <code>ams</code>.</p>
<hr />
<h2>The two parts that mattered: atomic enforcement and key passthrough</h2>
<h3>Atomic enforcement</h3>
<p>The tricky part of a budget proxy isn't checking the budget — it's checking it correctly under concurrent requests.</p>
<p>Here's the naive version of the problem: two requests arrive simultaneously. Both read <code>usage_millicents = 9_950_000</code> against a <code>limit_millicents = 10_000_000</code> cap. Both see "not exceeded." Both forward to OpenAI. Both come back and increment the counter. You end up at <code>10_050_000</code> — over budget, but the check already passed.</p>
<p>The fix is a row lock. Before checking, acquire a lock on the row. Any concurrent request for the same token has to wait until the lock releases before it can read. This serialises the check, so the second request sees the updated value:</p>
<pre><code class="language-ruby">def enforce_budget
  exceeded = @current_token.with_lock { @current_token.exceeded? }
  return unless exceeded

  render(
    json: { errors: [{ status: 429, detail: "Budget exceeded" }] },
    status: :too_many_requests
  )
end
</code></pre>
<p>For recording usage after a response comes back, I use <code>update_counters</code> — a single atomic SQL <code>UPDATE</code> that increments the column in the database rather than reading, modifying, and writing back in application code:</p>
<pre><code class="language-ruby">def record_usage!(millicents)
  self.class.update_counters(id, usage_millicents: millicents)
end
</code></pre>
<p>This means the increment is safe even if multiple requests finish at the same time.</p>
<h3>Key passthrough</h3>
<p>The second design decision worth explaining: I never store your OpenAI API key.</p>
<p>The proxy uses two separate authorization headers. The first authenticates you to the proxy:</p>
<pre><code class="language-plaintext">Authorization: Bearer &lt;your_proxy_token&gt;
</code></pre>
<p>The second carries your OpenAI key:</p>
<pre><code class="language-plaintext">X-Provider-Authorization: Bearer &lt;your_openai_key&gt;
</code></pre>
<p>The proxy reads <code>X-Provider-Authorization</code> from the incoming request, holds it in memory, and passes it directly to OpenAI on the forwarded request. It is never written to the database, never logged, never persisted anywhere. If you grep the codebase for where the provider key is stored — it isn't.</p>
<p>This has a real tradeoff: you have to send your OpenAI key on every request. That's a deliberate choice. The alternative — storing it server-side — is a security liability I wasn't willing to accept for a tool designed to proxy sensitive API credentials.</p>
<hr />
<h2>Production gotchas</h2>
<h3>The SSL crash</h3>
<p>The first latency run I did locally gave sensible numbers. The first run against the Fly deployment failed immediately with a redirect loop.</p>
<p>The cause: <code>force_ssl = true</code> in Rails sends an HTTP-to-HTTPS redirect for every plain HTTP request. Fly.io terminates TLS at its edge and forwards traffic to the app over plain HTTP on the internal network. So the proxy was redirecting Fly's own healthcheck — and everything else — to HTTPS, which Fly had already handled.</p>
<p>The fix is two config lines:</p>
<pre><code class="language-ruby"># production.rb
config.assume_ssl = true   # treat incoming requests as HTTPS even though they arrive over HTTP
config.force_ssl = true    # still enforce HTTPS, but now correctly understands it's already on HTTPS
config.ssl_options = {
  redirect: { exclude: -&gt;(request) { request.path == "/up" } }
}
</code></pre>
<p><code>assume_ssl</code> tells Rails the connection is already secure. The health check exclusion stops Fly's <code>/up</code> checks getting a 301 instead of a 200.</p>
<h3>open_timeout vs read_timeout</h3>
<p>Faraday has two distinct timeout settings and they fail in completely different ways.</p>
<p><code>open_timeout</code> is the time to establish the TCP connection and complete the SSL handshake. If OpenAI is unreachable, this fires and fails fast. Five seconds is plenty.</p>
<p><code>read_timeout</code> (Faraday's <code>timeout</code>) is the time to receive the response body after the connection is open. For <code>gpt-4o-mini</code>, typical latency is 700–900ms. For a slower model or a long prompt, it can be several seconds. Thirty seconds is the right number here — tight enough to detect a hanging request, loose enough not to cut off legitimate calls.</p>
<pre><code class="language-ruby">CONNECTION = Faraday.new(
  request: { open_timeout: 5, timeout: 30 }
) do |conn|
  conn.adapter :net_http_persistent
end
</code></pre>
<h3>Persistent connections</h3>
<p>The <code>net_http_persistent</code> adapter reuses TCP and TLS sessions across requests. Without it, every proxy call pays the handshake overhead — around 50–100ms for TLS on a fresh connection. With it, that cost is paid once and amortised across all subsequent requests to the same host.</p>
<p>The <code>CONNECTION</code> object is a class-level constant so it's shared across Puma threads. The key detail: per-request headers (including the caller's OpenAI key) are passed to <code>.post</code> at call time, not configured on the connection. The connection is credential-agnostic — only the request is not.</p>
<hr />
<h2>Numbers</h2>
<p>Measured today on <code>gpt-4o-mini</code>, single-turn chat completion, from Fly <code>ams</code> to OpenAI:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>p50 latency</td>
<td>951ms</td>
</tr>
<tr>
<td>p95 latency</td>
<td>1678ms</td>
</tr>
<tr>
<td>Mean</td>
<td>~1100ms</td>
</tr>
<tr>
<td>Proxy overhead</td>
<td>~50ms</td>
</tr>
</tbody></table>
<p>The proxy adds about 50ms. The rest is OpenAI.</p>
<p>What isn't measured: multi-turn conversations, large prompts, models other than <code>gpt-4o-mini</code>, or latency under concurrent load. At this scale — one machine, low traffic — the numbers are stable. At higher concurrency, the row-lock serialisation in <code>enforce_budget</code> will add measurable wait time for tokens with many simultaneous callers. That's a known tradeoff; at the scale this is useful for, it doesn't matter.</p>
<hr />
<h2>What I left out on purpose</h2>
<p><strong>Streaming.</strong> The proxy handles non-streaming chat completions only. Streaming budget enforcement is a different problem — you need to enforce the cap on token counts as they flow, which doesn't fit Rails' request/response model cleanly. That's a Bun sidecar I'll build when people start asking for it.</p>
<p><strong>Dashboard.</strong> There's no UI. You create tokens with a curl command, check usage with another. For a tool aimed at developers, a console interface is fine at launch. A React dashboard is on the roadmap but not on the critical path.</p>
<p><strong>Multi-region.</strong> The proxy runs in one Fly region (<code>ams</code>). Row-level locking on a single Postgres instance is the right choice for now — consistent, simple, auditable. Multi-region would require distributed coordination that adds real complexity for a marginal latency gain at this scale.</p>
<p><strong>Soft-cap alerts.</strong> No "you've reached 80%" email. Deliberate omission — alerts require infrastructure (email sending, scheduling) and they don't solve the underlying problem. The hard cap is the product.</p>
<p>Each of these is a next decision, not a current one. Shipping a narrow tool that works cleanly is more useful than shipping a broad one that half-works.</p>
<hr />
<h2>Try it</h2>
<p>The proxy core is 100% open source. You can read exactly how the budget logic works before routing your first request.</p>
<ul>
<li><p>Code: <code>github.com/naurisSeglins/ai_budget_proxy?utm_source=hashnode</code></p>
</li>
<li><p>Live: <code>ai-budget-proxy.fly.dev/?utm_source=hashnode</code></p>
</li>
<li><p>30-second quickstart in the README</p>
</li>
</ul>
<p>One URL, two headers, no SDK to install. Set a cap. When you hit it, requests stop.</p>
]]></content:encoded></item></channel></rss>