Blog

Ask Twice: What the Second Request for the Same Page Returns

· NanoParse team

The first request for a page is a decision about money. The second request is a decision made with less information than a client would like: whether the first one is still running, whether the answer was already counted, whether asking again bills twice. Retry policy is where agent budgets quietly leak.

This is a run with the second ask left in. One URL asked repeatedly after a failed render, one URL asked twice after a successful one, and one unpaid probe before each render so the free path stays visible. Everything below was measured against the live endpoint on 2026-10-13 from an exhausted free tier, through the operator path that bypasses the parse counter, and the raw payloads are committed beside this post.

What a first ask answers

An unpaid call is a quote, not a failure: POST https://nanoparse.app/fetch with no Payment-Signature header answers HTTP 402 carrying the x402 terms — price, asset, network, recipient. That surface was walked in full in The Free Half and is not the subject here. The relevant part is that the quote is also the default answer to a second request, until something else takes priority.

A paid request is a job. POST returns jobId and a statusUrl (or, when the request carries the sync flag and the render lands inside its wait window, the finished envelope inline as HTTP 200 — both answers are success), and the poll (free, unauthenticated) ends in one of two envelopes:

{"status":"completed","markdown":"…","metadata":{…},"litmus":{…}}

{"status":"failed","error_code":"blocked",
 "message":"…","blocked_by":"consent_wall:markup","http_status":403}

A render that produces under fifty words fails instead of completing, and settles nothing. The line is one constant in the deployed source:

// x402-v2.ts
export const MIN_CHARGEABLE_WORDS = 50;

export function isChargeableContent(markdown: string): boolean {
  const words = markdown.split(/s+/).filter((w: string) => w.length > 0);
  return words.length >= MIN_CHARGEABLE_WORDS;
}

That is the render's verdict. What the next request for the same URL gets is a different mechanism, and it is the one worth measuring.

The failure is cached for fifteen minutes

A failed render leaves a mark behind. The request path checks for it before the render is queued, before payment is required, and answers a short-circuit instead:

// index.ts, /fetch
// ── Insufficient-content short-circuit (15-min per-URL cache) ──
// If a recent paid render of this URL came back under 50 words (bot wall /
// JS shell), fail fast: no browser, no queue, no payment, no charge.

Measured on https://www.reuters.com/technology/, which renders to a consent wall from this host. Every probe below is unpaid and therefore free; the render itself went through the operator path.

POST /fetch   https://www.reuters.com/technology/      (no payment, free tier spent)
  before the render    402  payment_required                    963 ms
  render               202  job queued in 830 ms, then failed: blocked / consent_wall:markup
  +2s                  402  payment_required                    664 ms
  +13s                 402  payment_required                    620 ms
  +30s                 422  content_insufficient                223 ms
  +60s                 422  content_insufficient                519 ms
  +120s                422  content_insufficient                283 ms
  +240s                422  content_insufficient                297 ms
  +480s                422  content_insufficient                568 ms

The 422 body, verbatim from the +30s probe:

HTTP/2 422
{"error":"content_insufficient",
 "error_code":"content_insufficient",
 "message":"This URL recently returned only 0 words (bot wall, JS shell, or
            login/security checkpoint). Cached for 15 minutes — you were NOT
            charged.",
 "not_charged":true}

Three things fall out of that timeline.

The short-circuit runs ahead of the payment gate. The free tier on this network is spent and no wallet is attached, yet the marked URL answers 422 rather than the 402 it answered ninety seconds earlier. A retry on a recently failed page does not reach the challenge, the queue, or a browser.

The cache is fast, and the render is not. The five short-circuits came back in 223–568 ms. The submit that started the render took 830 ms on its own, and the job then sat in the queue for seconds before failing. The window exists to stop exactly that work from repeating — the reason it exists is in the source comment on the consumer side: retries short-circuit at the edge for 15 min — no re-render, no queue, no charge.

The mark is not reliable, and not instant. Running the same experiment twice, minutes apart, produced different visibility at identical offsets. The Reuters run above still received the payment challenge at +2s and +13s and the cached refusal from +30s on. A second run against a different page received the cached refusal at +2s, the payment challenge again at +25s, and the cached refusal at +60s and +120s. The mark is a key written by the render worker and read on the request path; what varies is when that read sees it, and nothing in this run isolates why. Treat the short-circuit as an optimisation that saves a render when it fires, never as a window a client can time.

The count inside the message is the failed render's own word count. A page marked earlier in the same session still answered with its number minutes later:

{"error":"content_insufficient","not_charged":true,
 "message":"This URL recently returned only 31 words (bot wall, JS shell, or
            login/security checkpoint). Cached for 15 minutes — you were NOT
            charged."}

Thirty-one words is the render's verdict, not a threshold. A client can compare it against its own floor — under a hundred words on an article URL is not an article — without spending anything.

The same wall reports two different codes

The first ask and the retry describe one consent wall with two different error codes, because one is the renderer's verdict and the other belongs to the cache that remembers it:

first ask (job envelope)     status failed   error_code blocked   blocked_by consent_wall:markup
retry, inside the window     HTTP 422        error_code content_insufficient

A client that switches on error_code and treats blocked as terminal will not recognise content_insufficient as the same event, and one that branches on the HTTP status alone sees 202-then-failed on the first ask and a flat 422 on the second. Both codes mean the same thing for a retry decision — the page answered with a wall, and asking again inside the window cannot change that — but they arrive on different transports: the job envelope at a status URL with HTTP 200, the cached refusal at the submit endpoint with HTTP 422.

The second timer: a paid retry does not enqueue a second render

The failure cache protects the endpoint. A second mechanism protects the payer, and it is the reason a slow render does not bill twice: when a paid request is in flight for a given payer and URL, a retry arriving with a fresh permit resumes the original job instead of queueing a new one.

// x402-v2.ts
export const DEDUPE_TTL_S = 900; // 15 min — covers timeout + render + retry cadence

export function resolveDedupeHit(storedJobId: string | null | undefined,
                                 done: boolean, resultServable: boolean): string | null {
  if (!storedJobId) return null;
  if (done && !resultServable) return null;
  return storedJobId;
}

The key is the payer address plus the URL, written on the way in (inflight::) and marked finished on the way out. The three-condition resolver is the interesting part. A stored id with the job still running is reused. A stored id whose job finished and whose result is still awaiting its single read is also reused — the retry collects the result the first attempt paid for. A stored id whose job finished and whose result was already consumed returns nothing, because at that point the caller is not retrying a failure, it is asking for a second parse: a new job, a new charge, which is the honest reading of a deliberate repeat.

This path is quoted from the deployed source rather than measured here: exercising it needs a funded permit, and the run above deliberately spends nothing. The two client-side rules it implies are safe to state — one permit per attempt, and never re-sign a new permit for a URL that is still inside its retry window.

The third timer: six hours is the ceiling on a result

Behind both windows sits the retention clock. A rendered result is stored under its job id, stamped with the age of the content rather than the copy, and refused past six hours:

// retention.ts
export const RESULT_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours, per the published copy

The stamping detail matters for anyone who reads a result through a cache: a copy served from a cached render inherits the original parse's clock instead of starting a fresh six hours, and a delivery past the ceiling is refused with the named terminal state result_expired rather than handed over stale. A six-hour window is a retry budget, not an archive — anything worth keeping belongs in the caller's own storage, first, before it is summarised, indexed, or handed to a model.

Ask twice, get the same document

All three timers above are about not repeating work. What happens when a repeat is deliberate? The same page was asked twice in one session, a second apart, through the operator path:

https://en.wikipedia.org/wiki/Markdown    first ask        second ask
  status                                  completed        completed
  words / characters                      3012 / 37292     3012 / 37292
  markdown sha256 (first 16)              9681515549ff799a 9681515549ff799a
  litmus block                            identical        identical

Byte-identical Markdown and a byte-identical signal block. The envelope carries four keys on success — status, markdown, metadata, litmus — and the metadata block is title, author, description, published, image, site, domain, wordCount. The fifteen signals are deterministic heuristics computed inside the same render (published with their values and thresholds), so a repeated ask is not a second opinion — it is the same document, and paying for it twice buys nothing new.

What is not constant is the page. The same URL returned 2,993 words on 2026-10-07, committed with that day's run, and 3,012 today. A wiki article moves, and a parse is a read of the web now: stable across two asks in a minute, not stable across a week.

A retry policy that costs nothing

The whole contract reduces to a handful of branches, and only one of them spends money.

// retry.ts — what a second ask can get back.
const ENDPOINT = "https://nanoparse.app/fetch";

async function read(url, opts) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json", ...opts.headers },
    body: JSON.stringify({ url }),
  });
  const body = await res.json();

  // 422 — a recent render of this URL came back under 50 words.
  // The wall is the page's answer, not a transient fault: a retry inside the
  // window cannot succeed, and not sending one costs nothing.
  if (res.status === 422 && body.error_code === "content_insufficient") {
    return { outcome: "wall", cachedWords: body.message, notCharged: body.not_charged };
  }

  // 402 — the quote. Sign it once per attempt, never a second permit for a
  // URL still inside its retry window.
  if (res.status === 402) return { outcome: "payment_required", terms: body.payment_required };

  // 202 — a job. Persist the id before polling, accept 200 or 202 from the
  // same POST, and branch on status, not on the HTTP code.
  if (res.status === 202) return { outcome: "job", jobId: body.jobId, statusUrl: body.statusUrl };

  return { outcome: "inline", envelope: body };
}

The checklist that goes with it:

Cost

$0.005 USDC on Base per successful parse, paid over x402, per request — no account, no API key, nothing prepaid, and no subscription. A failed, blocked, or truncated read is never charged, which is what makes every branch above cheap to send: the expensive outcome is a render that succeeds and gets thrown away. The free tier is documented at nanoparse.app/quickstart.

Point an MCP client at the hosted endpoint:

{
  "mcpServers": {
    "nanoparse": {
      "url": "https://nanoparse.app/mcp"
    }
  }
}

REST clients POST a URL to https://nanoparse.app/fetch and follow the returned statusUrl. Docs, response schemas, and the live demo: nanoparse.app · quickstart · MCP server repo

← All posts NanoParse home