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, or overloaded) 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 the retrieval bucket; some register event queries fall under other. A yellow or red colour means the client must slow that specific service immediately, even while other services stay green.

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.

The two independent axes of EPO OPS throttling and the client pacing gate Axis one is the per-minute, per-service quota signalled by the X-Throttling-Control header: a colour ladder for the retrieval bucket that degrades green to yellow to red, where red returns HTTP 403. Axis two is the weekly fair-use volume measured in bytes, a gauge filling toward a 4 GB cap that also returns HTTP 403 when exceeded. Both axes feed a single client pacing gate that decides between three outcomes: proceed, throttle and back off, or open the circuit breaker and fall back. Axis 1 · per-minute per-service quota — X-Throttling-Control green retrieval=green:200 · proceed yellow slow this service now red → HTTP 403 quota exceeded A service degrades left → right independently of the leading system word Axis 2 · weekly fair-use volume (bytes) 4 GB weekly cap → HTTP 403 80% alert — pause bulk audits current ≈ 62% of cap 0 bytes · rolling-week start Client pacing gate gate on colour, then count + meter weekly bytes Retry-After always wins Proceed colour green · bytes < cap Throttle · back off yellow/red or Retry-After Circuit-break repeat 403/503 → fallback

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 403 mistaken 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: a quota 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 EPORegisterRateLimiter will collectively overshoot retrieval while 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:200 still reports 200 requests remaining, but yellow signals the system is degrading that service — continuing at full rate is how a client walks straight into red and 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?
Register bibliographic and legal-status retrievals fall under the 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?
On OPS a 403 usually means a quota rejection, not an authentication failure. Parse the <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?
Meter downloaded bytes per rolling week, not just request counts. Schedule bulk audits in the off-peak CET window, request only the biblio sections you need, and cache aggressively so repeated status checks do not re-download unchanged payloads. Alert at roughly 80% of your tier's volume ceiling so an audit can be paused before it triggers a persistent 403.
Should a distributed docketing deployment share one rate limiter?
Yes. OPS enforces quotas per account, so per-process limiters collectively overshoot. Use a shared token bucket (for example Redis keyed by service) that every worker decrements before sending a request, so the whole deployment paces as a single client against the retrieval and other buckets.