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.
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 adownload_urland afile_hash; proceed to validation.FAILED— the payload carries anerror_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 the600sceiling, 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 thejob_idis not idempotent by default — aurllib3retry on a timed-outPOSTcan create a second generation job, doubling the WIPO charge and forking the audit trail across two IDs. Restrictallowed_methodstoGETand send anIdempotency-Keyheader 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_urlwhilestatusis stillPROCESSING, retrieves a404or an empty stub and dockets a non-existent priority document. Only thestatusfield advances the machine; time governs when to poll, never whether the job is done. - Ingesting bytes without hash verification. A
download_urlcan return atext/htmlmaintenance page or an auth-redirect body with a200status. Writing those bytes into a docket as a priority document is a chain-of-custody breach. Compare a locally computed SHA-256 against thefile_hashin 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 lostPRIORITY_CRITICALdocument 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 thejob_idand 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?
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?
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?
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?
job_id, timestamp, and remaining-window countdown — because a lost priority document is a missed PCT Rule 17 deadline.
Related
- ← WIPO API Async Polling Patterns — the parent async job lifecycle this fetch flow runs inside.
- Implementing Exponential Backoff for Patent APIs — the sibling policy that supplies the poll delay.
- Schema Validation & Error Categorization — where every completed payload is triaged before ingestion.
- PCT 30/31-Month Deadline Calculators — consumes the verified priority date as a base date.