>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

Turso SERVER_ERROR: 404 Right After Creating a Database

You just created a Turso database, the Platform API confirmed it exists, and the very next query returns SERVER_ERROR: Server returned HTTP status 404. The error is real, it lasts a couple of seconds, and it has nothing to do with your credentials.

This bites hardest in serverless code, where "create, then immediately use" is a natural pattern — a Cloudflare Worker that provisions a database and runs its first migration in the same request. The failure mode is documented in an open issue on tursodatabase/libsql-client-ts (tracked since June 2026): the database is reported as created before it is actually reachable over HTTP.

The error

The failure is a 404 with a SERVER_ERROR prefix, returned by the database HTTP endpoint for roughly 2.5 seconds after the Platform API reports success. A naive first query looks like this — and races the provisioning step:

// ❌ Races the create→serve gap
const res = await fetch(`https://${db}-${org}.turso.io/v2/pipeline`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    requests: [{ type: "execute", stmt: { sql: "SELECT 1" } }],
  }),
});
// → HTTP 404 "SERVER_ERROR: Server returned HTTP status 404"

Why it happens

Turso databases are files, not processes — but provisioning a new database still involves moving that file into the serving path. The Platform API (which answers your POST /v1/organizations/.../databases call) and the database HTTP endpoint (POST /v2/pipeline) are separate services, and there is no readiness signal exposed between them. The create call returns success before the pipeline endpoint can serve the new database.

This is a consistency gap, not a bug in your code: the same query succeeds a moment later. The production-grade fix is not to pray and retry — it is to treat readiness as a first-class state and poll for it with backoff, exactly as you would for any eventually-consistent dependency.

The fix — a readiness poll with backoff

Probe the database's own HTTP endpoint with a harmless SELECT 1, retry with exponential backoff, and — critically — surface real errors instead of masking them. Only 404 is "not ready yet"; anything else is a genuine failure you need to see:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export const waitForDatabase = async (
  dbUrl,
  token,
  { attempts = 10, baseDelayMs = 150, maxDelayMs = 3000 } = {},
) => {
  for (let i = 0; i < attempts; i++) {
    let res;
    try {
      res = await fetch(`${dbUrl}/v2/pipeline`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          requests: [{ type: "execute", stmt: { sql: "SELECT 1" } }],
        }),
      });
    } catch {
      // transient network failure (DNS propagation, connection reset) — retryable, like 404
      await sleep(Math.min(baseDelayMs * 2 ** i, maxDelayMs));
      continue;
    }
    if (res.ok) return; // database is serving
    if (res.status !== 404) {
      // Real failure — surface it, never swallow it
      throw new Error(`pipeline readiness check failed: HTTP ${res.status} ${await res.text()}`);
    }
    // 404 = still provisioning → exponential backoff with a ceiling
    await sleep(Math.min(baseDelayMs * 2 ** i, maxDelayMs));
  }
  throw new Error(
    `database not ready after ${attempts} attempts — check the HTTP URL and org slug`,
  );
};

Using it from a Cloudflare Worker with Hono

Wire it into your provisioning route so the create→serve gap is absorbed before the first real query. The same function works in any Worker, Hono handler, or plain Node script — it is dependency-free by design:

import { Hono } from "hono";
import { waitForDatabase } from "./readiness";

const app = new Hono<{
  Bindings: { TURSO_URL: string; TURSO_AUTH_TOKEN: string };
}>();

app.post("/provision", async (c) => {
  // ... create the database via the Platform API here ...
  await waitForDatabase(c.env.TURSO_URL, c.env.TURSO_AUTH_TOKEN);

  const res = await fetch(`${c.env.TURSO_URL}/v2/pipeline`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${c.env.TURSO_AUTH_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      requests: [{ type: "execute", stmt: { sql: "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, at TEXT)" } }],
    }),
  });
  if (!res.ok) throw new Error(`first query failed: HTTP ${res.status}`);

  return c.json({ ok: true }, 201);
});

Two production notes. First, keep the readiness probe's timeout bounded — a hung fetch should fail the request rather than pin a Worker. Second, every response your Worker returns should carry security headers (X-Content-Type-Options: nosniff, X-Frame-Options, and a Content-Security-Policy where applicable); provisioning endpoints are prime reconnaissance targets.

Prevention checklist

  • Create → poll → use. Never query a freshly created database without the readiness gate.
  • Confirm the HTTP URL with turso db show <name> --http-url before wiring secrets — a wrong org slug produces the same 404 shape.
  • Scope tokens to the organization; never log them, never put them in client code.
  • Re-verify after Turso upgrades — the provisioning path has changed before and will change again.

Sources

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.