WIPO Priority Document Sync with Python Requests

WIPO priority document sync is the acquisition flow that submits a certified-priority-document generation job to WIPO, polls its asynchronous 202 Accepted job endpoint with the requests library until the payload materializes, and verifies the returned bytes cryptographically before they enter a docket — so a legally load-bearing PCT priority document is retrieved deterministically without a fixed-sleep guess, a duplicate charge, or a broken chain of custody.

This is one concrete fetch path inside the WIPO API Async Polling Patterns adapter: WIPO’s Digital Access Service (DAS) and PCT priority-document endpoints do not answer synchronously — a POST returns a job_id, and the certified PDF only exists after WIPO finishes generating it. Treat that 202 as a finished result, or hammer the status endpoint with a rigid time.sleep() loop, and the failure is not abstract: a priority document that arrives after the 16-month window under PCT Rule 17 is a malpractice event, and a duplicate submission is a billable generation charge with no matching document. This page fixes the exact state machine, the backoff curve, and the validation gate that every retrieved payload must clear.

WIPO priority-document sync: from idempotent submission to hash-verified ingestion The client POSTs a generation request carrying an Idempotency-Key; WIPO DAS answers 202 Accepted with a job_id and no payload. The client polls GET /priority-docs/{job_id}/status and branches on the status field. PROCESSING sleeps min(delay,600s) plus or minus jitter and re-polls. COMPLETED carries a download_url and file_hash and proceeds to a validation gate that recomputes SHA-256 and checks the %PDF magic bytes: a match ingests into the docket, a mismatch is quarantined. FAILED carries an error_message and routes to the fallback chain (DAS lookup, cache, compliance ticket). Every state transition writes one append-only audit entry with job_id, UTC timestamp, response status, and hash-match result. returns 202 + job_id poll status PROCESSING — sleep min(delay,600s) ± jitter COMPLETED FAILED match mismatch Client — POST /priority-docs (generate) Idempotency-Key header — a retry never forks the job WIPO DAS → 202 Accepted body: { job_id } — payload not yet generated GET /priority-docs/{job_id}/status retry only idempotent GET on 5xx / timeout status field? advance on state, never on wall clock COMPLETED download_url + file_hash FAILED error_message / error_code Validation gate SHA-256(bytes) == file_hash and body starts with %PDF Docket ingestion priority date is trusted Quarantine reject, do not docket Fallback chain DAS lookup → cached registry → compliance ticket (never dropped) Append-only audit ledger every transition writes { job_id · UTC timestamp · response_status · hash_match }

Technical Specification: The HTTP 202 Job Contract

Priority-document generation is non-blocking by design, and the wire behavior is specified rather than incidental. Per RFC 9110 § 15.3.3, a 202 Accepted means the request was accepted for processing but is not complete; the response deliberately carries no final payload, only a handle to the pending work. The WIPO response body returns that handle as a job_id, and the state of the job is exposed on a separate status endpoint that the client must poll.

The sync engine therefore tracks exactly three explicit states, driven only by the status field in the JSON status payload — never by elapsed wall-clock time:

  • PROCESSING — generation in flight; continue polling on the backoff schedule.
  • COMPLETED — the payload carries a download_url and a file_hash; proceed to validation.
  • FAILED — the payload carries an error_message/error_code; halt and route to fallback.

The polling cadence must scale exponentially so it stays inside WIPO’s fair-use quota while still resolving before the statutory window closes. The reference configuration below caps total wait safely within WIPO’s documented generation SLA and applies jitter to stop firm-wide proxies from re-synchronizing into a request storm:

  • Initial poll delay: 2.0s
  • Backoff multiplier: 2.0
  • Maximum attempts: 12 (caps the per-poll delay at the 600s ceiling, well inside the generation SLA)
  • Jitter range: ±0.5s (breaks up the thundering-herd effect on shared firm proxies)
  • Timeout tuple: (connect=5, read=30) (prevents thread-pool starvation during high-concurrency docket sweeps)
  • Idempotency key: required on the initial submission, so a network retry never spawns a duplicate generation job.

Minimal Reproducible Implementation

The pattern uses a single requests.Session with a urllib3 retry adapter for connection-level failures, an explicit poll loop for the application-level state machine, and Python 3.11+ typing throughout. Business-logic failures (status: FAILED) are kept strictly separate from transport failures (timeouts, 5xx), because they demand different recovery.

from __future__ import annotations

import time
import random
import logging
from typing import Any

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# Source: RFC 9110 §15.3.3 (202 Accepted)  https://www.rfc-editor.org/rfc/rfc9110#status.202
# Source: WIPO DAS / PCT priority documents  https://www.wipo.int/das/en/

logger = logging.getLogger("wipo_priority_sync")

INITIAL_DELAY: float = 2.0
MULTIPLIER: float = 2.0
MAX_DELAY: float = 600.0        # 10-minute per-poll ceiling, inside WIPO's generation SLA
JITTER: float = 0.5             # ±0.5s to desynchronize shared firm proxies


def build_session(api_token: str) -> requests.Session:
    """Session that retries only idempotent GETs on transient transport failures."""
    retry = Retry(
        total=3,
        backoff_factor=0.5,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET"],          # never auto-retry the job-creating POST
        respect_retry_after_header=True,  # defer to WIPO's own directive
    )
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers.update({
        "Authorization": f"Bearer {api_token}",
        "Accept": "application/json",
        "User-Agent": "FirmDocketSync/2.1 (Python/requests)",
    })
    return session


def poll_priority_document(
    session: requests.Session,
    job_id: str,
    base_url: str,
    max_attempts: int = 12,
) -> dict[str, Any] | None:
    """Poll the async status endpoint until the job reaches a terminal state."""
    delay = INITIAL_DELAY

    for attempt in range(1, max_attempts + 1):
        try:
            resp = session.get(
                f"{base_url}/api/v1/priority-docs/{job_id}/status",
                timeout=(5, 30),          # (connect, read) — bound both phases
            )
            resp.raise_for_status()
            status = resp.json().get("status")

            if status == "COMPLETED":
                return resp.json()                      # carries download_url + file_hash
            if status == "FAILED":
                logger.error("Job %s failed: %s", job_id, resp.json().get("error_message"))
                return None
            # PROCESSING or unknown -> keep polling

        except requests.exceptions.HTTPError as exc:
            if exc.response is not None and exc.response.status_code >= 500:
                logger.warning("Transient %s on attempt %s", exc.response.status_code, attempt)
            else:
                raise                                   # 4xx is permanent — do not retry
        except requests.exceptions.Timeout:
            logger.warning("Timeout on attempt %s", attempt)
        except requests.exceptions.RequestException:
            logger.critical("Unrecoverable request error for job %s", job_id)
            raise

        time.sleep(max(0.1, min(delay, MAX_DELAY) + random.uniform(-JITTER, JITTER)))
        delay = min(delay * MULTIPLIER, MAX_DELAY)

    logger.error("Max polling attempts (%s) reached for job %s", max_attempts, job_id)
    return None

The loop advances only on the status field, backs off with jitter on every non-terminal cycle, and returns None on both an application FAILED and an exhausted poll budget — a single sentinel the caller escalates through the fallback chain rather than dropping. The retry adapter and the manual loop cover different layers: the adapter absorbs transient socket and 5xx noise on the status GET, while the loop owns the PROCESSING → COMPLETED/FAILED transition the adapter cannot see.

Known Gotchas & Compliance Traps

Each of these fails the same deterministic loop in a way that corrupts a different part of docketing state.

  • Auto-retrying the job-creating POST. The submission that mints the job_id is not idempotent by default — a urllib3 retry on a timed-out POST can create a second generation job, doubling the WIPO charge and forking the audit trail across two IDs. Restrict allowed_methods to GET and send an Idempotency-Key header on submission so WIPO collapses a retried create onto the original job.
  • Advancing state on wall-clock time. Treating “20 minutes elapsed, it must be done” as completion, then fetching download_url while status is still PROCESSING, retrieves a 404 or an empty stub and dockets a non-existent priority document. Only the status field advances the machine; time governs when to poll, never whether the job is done.
  • Ingesting bytes without hash verification. A download_url can return a text/html maintenance page or an auth-redirect body with a 200 status. Writing those bytes into a docket as a priority document is a chain-of-custody breach. Compare a locally computed SHA-256 against the file_hash in the completion payload and reject on mismatch (see the validation gate below).
  • Silently dropping an exhausted job. When polling exhausts or returns FAILED, the payload must escalate, never disappear — a lost PRIORITY_CRITICAL document is a missed PCT Rule 17 deadline. Fall back to a direct WIPO Digital Access Service lookup by application number, then to the parent adapter’s cached-document registry, and finally open a compliance ticket stamped with the job_id and the remaining-window countdown.

Every completed payload clears a cryptographic gate before it is trusted as a docket document. The gate verifies integrity, content type, and metadata correspondence in one pass:

import hashlib


def validate_and_store(doc_bytes: bytes, metadata: dict[str, Any]) -> bool:
    """Reject any payload whose bytes or content type fail verification."""
    computed = hashlib.sha256(doc_bytes).hexdigest()
    expected = metadata.get("file_hash")
    if computed != expected:
        logger.error("Hash mismatch for job %s: expected %s got %s",
                     metadata.get("job_id"), expected, computed)
        return False
    if not doc_bytes.startswith(b"%PDF"):            # reject text/html maintenance pages
        logger.error("Non-PDF payload for job %s", metadata.get("job_id"))
        return False
    # application_number / priority_date must match the originating docket record
    logger.info("Validated priority document for job %s", metadata.get("job_id"))
    return True

Integration Point

This fetch flow is a leaf of the acquisition pipeline, not a standalone script. Upstream, the parent WIPO API Async Polling Patterns adapter owns the POST → 202 → poll → terminal job lifecycle and reuses the identical backoff curve defined in Implementing Exponential Backoff for Patent APIs between polls. Downstream, only a hash-verified COMPLETED payload proceeds; every completed document is handed to the Schema Validation & Error Categorization gate before its priority date becomes a base date the PCT 30/31-Month Deadline Calculators rely on. When a job exhausts or fails, escalation runs through the Patent Docket Fallback Routing System rather than failing open.

Each state transition and validation result writes one append-only record — job_id, request_timestamp (ISO-8601 UTC), response_status, hash_match, and operator_id — to write-once storage governed by the Security & Access Control Boundaries model, so a reviewer can reconstruct exactly which certified document was ingested, when, and against which hash. Keep INITIAL_DELAY, MAX_DELAY, and max_attempts in environment variables so the cadence can be widened during a WIPO maintenance window without a redeploy.

Frequently Asked Questions

Why does a WIPO priority-document request return 202 instead of the PDF?
Certified priority documents are generated on a background pipeline, so WIPO accepts the request and returns 202 Accepted with a job_id rather than blocking until the PDF exists. Per RFC 9110 §15.3.3 a 202 is explicitly non-terminal; the finished document is retrieved by polling a separate status endpoint until status is COMPLETED.
Is it safe to let urllib3 automatically retry the submission POST?
No. The submission that creates the job_id is not idempotent, so an automatic retry on a timed-out POST can spawn a second generation job — a duplicate charge and a forked audit trail. Restrict allowed_methods to GET and send an Idempotency-Key header so WIPO collapses a retried create onto the original job.
How do I know the downloaded bytes are actually the priority document?
Compute a SHA-256 over the downloaded bytes and compare it against the file_hash in the completion payload, and confirm the body starts with the %PDF magic bytes. A 200 response can still return a text/html maintenance or auth-redirect page; ingesting that as a priority document breaks chain of custody, so reject on any mismatch.
What happens if polling exhausts before the 16-month priority window closes?
The job is never silently dropped. It escalates through a fallback chain — a direct WIPO Digital Access Service lookup by application number, then the parent adapter's cached-document registry, then a compliance ticket stamped with the job_id, timestamp, and remaining-window countdown — because a lost priority document is a missed PCT Rule 17 deadline.