DEV Community

Taylor Wang
Taylor Wang

Posted on

The Free Model Was Slow, the Gateway Gave Up, and My Webhook Ran Twice

Last week I wired a small webhook to a free model on a free server, mostly to see how far the pair could carry real work. I built the summarizer on MonkeyCode's free model access, deployed it to the free server option, and let an external service send events into it. The idea was simple: parse the payload, ask the model for a one-line summary, and store the result in SQLite. The next morning the database had two rows for the same event, and the summaries differed enough that I almost blamed the model.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

It took me a while to realize the model was innocent, the server was fine, and the bug was in the shape of my request path. This is the story of that debugging session, plus the idempotency fix that ended it. Along the way I learned a few techniques that apply to any webhook, not just this one.

The symptom that looked like model nondeterminism

Here is what the table looked like when I first opened it:

SELECT event_id, summary, created_at FROM events ORDER BY created_at;
Enter fullscreen mode Exit fullscreen mode
evt_8f2a1c  user clicked checkout, then left          2026-08-19 22:14:03
evt_8f2a1c  user started checkout and abandoned cart  2026-08-19 22:14:51
Enter fullscreen mode Exit fullscreen mode

Same event_id, two summaries, 48 seconds apart — my first instinct was to blame the free model's sampling, because two calls with the same input can legitimately produce different phrasings. But the timestamps were the real clue, since 48 seconds is a retry window rather than a model artifact. Someone or something sent that payload twice, and the model simply wrote two different answers to the same question.

Three suspects, one elimination

I listed every way a duplicate could appear, and crossed them off one by one:

  1. My handler retried internally — no, there was no retry loop anywhere in the code.
  2. The sender's SDK retried after a timeout — yes, its logs said timeout after 10s, retrying.
  3. The model produced two different outputs for one call — no, the matching event_id proved two separate calls happened.

What else could produce two rows with the same ID? Once I saw timeout after 10s in the sender's log, the story snapped into focus. The gateway on the free server had dropped the connection before my handler could answer, so the sender assumed failure and fired again. The first attempt had still finished its work, the write landed just before the reset, and the second attempt wrote the same event a second time.

Why the first attempt was slow

The slow step was the model call. On a cold process, the free model endpoint took roughly eleven seconds in my reproduction, and the connection dropped at about ten. My handler was doing everything synchronously: parse the payload, call the model, write the row, return 200. The write happened, the 200 never did, and the sender's retry logic did exactly what it was designed to do.

# app.py — the version that duplicated events
from fastapi import FastAPI, Request
import sqlite3

app = FastAPI()

def summarize(text: str) -> str:
    # Pseudocode: the free model client call goes here.
    # On a cold process this took ~11s in my reproduction.
    return call_free_model(text)

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    summary = summarize(payload["text"])
    save_event(payload["event_id"], summary)   # write happens...
    return {"ok": True}                        # ...then the response may never arrive
Enter fullscreen mode Exit fullscreen mode

Notice the ordering: the database write is the last real step before the response. If the gateway gives up between the write and the response, the sender cannot tell success from failure, so it retries. Free tiers make this more likely because their gateway timeouts are short and their cold starts are slow.

The fix: idempotency, not faster code

The durable fix is idempotency, not faster code. If the endpoint can recognize a payload it has already processed, the retry becomes harmless. I added a unique constraint on event_id and changed the handler to check before doing any expensive work:

-- schema.sql
CREATE TABLE events (
    id INTEGER PRIMARY KEY,
    event_id TEXT NOT NULL UNIQUE,
    summary TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Enter fullscreen mode Exit fullscreen mode
# app.py — the idempotent version
@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    event_id = payload["event_id"]

    if event_exists(event_id):
        return {"ok": True, "duplicate": True}

    summary = summarize(payload["text"])

    try:
        save_event(event_id, summary)
    except sqlite3.IntegrityError:
        return {"ok": True, "duplicate": True}

    return {"ok": True}
Enter fullscreen mode Exit fullscreen mode

Two layers matter here, and both earn their place. The early check avoids wasting a slow model call on a known duplicate, while the unique constraint catches the race where two attempts pass the check before either one commits. In my replay test, the second request came back in under a second with duplicate: true, and the table stayed at one row.

The replay test that proved it

A fix you cannot replay is a guess, so I wrote a script that sends the same payload twice:

# replay.sh — send the same payload twice, three seconds apart
curl -s -X POST https://your-app.example/webhook \
  -H "Content-Type: application/json" \
  -d '{"event_id":"evt_replay_001","text":"user added item to cart"}' | jq

sleep 3

curl -s -X POST https://your-app.example/webhook \
  -H "Content-Type: application/json" \
  -d '{"event_id":"evt_replay_001","text":"user added item to cart"}' | jq
Enter fullscreen mode Exit fullscreen mode

First run returned {"ok": true}, and the second returned {"ok": true, "duplicate": true}. Then the count confirmed the behavior, and the output was exactly what I wanted to see. Before the fix, that same script produced two rows and two different summaries, which is why I initially suspected the model. The free model's nondeterminism was a red herring; it just made the duplicate harder to spot by eye.

What I learned, and where this approach breaks

Three techniques carried this debugging session, and I would use them again:

  • Correlate by the sender's own ID before comparing outputs. If the event_id matches, the model is not your first suspect.
  • Check the sender's logs for timeouts, not just your own. The retry reason lives on the client side.
  • Treat any write-then-respond handler as at-least-once, and design for duplicates even if you have never seen one.

Now the honest limitations. Idempotency keys protect against duplicate writes, but they do not make the work transactional: if the model call succeeds and the process dies before the write, the event is lost, and the retry will not bring it back. If your model call regularly crosses the gateway timeout, you should move it out of the request path into a queue or worker, though on a free server that queue becomes its own failure domain. And if you need exactly-once semantics across multiple services, a unique constraint in SQLite is not enough; you need a transactional outbox or a distributed lock. This approach fits small, single-writer services where a lost event is acceptable but a duplicated one is not.

The whole incident came down to one question I had not asked: what happens when my response never reaches the caller? The free model and the free server both behaved within their normal limits; the design around them did not. If you are about to wire a webhook to a slow upstream, ask that question before the first payload arrives, because the sender will ask it for you eventually. MonkeyCode's free model access and free server option are a reasonable place to reproduce this exact failure, and now you know the fix before I did.

Top comments (0)