Async results and polling

Three Miso endpoints do their work in the background. You submit a request, get an id, and poll until the job is over.

API Submit Poll You read
Answer POST /v1/ask/questions GET /v1/ask/questions/{question_id}/answer answer, sources
Summary POST /v1/ask/summary GET /v1/ask/summaries/{question_id}/summary answer, sources
Content Tagging POST /v1/ask/tagging/{tag_set} GET /v1/ask/tagging/{tag_set}/{tagging_id} tags

They report progress the same way, so one polling client serves all three.


finished and finish_reason

finished says the job is over. finish_reason says whether it worked.

Do not branch on the progress label. answer_stage on Answer and Summary, and stage on Tagging, are written for a person reading the response. They do not mark the end of the job. A successful tagging leaves stage at "Done", and only a failure sets "Failed". New stage names can appear at any time.

Is the job over?

finished What it means What to do
false The job is still running. Poll again.
true The job is over. Read finish_reason.

A 404 on an early poll is not a failure. A new id takes a moment to become queryable. Wait 2 to 4 seconds before your first poll. Treat a 404 in the first 10 seconds as "not ready yet".

After that window, a 404 means Miso lost the job. Submit the original request again rather than giving up: the id came from your own submit, so it was valid when you got it.

Did it succeed?

Value Meaning Retry
not_finished The job is still running. Poll again.
success The result is complete. Not needed.
error The pipeline failed. Yes. Bounded retry, same payload.
forbidden Miso withheld the result. blocked_reason says why. No.

forbidden is not a fault on your side or on Miso's. It is the rate limit, the query check, or metering turning the request down, and blocked_reason names which one. Show the reader the text in answer.

Treat any value you do not know as a failure.

An empty result is not a failure signal

Read finish_reason before you read the result.

finish_reason Result What it means
success Filled The normal case.
success Empty Miso found nothing to return. This is an answer, not an error. Accept it.
A failure value Anything A processing failure. The result is absent or partial. Discard it.

An empty result on success does not improve if you ask again.

If the job never finishes

If finished is still false after 3 minutes, treat the job as lost and submit the original request again.

Set your own limit above the slowest request you see in practice. A summary with rules, or one over a large article set, runs longer than a plain one.

HTTP status

Status Meaning Retry
200 The poll worked. Read finished and finish_reason.
401 The API key is missing or wrong. No.
404 Not ready yet inside the grace window. A lost job after it. Yes. Submit the request again.
422 The request body is invalid. Read detail. No. Fix the body.
429 Over the rate limit. Yes. Back off first.
500 A fault on Miso's side. Yes. Bounded retry.

See Errors & Rate Limits for the error body itself.

A polling loop

One function submits, polls, and submits again when the job is lost. It either returns the result or gives up.

repeat 3 times:
    id = submit the request
    wait 3 seconds
    start = now

    loop:
        GET the poll endpoint

        404, under 10 seconds     wait 2 seconds, poll again   # not ready yet
        404, after that           leave the loop, submit again # the job is gone
        429 or 5xx                wait, poll again             # Miso is busy
        not finished, under 3 min wait 2 seconds, poll again
        not finished, over 3 min  leave the loop, submit again
        finish_reason "success"   return the result, even when empty
        finish_reason "error"     leave the loop, submit again
        finish_reason "forbidden" give up, read blocked_reason

give up

Only forbidden ends it on the spot. Everything else is worth another submit, bounded by the attempt count.

Each version takes a submit callback and the poll URL.

def get_result(submit, poll_url, api_key, attempts=3):
    """Submit, poll, and submit again when the job is lost.

    Returns the finished `data`, or None when it is not worth asking again.
    """
    session = requests.Session()
    session.headers["X-Api-Key"] = api_key

    for _ in range(attempts):
        job_id = submit()
        time.sleep(3)                    # a new id is not queryable at once
        start = time.monotonic()

        while True:
            response = session.get(poll_url(job_id), timeout=30)
            waited = time.monotonic() - start

            if response.status_code == 404:
                if waited < 10:
                    time.sleep(2)        # not ready yet, not lost
                    continue
                break                    # the job is gone, submit it again

            if response.status_code == 429 or response.status_code >= 500:
                time.sleep(5)            # Miso is busy
                continue

            data = response.json()["data"]

            if not data["finished"]:
                if waited > 180:
                    break                # lost, submit it again
                time.sleep(2)
                continue

            if data["finish_reason"] == "success":
                return data              # accept it, even when empty
            if data["finish_reason"] == "error":
                break                    # a fault, submit it again
            return None                  # forbidden, read blocked_reason

    return None                          # out of attempts
// Submit, poll, and submit again when the job is lost.
// Returns the finished `data`, or null when it is not worth asking again.
async function getResult(submit, pollUrl, apiKey, attempts = 3) {
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));

  for (let attempt = 0; attempt < attempts; attempt++) {
    const jobId = await submit();
    await wait(3000);                  // a new id is not queryable at once
    const start = Date.now();
    let lost = false;

    while (!lost) {
      const response = await fetch(pollUrl(jobId), {
        headers: { "X-Api-Key": apiKey },
      });
      const waited = Date.now() - start;

      if (response.status === 404) {
        if (waited < 10_000) { await wait(2000); continue; }  // not ready yet
        lost = true; continue;         // the job is gone, submit it again
      }
      if (response.status === 429 || response.status >= 500) {
        await wait(5000);              // Miso is busy
        continue;
      }

      const { data } = await response.json();

      if (!data.finished) {
        if (waited > 180_000) { lost = true; continue; }      // submit again
        await wait(2000);
        continue;
      }

      if (data.finish_reason === "success") return data;      // even when empty
      if (data.finish_reason === "error") { lost = true; continue; }
      return null;                     // forbidden, read data.blocked_reason
    }
  }
  return null;                         // out of attempts
}
# Submit, poll, and submit again when the job is lost.
# Prints the finished `data`. Exit 1 means it is not worth asking again.
get_result() {                       # get_result <submit-cmd> <poll-url-prefix>
  local attempt job_id waited start status out=$(mktemp)

  for attempt in 1 2 3; do
    job_id=$($1)                     # your submit command prints the new id
    sleep 3                          # a new id is not queryable at once
    start=$SECONDS

    while :; do
      status=$(curl -sS -o "$out" -w '%{http_code}' -H "X-Api-Key: $KEY" "$2/$job_id")
      waited=$(( SECONDS - start ))

      case $status in
        404) (( waited < 10 )) && { sleep 2; continue; }      # not ready yet
             break ;;                                          # gone, submit again
        429|5??) sleep 5; continue ;;                         # Miso is busy
      esac

      if [ "$(jq -r .data.finished "$out")" != true ]; then
        (( waited > 180 )) && break                           # lost, submit again
        sleep 2; continue
      fi

      case $(jq -r .data.finish_reason "$out") in
        success) jq .data "$out"; return 0 ;;                 # even when empty
        error)   break ;;                                     # submit again
        *)       return 1 ;;                                  # forbidden
      esac
    done
  done
  return 1                                                    # out of attempts
}

The grace window keeps an early 404 from ending the job. The stall guard keeps a lost job from hanging your worker. Backing off on a 429 keeps a busy minute from turning into a failed document.

How long to wait between polls

API Typical run Poll every
Answer About 12 seconds 1 to 2 seconds
Summary About 16 seconds for 3 bullets over 10 articles 1 to 2 seconds
Content Tagging 30 to 50 seconds for one news article 2 to 3 seconds

Answer and Summary fill the body as the model writes, so a short interval lets you render the text while it arrives. Tagging returns nothing until it is done, so a short interval only costs you requests.

Get the result in one call

Every one of these endpoints takes ?wait_for_answer=true on the POST. The request then blocks and returns the finished result.

Use it in a script or a test. In production, poll. The connection can time out after about 60 seconds, and the slowest requests run longer than that.

Next