DEV Community

Avery Lin
Avery Lin

Posted on

One Sale Survives the Restart

A solo founder closes the laptop after midnight. A stranger has paid nineteen dollars for a pack. The receipt write dies when the free process restarts.

The provider sends that same event a second time. The new process has no memory of the first write. Two receipt emails leave for one card charge.

That failure is not a clever model mistake. The handler treated a replay as a fresh sale. A restart is normal on a small free box.

A zero bill still needs a memory

An indie launch still wants a public URL today. The bill for that first night should stay at zero. The limit is real, and the design must accept it.

The practical gap shows up in ordinary launch code. A drafted handler can look finished on first read. Retry and restart behavior still belongs to the software.

A free coding model can sketch the route quickly. It cannot promise that a killed process stays honest. The founder needs a ledger before any customer link.

The useful split is small, boring, and local. Local tests own the money rule before any deploy. The server only hosts a process that already passed.

The rule is one sale and one receipt. The key joins the provider event and the order. A second delivery returns the stored result only.

SQLite fits a zero-bill night better than a hosted queue. The file lives beside the app and restarts with it. A bigger broker can wait until revenue pays for it.

The contract, not the framework

The snippet below is an unexecuted local example. The example uses the standard library plus pytest. The point is the contract, not the framework fashion.

# Unexecuted example: one sale, one receipt.
# Not a claim that this file was executed on a host.
import hashlib
import hmac
import json
import os
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

DB_PATH = Path(os.environ.get("LEDGER_PATH", "ledger.sqlite3"))

def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS receipts (
            event_key TEXT PRIMARY KEY,
            order_id TEXT NOT NULL,
            amount_cents INTEGER NOT NULL,
            status TEXT NOT NULL
        )
        """
    )
    return conn

def event_key(provider_event_id, order_id):
    raw = f"{provider_event_id}:{order_id}".encode()
    return hashlib.sha256(raw).hexdigest()

def signature_ok(secret, body, header):
    digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest, header or "")

def record_payment(provider_event_id, order_id, amount_cents):
    key = event_key(provider_event_id, order_id)
    conn = connect()
    try:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute(
            """
            INSERT OR IGNORE INTO receipts
                (event_key, order_id, amount_cents, status)
            VALUES (?, ?, ?, 'accepted')
            """,
            (key, order_id, amount_cents),
        )
        row = conn.execute(
            "SELECT status, amount_cents FROM receipts WHERE event_key = ?",
            (key,),
        ).fetchone()
        conn.commit()
        status, stored_amount = row
        if stored_amount != amount_cents:
            return "amount_mismatch"
        return status
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

def mark_mailed(provider_event_id, order_id):
    key = event_key(provider_event_id, order_id)
    conn = connect()
    try:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute(
            """
            UPDATE receipts
               SET status = 'mailed'
             WHERE event_key = ?
               AND status = 'accepted'
            """,
            (key,),
        )
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

def apply_event(provider_event_id, order_id, amount_cents, send_mail):
    status = record_payment(provider_event_id, order_id, amount_cents)
    if status in ("amount_mismatch", "mailed"):
        return status
    send_mail(order_id, amount_cents)
    mark_mailed(provider_event_id, order_id)
    return "mailed"

def send_mail(order_id, amount_cents):
    # Stand-in for an idempotent mail call keyed by event id.
    print(f"mail order={order_id} amount_cents={amount_cents}")

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/health":
            self.send_response(404)
            self.end_headers()
            return
        body = b'{"ok":true}'
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if self.path != "/hooks/payment":
            self.send_response(404)
            self.end_headers()
            return
        length = int(self.headers.get("Content-Length", "0"))
        raw = self.rfile.read(length)
        secret = os.environ.get("HOOK_SECRET", "")
        if not secret:
            self.send_response(500)
            self.end_headers()
            return
        header = self.headers.get("X-Signature", "")
        if not signature_ok(secret, raw, header):
            self.send_response(401)
            self.end_headers()
            return
        payload = json.loads(raw or b"{}")
        status = apply_event(
            payload["event_id"],
            payload["order_id"],
            int(payload["amount_cents"]),
            send_mail,
        )
        body = json.dumps({"status": status}).encode()
        code = 409 if status == "amount_mismatch" else 200
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

def main():
    if not os.environ.get("HOOK_SECRET"):
        raise SystemExit("HOOK_SECRET is required")
    host = os.environ.get("HOST", "127.0.0.1")
    port = int(os.environ.get("PORT", "8080"))
    ThreadingHTTPServer((host, port), Handler).serve_forever()

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The function opens one short transaction per event. Insert or ignore treats the key as a lock. A second writer finds the row instead of a new sale.

The stored amount must match the replayed amount. A mismatch returns a stop status and sends no mail. Silence is safer than a creative explanation to the buyer.

Mail should leave only after the row exists. The send sits outside the database lock on purpose. A slow mailbox must not freeze the second webhook.

A crash can still sit between send and update. The next replay may send the receipt one more time. That is at least once delivery, not a magic once.

The mailer should key on the same event id. A second send with that key becomes a no-op. Without that key, the ledger cannot save the inbox.

The status names stay dull for a reason. Accepted means the sale is stored and mail is pending. Mailed means the buyer path already ran to completion.

A founder should not invent extra states tonight. Paid, pending, and success overlap in ugly ways. Two words are enough until a real refund appears.

Refunds do not belong in this first handler. A refund is a new event with its own key. Mixing it into the sale row hides a second money movement.

The signature check belongs in front of the ledger. A bad signature never touches the database file. The comparison uses a constant-time helper, not a raw equal sign.

The secret comes from the process environment only. It never sits in the repository or the prompt. A free model does not need the live signing key.

The handler in that file is also an unexecuted example. It accepts one JSON post and returns a status. It does not speak to a real card network.

Tests before any public URL

The test uses a temporary database path on purpose. The first call must insert exactly one row. The second call must not insert another row.

# Unexecuted example test. It documents the contract.
import sqlite3
import ledger

def test_replay_keeps_one_row(tmp_path, monkeypatch):
    monkeypatch.setattr(ledger, "DB_PATH", tmp_path / "ledger.sqlite3")
    sends = []

    def fake_mail(order_id, amount_cents):
        sends.append((order_id, amount_cents))

    first = ledger.apply_event("evt_1", "ord_9", 1900, fake_mail)
    second = ledger.apply_event("evt_1", "ord_9", 1900, fake_mail)
    assert first == "mailed"
    assert second == "mailed"
    assert sends == [("ord_9", 1900)]
    conn = sqlite3.connect(tmp_path / "ledger.sqlite3")
    count = conn.execute("SELECT COUNT(*) FROM receipts").fetchone()[0]
    assert count == 1

def test_amount_mismatch_stops(tmp_path, monkeypatch):
    monkeypatch.setattr(ledger, "DB_PATH", tmp_path / "ledger.sqlite3")
    ledger.apply_event("evt_1", "ord_9", 1900, lambda o, a: None)
    status = ledger.record_payment("evt_1", "ord_9", 2000)
    assert status == "amount_mismatch"

def test_crash_before_mark_sends_again(tmp_path, monkeypatch):
    monkeypatch.setattr(ledger, "DB_PATH", tmp_path / "ledger.sqlite3")
    sends = []

    def fake_mail(order_id, amount_cents):
        sends.append(order_id)

    status = ledger.record_payment("evt_2", "ord_2", 1900)
    assert status == "accepted"
    fake_mail("ord_2", 1900)  # process dies before mark_mailed
    again = ledger.apply_event("evt_2", "ord_2", 1900, fake_mail)
    assert again == "mailed"
    assert sends == ["ord_2", "ord_2"]
Enter fullscreen mode Exit fullscreen mode

The mail callback counts sends inside the test. One replay after a clean mail should not send again. A forced crash before the status update should send once more.

That second case is the honest failure window. The test should fail if someone deletes it later. A green suite that hides the window is theater.

Commands stay plain so a tired founder can rerun them. Pytest is the gate before any public URL. Curl is only a smoke check after the gate is green.

python -m pytest -q test_ledger.py
export HOOK_SECRET=dev-only-secret
export LEDGER_PATH=./ledger.sqlite3
python ledger.py
Enter fullscreen mode Exit fullscreen mode
export HOOK_SECRET=dev-only-secret
export BODY='{"event_id":"evt_1","order_id":"ord_9","amount_cents":1900}'
export SIG=$(python -c 'import hmac,hashlib,os; print(hmac.new(os.environ["HOOK_SECRET"].encode(), os.environ["BODY"].encode(), hashlib.sha256).hexdigest())')
curl -s -X POST http://127.0.0.1:8080/hooks/payment \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
curl -s -X POST http://127.0.0.1:8080/hooks/payment \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
sqlite3 ledger.sqlite3 "SELECT COUNT(*) FROM receipts;"
cp ledger.sqlite3 ledger.sqlite3.bak
Enter fullscreen mode Exit fullscreen mode

The first curl should return the mailed status. The second curl should return the same status. The count query should print the number one.

If the count prints two, the key is wrong. If the second curl sends mail, the status update was skipped. Those two symptoms are the whole debugging map.

Logs should print the event id and the status. Logs should not print the body or the secret. A free box log is still a copy of private data.

Draft on a free model, host after green

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can draft the handler from the tests. Its free server option can host the process after those tests pass.

The model is a drafter, not the source of truth. The founder pastes the failing test first and asks for a narrow fix. The founder rejects any draft that changes the assertions to get green.

That order matters more than the wording of the prompt. A model that sees only the route will agree too fast. The test is the disagreement the founder actually needs.

A founder should not paste live customer rows into the chat. Fake event ids and a nineteen dollar fixture are enough. Real buyer data does not improve this particular lesson.

Quotas, model names, and hardware can change without notice. This article does not claim a token count or a machine size. The founder reads the current product terms before a launch depends on them.

Where this pattern stops

Disk choice matters more than the model choice. This ledger works only if the file survives a restart. An ephemeral disk forgets the sale and breaks the rule.

The founder confirms that fact on the chosen host before launch. A founder should not assume a free plan keeps files forever. If the disk resets, the file moves to durable storage.

A sleeping process is a different kind of limit. The first buyer after sleep may wait on a cold start. The handler should return the stored row, not a new sale.

Timeouts from the provider need a calm response code. A slow handler gets another delivery, which this design expects. A crash loop is different, and bad config should exit.

Bad config means a missing secret or a missing path. The process should refuse to boot in that case. A silent empty secret would accept unsigned posts.

Traffic shape is the next honest limit here. One SQLite file on one box serves a small launch. Two app instances writing one file will fight each other.

When a second instance becomes necessary, stop this pattern. The unique key moves to a shared database then. The same test names stay so the rule does not drift.

The free server is a launch pad, not a bank. It can host the smoke process for a zero-bill night. It should not store card numbers or raw provider bodies.

A founder who needs a signed uptime promise should not stop here. A team that already runs multi-region payments should not stop here. A product that stores card data itself should not use this box.

The approach also fails for marketplaces with split payouts. Those flows need ledger entries the toy schema cannot name. Shipping the toy anyway would hide missing money.

Support mail is another trap on night one. The handler should not draft refund promises to a buyer. A template that says the event was recorded is enough.

The public surface stays at one route only. A health route can return ok without touching the ledger. A second money route doubles the replay risk for no gain.

Backups are a manual habit on a zero bill. The founder copies the SQLite file before any schema change. A model-written migration without a copy can erase memory.

The copy command is dull and that is the point. The copy runs, then the tests run, then the process restarts. None of those steps should be skipped because the draft looked confident.

A schema change should add a column, not rewrite history. The old event key must remain valid after the alter. New code that rehashes old rows will mint duplicate receipts.

There is a small decision buried in the file choice. SQLite stays while one process owns the file. The pattern ends when a second process or a refund ledger appears.

That decision is the whole cost control for tonight. Extra services feel safe and quietly start a bill. The bill can wait until the replay test feels boring.

The pack page ships only after the count stays at one. The webhook URL goes into the provider after the curls agree. Early buyers hear that the receipt may arrive once or twice.

A second charge is the failure this night refuses. A duplicate email is an apology a founder can send by hand. Those two outcomes are not equal, and the code should know it.

A founder who wants that split can draft against the tests in MonkeyCode. The free server should open only after the replay count stays at one.

Top comments (0)