# Real Ephemeris Math, Graceful AI Degradation, and a Rebuild Gotcha: Building an Astrology SaaS on Cloudflare Workers

> Source: <https://dev.to/amritanshu_gaurav_8c70b9e/real-ephemeris-math-graceful-ai-degradation-and-a-rebuild-gotcha-building-an-astrology-saas-on-308i>
> Published: 2026-08-17 17:00:27+00:00

Most "astrology app" tech stacks are a sun-sign lookup table and a template string. I've spent the last few months building [AstroMystra](https://astromystra.com), a Next.js app that does actual ephemeris calculations for Vedic (sidereal) and Western (tropical) charts, generates AI-written readings from those calculations, and — as of a few weeks ago — runs entirely on Cloudflare Workers instead of Vercel.

Three engineering problems from that build were interesting enough to write up: how to get real planetary positions without a C++ ephemeris library, how to make an LLM-dependent product survive a 15-requests-per-minute free tier, and what actually breaks when you move a Next.js app to Cloudflare Workers.

Vedic (sidereal) astrology and Western (tropical) astrology disagree about where the zodiac starts, by a slowly-growing offset called the **ayanamsa** — currently a little under 24°. Get this wrong and every single house and sign placement in a Vedic chart is wrong.

The planetary positions themselves come from [ astronomy-engine](https://github.com/cosinekitty/astronomy), a real numerical-integration ephemeris library (this is the same one used by Astronomy.com), which gives tropical (Earth-centered, equinox-referenced) longitudes. To get sidereal positions for Vedic charts, you subtract the ayanamsa.

The "correct" way to compute Lahiri ayanamsa involves modeling lunar/solar nutation — an 18.6-year wobble cycle. That's overkill for sign and nakshatra-level astrology (nobody's natal Moon changes sign because of a 17-arcsecond wobble), so this is a documented linear approximation instead:

```
/**
 * Lahiri (Chitrapaksha) ayanamsa: linear approximation anchored at
 * J2000.0 (23.85333°) with a precession rate of 0.0139289°/year,
 * derived from the published historical table (1900–2025).
 *
 * True Lahiri ayanamsa has a small non-linear wobble from nutation
 * (~18.6-year cycle, ~17 arcsecond amplitude) that this linear model
 * does not capture — acceptable for sign/nakshatra-level astrology,
 * not for arcsecond-precision research use.
 */
const J2000_AYANAMSA_DEG = 23.85333;
const RATE_DEG_PER_YEAR = 0.0139289;

export function lahiriAyanamsa(date: Date): number {
  const yearsFromJ2000 = decimalYear(date) - 2000;
  return J2000_AYANAMSA_DEG + yearsFromJ2000 * RATE_DEG_PER_YEAR;
}
```

The lesson here generalizes past astrology: know exactly which precision tier your domain actually needs, document the corner you're cutting and why, and don't drag in a heavier dependency (or a slower calculation) for accuracy nobody downstream can use. A comment that says "this is a linear approximation, here's the error bound, here's why it doesn't matter for this use case" is worth more than either silently doing it wrong or over-engineering it.

Every reading — birth chart, compatibility, daily horoscope — is written by an LLM from the calculated chart data. The free-tier Gemini API this runs on is capped at 15 requests per minute, which is fine for a low-traffic app until it very suddenly isn't (a cron job, a traffic spike, whatever).

Two things handle this:

**Model fallback with a typed overload error.** Instead of a generic try/catch, overload conditions get their own error class, and the call site tries a cheaper/lighter model before giving up:

```
export class GeminiOverloadedError extends Error {
  constructor() {
    super("Gemini API rate limit exceeded on all models");
    this.name = "GeminiOverloadedError";
  }
}

async function geminiChat(systemPrompt: string, userPrompt: string, lang = "en") {
  const MODELS = ["gemini-2.5-flash", "gemini-2.5-flash-lite"];

  for (const modelName of MODELS) {
    try {
      const model = gemini.getGenerativeModel({ model: modelName, systemInstruction: systemPrompt });
      const result = await model.generateContent(userPrompt);
      return result.response.text();
    } catch (err) {
      const status = (err as any)?.status ?? (err as any)?.response?.status;
      if (status === 503 || status === 429 || status === 500) continue; // try next model
      throw err; // not an overload — don't swallow real bugs
    }
  }
  throw new GeminiOverloadedError();
}
```

That `GeminiOverloadedError`

bubbles up to the API route, which returns a 503 instead of a 500, and the frontend has a dedicated `AiBusyBanner`

that shows a "high demand, try again shortly" state instead of a raw error. Distinguishing "the upstream is overloaded" from "our code is broken" as a *type*, not a string match on an error message, is what makes it possible to route these two failure modes completely differently in the UI without the routes and the banner component silently drifting out of sync over time.

**Pre-warming with a rate budget, not just a queue.** A daily cron job generates all 12 zodiac sign horoscopes ahead of traffic. Naively firing 12 requests at once would blow straight through the 15 RPM ceiling. Instead:

```
// Sequential with 4s gap — 12 signs × 4s = ~48s total, stays within 15 RPM free limit
```

Nothing clever — just doing the arithmetic on the actual constraint (15/min → one request every 4s is safely under that with margin) instead of reaching for a rate-limiting library for a problem that's really just "wait a bit between 12 sequential calls."

There's also a second, paid model tier (GPT-5 Mini via OpenRouter) reserved for a premium plan, kept completely separate from the free-tier Gemini path — different failure domain, different cost profile, no reason to share a code path just because both "generate text."

The migration itself, via `@opennextjs/cloudflare`

, was mostly straightforward — `next build`

differences aside, App Router deploys cleanly to Workers. Two things were genuinely worth knowing in advance:

**Hyperdrive wants the unpooled database connection.** The Postgres instance sits behind Supabase's own connection pooler by default. Cloudflare's Hyperdrive does its own edge-side pooling, so pointing it at Supabase's pooled (pgbouncer) endpoint means stacking two poolers — which mostly works until it doesn't, under exactly the kind of connection-exhaustion conditions you'd rather not debug in production. The fix is boring: point Hyperdrive at the

** opennextjs-cloudflare deploy does not rebuild your app.** This one cost a genuinely confusing 20 minutes. The deploy command uploads whatever's already sitting in

`.open-next/`

— it does not invoke `next build`

first. Ship a code change, forget to run `cf:build`

immediately before `cf:deploy`

, and you get a deploy that reports success while quietly serving five-day-old content. No error, no warning — the routes that exist in both builds serve fine, and only routes that are new since the last real build 404. If a Cloudflare deploy "succeeds" but a brand-new route 404s, check your `.open-next/`

build timestamp before you go looking for a routing bug that isn't there.None of these three problems are exotic — approximate a slowly-varying astronomical constant, degrade gracefully under a rate limit, don't trust a deploy tool's own "done" message — but they're the kind of thing that's easy to get subtly wrong in a way that looks fine until a specific edge case hits it in production. Writing the actual constraint down next to the code that handles it (the ayanamsa comment, the typed error class, the RPM-budget comment) turned out to matter more than any particular library choice.

If you want to see where all this lands for an actual user: [astromystra.com](https://astromystra.com) — Vedic and Western birth charts, AI-generated readings in English and Hindi, all built on the stack above.
