API Response Structure and Error Semantics¶
Part of the Perplexity web API reference — full map at the API index.
4. Response structure essentials (parsing discipline)¶
- Raw data written to disk first:
raw_entries.json(plain) andraw_blocks.json(schematized) are always written first; parsing/rendering can be re-run offline (pplx-export re-render) without re-fetching. - Field extraction centralized in
sites/perplexity/parsers.py(schema drift only needs one place changed). - Mode detection (
normalize.detect_mode; decision tree in export-pipeline.md): the highest-priority signal is thesearch_modefield of any entry (mapping at the end of §3.9); when all signals fail, fall back — computer = URL/computer/tasks/or metadata.mode=="4" or index mode ∈ {ASI,COMPUTER}; council = a COUNCIL_RESEARCH step exists; deep-research = a RESEARCH_ANSWER step exists (content-based, not relying on Chinese labels); otherwise search. - The computer UI collapses everything — always go by the API entries/blocks; never use UI text as the content boundary.
- Subagent dual channel (discovered 2026-07-19): prompt in schematized
workflow_payload.objective_chunks; steps/conclusion in plainbackground_entries; linked viaworkflow_payload.id(toolu_X). WORKFLOW_ITEM_SOURCESitems often carrytext_payload(subagent page-extraction text / comparison tables; 450 occurrences library-wide, 408 inside background nested payloads) in addition tosources_payload.sources(link list); the same content appears both in the plain background entry'stextembedded step JSON and in the schematized nested payload — anchored subagents rendered via the plain path already preserve the text (library-wide check 2026-07-22: 408/408 present, none missing).related_queries/related_query_items(settled 2026-07-23): each entry carries next-question prompt recommendations — follow-up suggestions the platform generates for a completed answer;related_queriesis an array of recommendation texts,related_query_itemsthe structured items (uuid/upsell_type, etc.). Forensic conclusion: an item's uuid is not a thread uuid (0/988 cross-match against library thread uuids), and recommendation texts have zero overlap with other threads' queries — not resolvable into inter-thread relations for now; the hypothesis of "pre-allocated thread uuids (materialized on click)" remains unverified. The data is naturally preserved in the archiveraw_entries.json(hits in over half the threads of one archive library); no extra collection action needed; the relations graph builds no edges from it.
5. Error and risk-control semantics¶
| Symptom | Meaning / handling |
|---|---|
| 403 (with cf challenge page) | Cloudflare block (TLS fingerprint / rate control) — back off; urllib + browser cookies generally don't trigger it |
| 401 / API-level 403 (no cf challenge page) | Session cookie expired/invalid — the tool raises immediately, no backoff; batch fail-fasts after 3 consecutive auth failures (update the cookie) |
| 429 | Rate limiting — exponential backoff (implemented in the tool) |
| 5xx (500/502/503/504) | Transient server errors (504 commonly a Cloudflare timeout) — back off and retry (implemented in the tool) |
| ENTRY_EXPIRED | Purged by the platform (~3 months) — terminal, do not retry |
| ENTRY_DELETED | Deleted by user/remote (also HTTP 400, different code) — terminal deleted, do not retry |
_response_type: VIEW_COLLECTION_NOT_ALLOWED (HTTP 200) |
Current account cannot view the space — retry with an account that can |
error_code: VIEW_THREAD_NOT_ALLOWED (HTTP 403) |
Current account cannot view the thread (tested 2026-07-23: sibling-variant uuid probing; the object exists but is inaccessible, not "nonexistent") |
status:"failed" empty data |
Same class (the get_collection failure form) |
Rate-limit discipline (anti-ban, explicit user requirement): random 10–20s between batch threads, no concurrency, 429/403 backoff, 5xx backoff-retry; pagination ≥3s; schematized re-fetch ≥4s; space metadata fetch ≥3s. Single-thread export = 1–2 requests ≈ opening the page once.
5.1 Interruption semantics — observed values (2026-07-22; classification source of truth: parsers.classify_wf_status)¶
The locked_reason field: appears in thread_metadata / entries[] / background_entries[]
(on both the plain and schematized sides). Only observed value:
| locked_reason | Meaning | Observed distribution |
|---|---|---|
spending_limit_exceeded |
spending-limit interruption (quota exhausted; the workflow halts at the interruption point) | 1 thread library-wide (c598b54f, 39 markers) |
Workflow status field (workflow_block.status and nested workflow_payload.status share the same enum) observed values:
| status | Semantics | Render annotation (COMPLETED gets none) |
|---|---|---|
WORKFLOW_COMPLETED |
normal completion | — |
WORKFLOW_AWAITING_NEXT_STEPS |
awaiting next steps; with locked_reason=spending_limit_exceeded it is a spending-limit interruption (content stops at the interruption point); without locked_reason, interrupted pending continuation |
⏸ 限额中断(内容截至中断点) (⏸ limit-interrupted — content stops at the interruption point) / ⏸ 中断待续 (⏸ interrupted, pending continuation) |
WORKFLOW_CANCELED |
canceled (user/platform abort) | ⛔ 已取消 (⛔ canceled) |
WORKFLOW_CANCELEDobserved 19 times (16 main + 3 nested), across 7 computer threads (a5e8f481/cfca382d/f2e5957d/8417b02a/2dc5716d/356f833e/ed3714ff).- Note: the main-entry anchor payload's status may lag (observed anchor COMPLETED while the background was actually CANCELED) —
a subagent's true status is the background-side
workflow_block.status. - Interrupted background tasks produce no subagent_result completion notification; unconsumed background payloads fall back to the thread appendix (see "attribution waterfall" in subagents-interruptions.md).
5.2 side_by_side_metadata: answer-rewrite variant signal (settled 2026-07-23)¶
Field path: entries[].side_by_side_metadata (plain /rest/thread/<uuid> response).
When the platform generates multiple answer versions for the same query (A/B experiment or rewrite), this is the
only trace left on the currently active entry — the replaced variant's body (text/steps/citations) is not in the thread API response (real case
b2d2632b: the response has only 1 entry, 1 FINAL; variant 2 completely invisible).
Observed keys and values (evidence: b2d2632b raw; library-wide scan of 2442 entries):
{
"experiment_role": "override-default-model-class:qwen3_instruct-01f7f",
"sibling_uuid": "00000000-0000-5000-8000-000000000000",
"experiment_override": {"override-default-model-class": "qwen3_instruct"},
"selection_status": "SELECTED",
"execution_log": {}
}
| Key | Semantics (observed/hypothesized) |
|---|---|
sibling_uuid |
Points to the sibling answer variant of the same query (another entry/context identifier). 7 threads hit library-wide; online forensics (2026-07-23) confirm a dead link: both accounts' GET /rest/thread/<sibling_uuid> return 403 VIEW_THREAD_NOT_ALLOWED (not 404/ENTRY_EXPIRED — the server recognizes it as an existing but unviewable object), and opening /search/<sibling_uuid> in the browser (owner account) gets SPA-redirected back to home — replaced variants cannot be recovered via sibling_uuid |
selection_status |
SELECTED = this entry's answer is the version chosen for display; control-group instances are all SELECTION_STATUS_UNSPECIFIED |
experiment_role |
Experiment role. The control group carries a [control] prefix (6 cases library-wide: [control]default-model-class:gpt41, etc.); the real case has no prefix (override-default-model-class:qwen3_instruct-01f7f, i.e. the treatment group of a model-override experiment) |
experiment_override |
Experiment override parameters (e.g. override-default-model-class: qwen3_instruct); observed only on treatment-group instances |
execution_log |
Observed as an empty object; semantics unknown |
Narrowing criteria (distinguishing "genuine rewrite keeping both versions" from "routine A/B control"):
sibling_uuid non-empty AND (selection_status non-empty and not SELECTION_STATUS_UNSPECIFIED,
OR experiment_role without the [control] prefix) → only b2d2632b hits among the 2442 entries library-wide
(the only confirmed real case; precision/recall are both 1 on this library, but n=1 cannot be extrapolated).
Tool behavior: parsers.collect_answer_variants extracts hits; adapter.get_thread
logs a warning + writes thread.json.answer_variants (key absent when no hits);
re-render --thread-json adds/removes in place (idempotent). Time evidence: the real case's entry created→updated
delta is 53.66 s (generated at 17:13, then rewritten/selected), and the rewrite advanced the thread-level lastUpdated (an incremental re-export
can trigger a re-fetch, but the re-fetched response still contains only the active answer; old variants are unrecoverable).
Detection log and handling flow (2026-07-23, sites/perplexity/variant_log.py):
- Log marker: each hit emits a single WARNING line with the uniform greppable marker
ANSWER_VARIANT_DETECTED, including all locating fields and handling guidance, shaped like:ANSWER_VARIANT_DETECTED thread=<full uuid> uuid8=<8 chars> title="…" entry=<entry_uuid> sibling=<sibling_uuid> selection_status=SELECTED experiment_role=… | action: …The online path (adapter.get_thread) outputs on every actual fetch hit; offlinere-renderoutputs only when registered content is added/changed (idempotent re-runs don't spam);batchalso passes through a one-line hit-count reminder at the end summary (without breaking the existing summary format). - Central registry:
<out>/index/answer_variants_log.jsonl(a checked-in file, not under the gitignoredlogs/) — one JSON per line (detected_at / source=online|offline / web_uuid / uuid8 / title / entry_uuid / sibling_uuid / selection_status / experiment_role), deduped by (web_uuid, entry_uuid); repeated exports/re-renders don't append endlessly; detected_at keeps the first-seen time. - Recommended action on hit: siblings are empirically dead links (see the table above); the alternate answer usually
cannot be recovered via the API — promptly confirm manually whether the alternate answer is still obtainable (platform conversation side / user memory / screenshots); if obtainable,
record it manually as a
rewritten_answer_variant.mdfile in the thread directory; if not,thread.json.answer_variants+ the jsonl registry serve as the final traceable record.