EPO Register API Rate Limiting Strategies
EPO Register API rate limiting is the discipline of pacing register queries against the European Patent Office Open Patent Services (OPS) so that a docketing sync never trips the office’s per-service request quotas or its weekly fair-use volume cap — because a throttled or blocked OPS account silently stops the status updates that statutory deadlines depend on. This page specifies the exact throttling signals OPS returns, a single reference client that honours them, the failure modes unique to the register service, and how the pacing layer sits inside the broader EPO Register Sync Architecture.
Technical Specification: How OPS Actually Throttles
OPS does not expose the X-RateLimit-* family that many REST APIs use. Its throttling contract is published in the OPS version 3.2 documentation and the accompanying fair-use policy, and it works on two independent axes that a docketing client must track separately:
1. Per-minute, per-service request quotas — signalled by X-Throttling-Control. Every OPS response carries this header. Its shape is a system status word followed by a per-service breakdown:
X-Throttling-Control: busy (images=green:200, inpadoc=green:60, other=green:1000, retrieval=green:200, search=green:10)
- The leading word (
idle,busy, oroverloaded) is the whole-system state. - Each service —
images,inpadoc,other,retrieval,search— reports a colour (green,yellow,red) and an integer. The integer is the number of requests that service will accept in the next 60 seconds. Register bibliographic and legal-status pulls fall under theretrievalbucket; some register event queries fall underother. Ayelloworredcolour means the client must slow that specific service immediately, even while other services staygreen.
2. A weekly fair-use volume quota — measured in bytes, not requests. A registered free account is capped at roughly 4 GB of downloaded data per rolling week; paid tiers raise the ceiling. This axis is invisible in per-request headers — you only learn you have exhausted it when OPS returns HTTP 403 with a <fault> payload citing the fair-use policy. Bulk register audits blow through the volume cap long before they hit any per-minute limit, so a byte counter is as important as a request counter.
When the per-minute quota is exceeded OPS returns HTTP 403 (SERVER.DeveloperMessage: quota exceeded); when the whole system is overloaded it returns HTTP 503 with a Retry-After header (seconds). Treating Retry-After as authoritative and never overriding it is a compliance requirement under RFC 6585 §4 — ignoring it invites an IP-level temporary block that halts every docket, not just the offending query.
Minimal Reproducible Implementation
The reference client parses X-Throttling-Control, paces the retrieval bucket, respects server-directed backoff, and opens a circuit breaker on unrecoverable errors. It uses Python 3.11+ syntax with explicit type annotations.
import re
import time
import random
import logging
import requests
logger = logging.getLogger("epo_ops_register_sync")
# Matches "retrieval=green:200" style tokens inside X-Throttling-Control.
_SERVICE_RE = re.compile(r"(\w+)=(green|yellow|red):(\d+)")
def parse_throttle(header: str | None) -> tuple[str, dict[str, tuple[str, int]]]:
"""Return (system_status, {service: (colour, requests_next_minute)})."""
if not header:
return "unknown", {}
status = header.split("(", 1)[0].strip().lower()
services = {m[1]: (m[2], int(m[3])) for m in _SERVICE_RE.finditer(header)}
return status, services
class EPORegisterRateLimiter:
def __init__(self, base_delay: float = 1.0, max_retries: int = 5,
service: str = "retrieval") -> None:
self.base_delay = base_delay
self.max_retries = max_retries
self.service = service # register pulls use the retrieval bucket
self.circuit_open_until: float | None = None
def _backoff(self, attempt: int, retry_after: str | None) -> float:
# Server-directed Retry-After always wins (RFC 6585 §4).
if retry_after and retry_after.isdigit():
return float(retry_after)
# Full-jitter exponential backoff to avoid synchronised retry storms.
window = self.base_delay * (2 ** attempt)
return window + random.uniform(0, window)
def fetch_register(self, application_number: str, token: str) -> dict:
if self.circuit_open_until and time.time() < self.circuit_open_until:
raise RuntimeError("Circuit open: EPO OPS temporarily unavailable")
url = (
"https://ops.epo.org/3.2/rest-services/register/application/"
f"epodoc/{application_number}/biblio"
)
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
for attempt in range(self.max_retries):
try:
resp = requests.get(url, headers=headers, timeout=12)
status, services = parse_throttle(resp.headers.get("X-Throttling-Control"))
colour, remaining = services.get(self.service, ("green", 999))
# Proactively yield when our bucket is amber/red or nearly drained.
if colour != "green" or remaining <= 5:
logger.warning("Throttle pressure: %s=%s:%s (system %s)",
self.service, colour, remaining, status)
time.sleep(self._backoff(attempt, None))
continue
if resp.status_code == 200:
return resp.json()
if resp.status_code in (403, 429, 503):
# 403 here is a per-minute or fair-use quota rejection.
delay = self._backoff(attempt, resp.headers.get("Retry-After"))
logger.info("Quota rejection %s; backing off %.2fs (try %d/%d)",
resp.status_code, delay, attempt + 1, self.max_retries)
time.sleep(delay)
continue
resp.raise_for_status()
except requests.exceptions.Timeout:
time.sleep(self._backoff(attempt, None))
except requests.exceptions.RequestException as exc:
self.circuit_open_until = time.time() + 300 # 5-minute circuit break
logger.critical("Circuit breaker tripped: %s", exc)
raise
raise RuntimeError(f"Max retries exceeded for {application_number}")
Register requests must be classified by statutory urgency before they reach this client, so a deadline-critical pull is never starved by a background portfolio audit. Encode the tiers as version-controlled config rather than inline constants:
# epo_ops_request_tiers.yaml
# Source: EPO OPS 3.2 fair-use policy (per-minute + weekly volume quotas).
# https://www.epo.org/en/searching-for-patents/data/web-services/ops
tiers:
critical: # opposition, EPC Rule 71(3) grant, renewal grace windows
max_per_minute: 20
retry_immediately_on_403: true
standard: # routine legal-status and family checks
max_per_minute: 40
queue_backed: true
bulk: # historical audits, reconciliation — off-peak only
window_cet: "18:00-07:00"
max_per_minute: 12
counts_against_weekly_volume: true
Known Gotchas & Compliance Traps
HTTP 403mistaken for an auth failure. A 403 from OPS is far more often a per-minute or weekly-volume quota rejection than a bad token. Parse the<fault>body: aquota exceeded/ fair-use message means back off, not re-authenticate. Blindly refreshing the OAuth token on 403 produces a retry storm that accelerates the block.- Concurrent workers sharing one account, not one limiter. OPS quotas are enforced per account, so several worker processes each running their own
EPORegisterRateLimiterwill collectively overshootretrievalwhile each believes it is compliant. Enforce a single distributed counter (e.g. a Redis token bucket keyed by service) across every node before any request leaves the pool. - Weekly volume exhaustion masquerading as an outage. Because the fair-use cap is byte-based, a week of large biblio payloads can trip a persistent 403 that looks like an EPO incident. Meter downloaded bytes per rolling week and alert at ~80% of the tier ceiling; route bulk reconciliation to off-peak windows so it never competes with deadline-critical pulls.
- Ignoring the service colour and reading only the count. A
retrieval=yellow:200still reports 200 requests remaining, but yellow signals the system is degrading that service — continuing at full rate is how a client walks straight intoredand then a 503. Gate on colour first, count second.
Integration Point
This pacing layer is the ingestion valve for the parent EPO Register Sync Architecture: it sits between the OAuth client-credentials flow and the schema-normalization stage, so every throttle event, retry, and quota rejection is captured before payloads reach the deterministic rule engine. The backoff mathematics here are the register-specific application of the general pattern documented in Implementing Exponential Backoff for Patent APIs, and the same full-jitter discipline underpins the WIPO API Async Polling Patterns used elsewhere in the pipeline.
When retries are exhausted the client must degrade without losing data or compliance traceability. Serve the last-known-good register state from cache flagged sync_status: STALE, push those records to a paralegal review queue with explicit deadline warnings, and — for repeated register unavailability — hand off to the EPO Register Headless Browser Fallback path rather than hammering the throttled API. Every rate-limit event, retry, and fallback trigger is written to an append-only audit trail governed by the Security & Access Control Boundaries that decide who may read or override a stale deadline, and the whole degradation sequence follows the Patent Docket Fallback Routing System. Debugging starts from those logs — parse the throttle headers alongside request IDs, for example grep -E "(X-Throttling-Control|Retry-After|status_code)" /var/log/epo_sync/*.log. Never suppress a 403/503 or mask a retry failure in production logs; doing so obscures the root-cause record a statutory-deadline dispute will demand.
Frequently Asked Questions
Which X-Throttling-Control service bucket do register queries count against?
retrieval bucket; some event-list queries are billed against other. Read the colour and integer for that specific service rather than the leading system word — a busy system can still serve retrieval=green:200, and an idle system can still show retrieval=yellow if you have been hammering that one endpoint.
Why am I getting HTTP 403 when my OAuth token is valid?
<fault> body: a per-minute overshoot or a weekly fair-use (volume) breach both return 403. Back off and, for volume breaches, pause bulk traffic until the rolling week resets — do not refresh the token in a loop, which only accelerates an IP block.
How do I stay inside the weekly fair-use volume cap during a large portfolio audit?
Should a distributed docketing deployment share one rate limiter?
service) that every worker decrements before sending a request, so the whole deployment paces as a single client against the retrieval and other buckets.