WIPO API Async Polling Patterns
Patent docketing runs on statutory deadlines where data latency is compliance exposure: a priority document that arrives a day late, or a national-phase anchor that is never materialized, is a malpractice event that surfaces months after the fact. The specific gap this page closes is that WIPO does not answer international-filing queries synchronously. Requests against PATENTSCOPE and the PCT data services are accepted for background processing and return an immediate 202 Accepted with a job_id; the payload only becomes available after WIPO finishes generating it. A docketing engine that treats that 202 as terminal, or that hammers the status endpoint with a fixed time.sleep() loop, produces missed deadlines, duplicate generation charges, throttled credentials, and a fragmented audit trail. This page specifies how to model the asynchronous job lifecycle as an explicit state machine and poll it deterministically until a payload resolves.
This is the international acquisition adapter inside the Patent Office Portal Sync & Data Ingestion pipeline. It treats WIPO as an eventually-consistent source, polls with bounded backoff until a job materializes, and hands every completed payload to the Schema Validation & Error Categorization gate before it becomes a base date the Automated Deadline Calculation & Rule Engines framework reads. Unlike the USPTO Patent Center Web Scraping adapter, which resolves near-real-time from DOM and structured endpoints, the WIPO path is inherently deferred and must track job state across many requests without ever losing a deadline-critical result.
Compliance & Scope Boundaries
This adapter acquires and tracks WIPO/PCT job state; it does not compute deadlines, roll dates off non-working days, or make legal judgments. It hands validated base dates — international publication date, the 30/31-month national-phase anchor under PCT Articles 22 and 39, priority claims under Article 8 — to the calculation layer and stops there. Keeping acquisition and computation separate is a compliance boundary: the moment a poller starts “adjusting” a returned filing date it becomes an unauditable source of truth.
What the adapter is permitted to do is bounded by WIPO’s published access terms. It may submit generation jobs, poll their status, and retrieve completed payloads within the fair-use quota tied to its credentials. It is prohibited from polling faster than the documented cadence allows, from ignoring a Retry-After header, and from retrieving or caching pre-publication PCT content in violation of the international publication embargo. Concretely:
- Respect the asynchronous contract. A
202is an acknowledgement, not a result. Never interpret elapsed wall-clock time as completion — only thestatusfield in the status payload advances the state machine. - Stay inside the rate policy. Honor
Retry-Afterand theX-RateLimit-Remainingmetadata WIPO returns; treat an approaching quota as a signal to widen intervals, not to open more connections. Aggressive polling triggers IP-based throttling that degrades firm-wide synchronization, not just one job. - Preserve the embargo. Priority and PCT data acquired before international publication is confidential; audit records store a hash and redacted field paths, never raw applicant content in the clear. Who may change poll cadence, credentials, or retention is itself governed by the Security & Access Control Boundaries model.
- Capture provenance on every payload. Each completed result is stamped with its source URL, retrieval timestamp, and the access rule it was collected under before it enters normalization, matching the admissibility contract defined by the Patent Office Portal Sync & Data Ingestion reference.
Prerequisites & Dependency Map
The poller is a long-lived but stateless-per-job coroutine with a small pinned dependency surface. Before implementing it, the following must be in place:
- WIPO API credentials. An API key or OAuth client with access to the PCT/PATENTSCOPE data services, scoped to the offices and document types the firm actually dockets. Store secrets outside source control.
- A completed-payload consumer. The Schema Validation & Error Categorization gate, which every
COMPLETEDresult is handed to before it is trusted as a base date. - A canonical event target. The
DocketEventshape defined by the Core Docketing Architecture & Deadline Types reference, which validated results are normalized onto. - Library versions (pinned).
httpx>=0.27for async, connection-pooled HTTP;pydantic>=2.6for the job and payload contracts;tenacityoptional for declarative retry. Timestamp handling uses the standard-libraryzoneinfoanddatetimemodules — neverpytz. Python 3.11+ is required for the modern typing anddatetimebehavior used below. - An append-only audit sink. A WORM-compliant store or append-only table for one record per state transition.
The poll cadence and quota are configuration, not code, so they can be tuned without a redeploy and cited to their governing source:
# config/adapters/wipo_async.yaml
# WIPO/PCT asynchronous acquisition adapter — poll cadence and quota.
# PATENTSCOPE data services: https://patentscope.wipo.int
# PCT Article 20 (communication of the international application):
# https://www.wipo.int/pct/en/texts/articles/
wipo_async:
base_url: "https://api.wipo.int/v1" # scope to credentialed data services
access_rule: "PATENTSCOPE terms of service; PCT Arts. 8, 20, 22, 39"
# Adaptive interval ladder (seconds): tight at first, then widen to a cap.
poll_intervals: [2.0, 2.0, 2.0, 5.0, 10.0, 30.0]
interval_cap_seconds: 30.0
jitter_fraction: 0.15 # +/-15% to avoid synchronized storms
max_polls: 12 # bounded; caps total wait, then quarantine
request_timeout_seconds: 15.0
honor_retry_after: true # obey Retry-After / X-RateLimit-Remaining
terminal_states: ["COMPLETED", "FAILED", "EXPIRED", "CANCELLED"]
Step-by-Step Implementation
Each step is independently verifiable — you can exercise the state machine, the backoff math, and the fetch against a mock WIPO endpoint before wiring in live credentials.
Step 1 — Model the job lifecycle as an explicit state machine
WIPO enforces a deterministic POST → 202 Accepted → Poll → Terminal State workflow. Model every state explicitly so a deadline-critical payload is never silently dropped. Non-terminal states (QUEUED, PROCESSING) trigger a non-blocking wait; terminal states route deterministically: COMPLETED proceeds to validation, FAILED to structured error categorization, and EXPIRED/CANCELLED to a paralegal quarantine queue. Each transition is an auditable event, never an implicit side effect of a sleep timer.
from enum import Enum
class JobState(str, Enum):
QUEUED = "QUEUED" # accepted, not yet processing — keep polling
PROCESSING = "PROCESSING" # generating — keep polling
COMPLETED = "COMPLETED" # terminal: fetch result, then validate
FAILED = "FAILED" # terminal: categorize error, alert ops
EXPIRED = "EXPIRED" # terminal: job aged out — paralegal review
CANCELLED = "CANCELLED" # terminal: cancelled upstream — paralegal review
@property
def is_terminal(self) -> bool:
return self in (
JobState.COMPLETED, JobState.FAILED,
JobState.EXPIRED, JobState.CANCELLED,
)
Step 2 — Compute adaptive, jittered poll intervals
A fixed interval either wastes quota (too tight) or blows past a generation SLA (too loose). Use an adaptive ladder — 2s for the first three checks, then 5s, 10s, capped at 30s — and add randomized jitter (±15%) so that many paralegal workstations or batch workers polling at once do not synchronize into a request storm against WIPO. The full mathematical treatment, including the decorrelated-jitter variant, lives in the Implementing Exponential Backoff for Patent APIs guide.
import random
def next_delay(base_intervals: list[float], attempt: int,
cap: float = 30.0, jitter_fraction: float = 0.15) -> float:
"""Return the jittered wait before the next poll for a given attempt."""
base = base_intervals[attempt] if attempt < len(base_intervals) else cap
# Symmetric jitter in [-fraction, +fraction]; floor at 0.5s so we never
# busy-poll even if jitter drives the value toward zero.
jitter = base * jitter_fraction * (random.random() * 2 - 1)
return max(0.5, base + jitter)
Step 3 — Poll asynchronously until a terminal state
Use asyncio with httpx.AsyncClient for non-blocking, connection-pooled polling; a synchronous requests loop blocks the event loop and collapses throughput under concurrent docket loads. The coroutine validates the status shape on every poll, honors Retry-After on throttling, and terminates cleanly when the state machine reaches a terminal state or the bounded poll budget is exhausted.
import asyncio
import logging
from datetime import datetime, timezone
import httpx
from pydantic import ValidationError
logger = logging.getLogger("wipo_async_poller")
class WipoAsyncPoller:
def __init__(self, api_key: str, base_url: str = "https://api.wipo.int/v1",
base_intervals: list[float] | None = None) -> None:
self.client = httpx.AsyncClient(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}",
"Accept": "application/json"},
timeout=15.0,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
self.base_intervals = base_intervals or [2.0, 2.0, 2.0, 5.0, 10.0, 30.0]
async def poll_job(self, job_id: str, max_polls: int = 12) -> "JobStatus":
for attempt in range(max_polls):
try:
response = await self.client.get(f"/jobs/{job_id}/status")
# 429/503 carry Retry-After: obey it instead of our own ladder.
if response.status_code in (429, 503):
retry_after = float(response.headers.get("Retry-After", 30))
logger.warning("Throttled on job %s; honoring Retry-After=%ss",
job_id, retry_after)
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
# Validate the status envelope BEFORE evaluating the state.
status = JobStatus.model_validate(response.json())
logger.info("Poll %d | job %s | %s | %s", attempt + 1, job_id,
status.state, datetime.now(timezone.utc).isoformat())
if status.state.is_terminal:
return status # COMPLETED/FAILED/EXPIRED/CANCELLED
await asyncio.sleep(next_delay(self.base_intervals, attempt))
except httpx.HTTPStatusError as exc:
logger.warning("HTTP %s on poll %d for job %s",
exc.response.status_code, attempt + 1, job_id)
await asyncio.sleep(next_delay(self.base_intervals, attempt))
except ValidationError as exc:
# A malformed status envelope is not retryable — the source
# drifted. Halt and quarantine rather than guessing.
logger.error("Schema violation for job %s: %s", job_id, exc)
raise RuntimeError(f"Invalid WIPO status envelope: {exc}") from exc
raise TimeoutError(
f"Job {job_id} did not reach a terminal state within {max_polls} polls"
)
async def close(self) -> None:
await self.client.aclose()
Step 4 — Fetch and hand off the completed payload
A COMPLETED status carries a result_url, not the document itself. Fetch it, stamp provenance, and hand the raw bytes to the validation gate — never straight into the docketing database. FAILED, EXPIRED, and CANCELLED states are recorded and routed to their respective queues without a retrieval attempt.
API Contract & Schema
The adapter exposes two typed contracts: the status envelope it polls and the completed payload it hands downstream. Both are UTC-normalized and validated before any state transition. Detailed retrieval and cryptographic validation of the priority document itself are covered in the WIPO Priority Document Sync with Python Requests guide.
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from pydantic import BaseModel, Field, field_validator
class JobStatus(BaseModel):
"""The polled status envelope. Only `state` advances the machine."""
job_id: str
state: JobState
created_at: datetime
updated_at: datetime | None = None
result_url: str | None = None
error_detail: str | None = None
class PriorityDocumentPayload(BaseModel):
"""A completed WIPO/PCT priority-document result, ready for validation."""
application_number: str = Field(pattern=r"^[A-Z]{2}\d{10,15}$")
filing_date: date
country_code: str = Field(pattern=r"^[A-Z]{2}$") # ISO-3166 alpha-2
document_status: str = Field(pattern=r"^(PUBLISHED|PENDING|WITHDRAWN)$")
document_url: str
@field_validator("filing_date")
@classmethod
def _no_future_filing(cls, v: date) -> date:
# A filing date after today is structurally impossible — CRITICAL.
if v > datetime.now(ZoneInfo("UTC")).date():
raise ValueError("filing_date cannot be in the future")
return v
Idempotency key and audit hash
WIPO jobs are frequently re-polled — after a retry, a nightly reconciliation, or a manual re-pull — so the ingestion boundary must be idempotent. Derive a deterministic key so a duplicate result collapses onto one ledger entry rather than creating a second docket event:
import hashlib
def idempotency_key(source: str, application_number: str,
event_type: str, base_date: date) -> str:
"""Stable across re-fetches of the same logical event."""
material = f"{source}|{application_number}|{event_type}|{base_date.isoformat()}"
return hashlib.sha256(material.encode("utf-8")).hexdigest()
def payload_hash(raw_bytes: bytes) -> str:
"""SHA-256 of the raw result bytes — proves chain of custody."""
return hashlib.sha256(raw_bytes).hexdigest()
The payload_hash is retained alongside the idempotency key. A re-fetch that yields the same key but a different hash is source drift — surfaced to the calculation layer as a re-computation signal, never a silent overwrite. Every poll and every retrieval writes one immutable audit record with the state, the UTC timestamp, and (for completed jobs) the payload hash; raw pre-publication content is never logged in the clear, satisfying the confidentiality boundary above.
Edge Cases & Failure Modes
202misread as terminal. The single most common defect: treating the acknowledgement as the result. The state machine only advances on thestatusfield, so a202with no subsequentCOMPLETEDkeeps polling until the budget is exhausted, then quarantines — it never fabricates a payload.- Job aged out (
EXPIRED). Long-running generation can outlive WIPO’s job retention. AnEXPIREDterminal state must re-submit a fresh job or flag for paralegal intervention; it must never be silently retried against the deadjob_id. - Throttling mid-poll (
429/503). When quota is hit, WIPO returnsRetry-After. Honor it verbatim instead of the local interval ladder, and widen subsequent intervals — opening more connections here guarantees a longer ban. - Schema drift in the status envelope. A renamed field or a new state value raises
ValidationError. This is not retryable: halt, preserve the raw envelope in the quarantine store, and alert, rather than validating against a guessed shape. - Thundering herd on shared deadlines. Many workers polling the same batch (e.g. a firm’s whole PCT portfolio near a publication window) synchronize without jitter. The ±15% jitter in Step 2 is mandatory, not decorative.
NOT_FOUND/DOCUMENT_UNAVAILABLEfor legacy or annex records. Some jurisdiction-specific annexes are absent from the WIPO API. On these states, route to a fallback rather than failing the docket: European supplementary bibliographic metadata is recovered through the EPO Register Headless Browser Fallback adapter. All fallback results run through the same validation gate and are logged separately from primary API metrics to prevent data contamination.- National-phase scope leakage. WIPO’s PCT data covers the contracting states but not national-phase prosecution. The poller must never attempt to resolve domestic-registry data outside WIPO’s scope; cross-jurisdictional reconciliation happens post-ingestion via deterministic matching, not inside the polling loop.
Verification & Regression Testing
Treat the poller and its interval ladder as code, and pin behavior against recorded WIPO responses (redacted). Because the loop is time-driven, tests must stub the transport and the clock rather than sleeping in real time.
import pytest
from datetime import date
def test_state_terminality() -> None:
assert JobState.COMPLETED.is_terminal is True
assert JobState.PROCESSING.is_terminal is False
def test_jitter_stays_within_bounds() -> None:
# 1000 draws around a 10s base must never leave +/-15% or floor at 0.5s.
for _ in range(1000):
d = next_delay([10.0], attempt=0, jitter_fraction=0.15)
assert 8.5 <= d <= 11.5
assert d >= 0.5
def test_future_filing_date_is_rejected() -> None:
with pytest.raises(ValueError):
PriorityDocumentPayload(
application_number="US20240012345",
filing_date=date(2999, 1, 1),
country_code="US",
document_status="PUBLISHED",
document_url="https://example.test/doc.pdf",
)
def test_idempotency_key_is_stable() -> None:
a = idempotency_key("WIPO", "US20240012345", "PRIORITY_DOC", date(2024, 3, 1))
b = idempotency_key("WIPO", "US20240012345", "PRIORITY_DOC", date(2024, 3, 1))
assert a == b # same logical event -> same ledger entry, no duplicate
A corpus of recorded status transitions — QUEUED → PROCESSING → COMPLETED, plus each failure terminal — is the best defense against a change that accidentally makes a non-terminal state look terminal (or the reverse). Run the corpus in CI before promoting any change to the interval ladder or the state enum.
Operational Action Summary
Operational Action: Version-control the interval ladder, jitter fraction, and poll budget as configuration, and pin them per credential tier so a quota change is a config edit, not a redeploy. Cite each value to its governing WIPO/PCT source in a comment.
Operational Action: Enforce the idempotency key at the ingestion boundary and retain the SHA-256 payload hash. A re-polled job must resolve to the same ledger entry; a changed hash on the same key raises a source-drift alert, never a silent overwrite.
Operational Action: Log every state transition to append-only storage with a UTC timestamp; never write pre-publication PCT content in the clear. Route EXPIRED/CANCELLED/FAILED jobs to a paralegal quarantine queue and gate any change to poll cadence or retention behind the Security & Access Control Boundaries model.
Frequently Asked Questions
Why does WIPO return HTTP 202 instead of the document I requested?
202 Accepted means WIPO has queued the request for background processing and returned a job_id, not the finished payload. The document only becomes available at the result_url once the job reaches a COMPLETED state. Treating the 202 as terminal — or assuming completion after a fixed sleep — is the most common cause of missed retrievals; only the status field should advance your state machine.
How fast can I poll the WIPO status endpoint without getting throttled?
Retry-After header and watch X-RateLimit-Remaining; when quota is low, widen intervals rather than opening more connections. Aggressive fixed-interval polling triggers IP-based throttling that affects your whole firm, not just one job.
What should happen when a WIPO job comes back EXPIRED?
EXPIRED terminal state means the job aged out of WIPO's retention before you retrieved it. Do not retry the dead job_id. Submit a fresh generation job or flag the record for paralegal intervention, and write the expiry to the audit ledger so the deadline owner can see why the document is not yet in the system.
Should the async poller ever calculate the 30-month national phase deadline itself?
What do I do when WIPO returns NOT_FOUND for a document I know exists?
NOT_FOUND or DOCUMENT_UNAVAILABLE, route to a fallback instead of failing the docket — for European filings, the EPO Register headless-browser adapter can supply supplementary bibliographic metadata. Run every fallback result through the same validation gate and log it separately from primary API metrics so the two sources never contaminate each other.
Related
- Patent Office Portal Sync & Data Ingestion — the acquisition pipeline this adapter feeds and the provenance contract every payload must satisfy.
- Implementing Exponential Backoff for Patent APIs — the full backoff-curve and jitter mathematics behind Step 2.
- WIPO Priority Document Sync with Python Requests — retrieving and cryptographically validating the completed priority document.
- Schema Validation & Error Categorization — the gate every COMPLETED payload passes through before it becomes a base date.
- EPO Register Headless Browser Fallback — the fallback path for records the WIPO API returns as unavailable.