TEJHQ
All posts
·7 min read·TejHQ

Serving a free API without an origin: pre-rendered JSON on R2

How the keyless tier of api.tejhq.dev now answers from a Cloudflare Worker and an R2 read, never from Cloud Run. The pre-render step, the in-memory date slice, the cache rules, and the guardrails that keep the bill at zero.

#cloudflare#r2#engineering#infrastructure

Every request to /v1/ohlcv, /v1/snapshot and /v1/actions on the keyless tier now comes back with X-Served-By: edge-r2. That header means the request was answered by a Cloudflare Worker that read one JSON object from R2 and sliced it in memory. It never reached Cloud Run, never opened DuckDB, never cost us a CPU-second.

This post is about why we did that, how the shapes stay byte-identical to the Go origin, and the small set of guardrails around it. The short version: a free tier is only free if the marginal request costs nothing, and a Go binary querying parquet over HTTPS is not nothing.

What a keyless request used to cost

Cloud Run scales to zero, which is great until the first request of the morning. A cold instance has to start the Go binary, open DuckDB, then read parquet footers from R2 before it can answer anything. A single /v1/ohlcv/nse/RELIANCE for 90 days is about a second warm and several seconds cold. Fine for a demo. Not fine when a notebook fans out 50 symbols and each one pays for a DuckDB scan.

The observation that changed the design: the free-tier responses are pure functions of yesterday's bhavcopy. A row for 2024-03-14 never changes. There is no reason to compute it on demand.

Pre-render in the pipeline

The nightly cron gained an export-json step after the parquet rollup. It writes one file per symbol for OHLCV, one file per date for snapshots, one file per symbol for corporate actions:

r2-layout.txt
api/v1/ohlcv/nse/RELIANCE.json      {"data": [rows by date asc, 2010 -> today]}
api/v1/snapshot/nse/2026-08-12.json {"data": [rows by symbol asc]}
api/v1/actions/RELIANCE.json        {"data": [rows by ex_date desc]}

The shapes mirror the Go wire models field for field, including the omitempty quirks. A corporate action with no ratio_num omits the key rather than writing null, because that is what encoding/json does on the origin:

export_json.py
# Go marshals these with omitempty; drop when null so the JSON is identical.
ACTION_OPTIONAL = {
    "record_date", "ratio_num", "ratio_den", "cash_amount",
    "face_value_from", "face_value_to",
}

rows = [
    {k: v for k, v in row.items() if not (k in ACTION_OPTIONAL and v is None)}
    for row in part.to_dicts()
]

Snapshots are only re-exported for the current year, since a past date's bhavcopy is immutable. Per-symbol OHLCV files are rewritten every run because every run appends a row. That is a lot of PUTs, which is where the second half of the export matters.

Publishing without re-uploading everything

R2's ETag for a single-part PUT is the MD5 of the body. So the publisher hashes each local file, compares against the remote ETag, and only uploads the ones that differ. On a normal day that is today's snapshot plus every symbol that traded.

The first version did one HEAD per object to fetch the ETag. With many thousands of JSON files that alone took longer than the 20 minute job timeout, and the seed run got cancelled mid-upload. Two changes: above 200 objects we do a single prefix listing instead of per-key HEADs, and the job timeout went to 45 minutes with an alert on cancellation, not only on failure.

publish_r2.py
LIST_THRESHOLD = 200  # above this, one prefix listing beats per-key HEADs

def _filter_uploads(s3, bucket, plan):
    if len(plan) > LIST_THRESHOLD:
        remote = _list_etags(s3, bucket, _common_prefix([k for _, k, _, _ in plan]))
        return [(p, k, md5, c) for p, k, md5, c in plan if remote.get(k) != md5]
    ...  # small plans: parallel HEADs

The Worker

The Worker in front of api.tejhq.dev matches the three routes, validates the same way the Go handlers do, reads the object, and slices by date with a binary search. Rows are sorted by date and ISO strings compare lexically, so no parsing:

lib.js
export function sliceByDate(rows, from, to) {
  let lo = 0, hi = rows.length;
  while (lo < hi) { const m = (lo + hi) >> 1; if (rows[m].date < from) lo = m + 1; else hi = m; }
  const start = lo;
  hi = rows.length;
  while (lo < hi) { const m = (lo + hi) >> 1; if (rows[m].date <= to) lo = m + 1; else hi = m; }
  return rows.slice(start, lo);
}

Anything the Worker cannot answer falls through to Cloud Run unchanged. That includes a symbol that listed today and has no JSON yet, a holiday date with no snapshot, a malformed date string (the origin owns the canonical error text), and every keyed or non-GET request. The behaviour a client sees is identical either way; only the X-Served-By header differs.

Cache rules are the same as the origin's. If the range ends more than three days ago the response is immutable and cached for a year. If it touches recent days it gets a short TTL, because a late correction from the exchange is rare but real.

curl.sh
$ curl -sD - -o /dev/null "https://api.tejhq.dev/v1/ohlcv/nse/RELIANCE?from=2024-01-01&to=2024-12-31" \
    | grep -i "x-served-by\|cache-control\|cf-cache-status"
x-served-by: edge-r2
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT

Guardrails, because free invites abuse

Three layers, cheapest first. None of them are clever; all of them are things we should have had on day one.

  • Cloudflare rate-limit rule. 100 requests per 10 seconds per IP on /v1/*. This is the hard edge and it costs nothing to enforce.
  • Per-IP token bucket in the Go origin. Defence in depth for whatever falls through, keyed on the real client IP that the Worker forwards explicitly, since CF-Connecting-IP is not guaranteed to survive a subrequest to a non-Cloudflare origin.
  • A GCP budget alert at ₹1,000 a month. It does not stop the service. It emails us, which is the point: the Cloud Run bill is supposed to be near zero, and a spike means something is bypassing the edge.

Since the switch, the only traffic that reaches Cloud Run on the free tier is what the Worker declines: a symbol with no JSON yet, a bad date, and anything with a key. The budget alert has not fired.

What you can do with it

Nothing new, which is the intended outcome. Same URLs, same JSON, same limits. Fan out 50 symbols from a notebook and every one of them should come back in well under a second from wherever you are. If you see X-Served-By: origin on one of the three free endpoints for a date older than yesterday, that is a gap in the pre-render and we would like to hear about it at github.com/tejhq/tej-api.