---
title: "Ask Twice: What the Second Request for the Same Page Returns — NanoParse Blog"
url: https://nanoparse.app/blog/2026-10-13-second-ask
description: "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…"
---

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](https://nanoparse.app/blog/2026-10-04-free-half-discovery) 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](https://nanoparse.app/blog/2026-09-28-parse-job-contract)), and the poll ([free, unauthenticated](https://nanoparse.app/blog/2026-09-28-parse-job-contract)) 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](https://nanoparse.app/blog/2026-10-01-litmus-signals-measured)), 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:

- A wall is terminal for that URL, first ask or retry. Neither `blocked` nor `content_insufficient` is improved by asking again inside the window.
- Do not count on the cache being visible: inside one minute of a failure, the same ask was measured returning both the cached 422 and the payment challenge. The code path has to handle either.
- One permit per attempt. A retry inside the in-flight window resumes the job the first permit started.
- A deliberate repeat is a new parse and a new charge, and it returns the same document. Cache the Markdown in your own storage.
- Keep the job id after a deadline. The result is the only copy, and six hours is the ceiling on 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](https://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](https://nanoparse.app/) · [quickstart](https://nanoparse.app/quickstart) · [MCP server repo](https://github.com/nanoparse-dev/nanoparse-mcp)
