Skip to content

API reference

Auto-generated from docstrings in the ovh_voip_spam_filter package.

OVH client

ovh_voip_spam_filter.ovh_api

Minimal OVH API client (stdlib only).

Implements OVH's "$1$" signed-request scheme:

signature = "$1$" + sha1(
    application_secret + "+" + consumer_key + "+" +
    METHOD + "+" + URL + "+" + BODY + "+" + TIMESTAMP
).hexdigest()

Designed for a non-interactive cron/K8s context: - Client-side throttle between calls (default 1 req/s, configurable). - 429 backoff respecting Retry-After header, with jittered exponential fallback and an absolute cap. - Auto-adapts the throttle: after 3 consecutive 429s the inter-call interval is doubled for the rest of the session (no manual tuning required if OVH's per-account quota differs from the documented 60/min).

OvhClient

Source code in src/ovh_voip_spam_filter/ovh_api.py
class OvhClient:
    def __init__(
        self,
        credentials: OvhCredentials,
        min_interval_seconds: float = 0.0,
        timeout: float = DEFAULT_TIMEOUT_SECONDS,
        max_retries_on_429: int = DEFAULT_MAX_RETRIES_ON_429,
        _sleep: Callable[[float], None] = time.sleep,
        _monotonic: Callable[[], float] = time.monotonic,
        _rand: Callable[[], float] = random.random,
    ) -> None:
        self.credentials = credentials
        self.min_interval_seconds = float(min_interval_seconds)
        self.timeout = timeout
        self.max_retries_on_429 = max_retries_on_429
        self._sleep = _sleep
        self._monotonic = _monotonic
        self._rand = _rand
        self._last_call_at: float = 0.0
        self._consecutive_429: int = 0
        self._throttle_adaptations: int = 0

    @property
    def total_throttle_adaptations(self) -> int:
        """Number of times min_interval_seconds was auto-doubled this session."""
        return self._throttle_adaptations

    # ---------- low-level signed request ----------

    def request(
        self,
        method: str,
        path: str,
        body: dict[str, Any] | None = None,
        signed: bool = True,
    ) -> Any:
        url = self.credentials.base_url() + path
        body_str = json.dumps(body) if body is not None else ""

        if signed and self.min_interval_seconds > 0:
            gap = self._monotonic() - self._last_call_at
            sleep_for = self.min_interval_seconds - gap
            if sleep_for > 0:
                self._sleep(sleep_for)

        attempt = 0
        backoff = 1.0
        while True:
            headers = self._headers(method, url, body_str, signed=signed)
            req = urllib.request.Request(
                url=url,
                data=body_str.encode("utf-8") if body_str else None,
                headers=headers,
                method=method,
            )
            try:
                with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                    self._last_call_at = self._monotonic()
                    self._consecutive_429 = 0
                    raw = resp.read()
                    if not raw:
                        return None
                    return json.loads(raw.decode("utf-8"))
            except urllib.error.HTTPError as exc:
                self._last_call_at = self._monotonic()
                raw = (exc.read() or b"").decode("utf-8", errors="replace")
                if exc.code == 429 and attempt < self.max_retries_on_429:
                    attempt += 1
                    self._consecutive_429 += 1
                    sleep_for = self._compute_429_sleep(exc.headers.get("Retry-After"), backoff)
                    backoff = min(backoff * 2, BACKOFF_CAP_SECONDS)
                    self._maybe_adapt_throttle()
                    logger.warning(
                        "429 on %s %s, sleeping %.1fs (attempt %d/%d, consecutive=%d)",
                        method,
                        path,
                        sleep_for,
                        attempt,
                        self.max_retries_on_429,
                        self._consecutive_429,
                    )
                    self._sleep(sleep_for)
                    continue
                message = self._extract_message(raw) or exc.reason
                raise OvhApiError(exc.code, message, raw) from exc
            except urllib.error.URLError as exc:
                raise OvhApiError(0, f"network error: {exc}", "") from exc

    def get(self, path: str) -> Any:
        return self.request("GET", path)

    def post(self, path: str, body: dict[str, Any]) -> Any:
        return self.request("POST", path, body=body)

    def put(self, path: str, body: dict[str, Any]) -> Any:
        return self.request("PUT", path, body=body)

    def delete(self, path: str) -> Any:
        return self.request("DELETE", path)

    # ---------- backoff helpers ----------

    def _compute_429_sleep(self, retry_after_header: str | None, backoff: float) -> float:
        if retry_after_header:
            try:
                return max(float(retry_after_header), 0.5)
            except ValueError:
                pass  # falls through to jittered exponential
        jitter = 1.0 + (self._rand() * 0.4 - 0.2)  # ±20%
        return float(min(backoff * jitter, BACKOFF_CAP_SECONDS))

    def _maybe_adapt_throttle(self) -> None:
        if self._consecutive_429 >= AUTO_ADAPT_429_THRESHOLD and self.min_interval_seconds > 0:
            old = self.min_interval_seconds
            self.min_interval_seconds = min(old * 2.0, 10.0)
            self._consecutive_429 = 0
            self._throttle_adaptations += 1
            logger.warning(
                "Auto-adapting throttle after %d consecutive 429s: %.2fs -> %.2fs",
                AUTO_ADAPT_429_THRESHOLD,
                old,
                self.min_interval_seconds,
            )

    # ---------- signature ----------

    def _headers(self, method: str, url: str, body_str: str, signed: bool) -> dict[str, str]:
        headers = {
            "X-Ovh-Application": self.credentials.application_key,
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": USER_AGENT,
        }
        if not signed:
            return headers
        if self.credentials.consumer_key is None:
            raise ValueError("Consumer key is required for signed requests")
        timestamp = str(int(time.time()))
        to_sign = "+".join(
            [
                self.credentials.application_secret,
                self.credentials.consumer_key,
                method,
                url,
                body_str,
                timestamp,
            ]
        )
        signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest()
        headers["X-Ovh-Consumer"] = self.credentials.consumer_key
        headers["X-Ovh-Timestamp"] = timestamp
        headers["X-Ovh-Signature"] = signature
        return headers

    @staticmethod
    def _extract_message(raw: str) -> str | None:
        try:
            payload = json.loads(raw)
        except (json.JSONDecodeError, ValueError):
            return None
        if isinstance(payload, dict):
            return payload.get("message") or payload.get("error")
        return None

    # ---------- high-level telephony helpers ----------

    def whoami(self) -> dict[str, Any]:
        return self.get("/me") or {}

    def list_billing_accounts(self) -> list[str]:
        return self.get("/telephony") or []

    def list_screen_services(self, billing_account: str) -> list[str]:
        return self.get(f"/telephony/{billing_account}/screen") or []

    def get_screen_state(self, billing_account: str, service_name: str) -> dict[str, Any]:
        return self.get(f"/telephony/{billing_account}/screen/{service_name}") or {}

    def set_screen_state(
        self,
        billing_account: str,
        service_name: str,
        *,
        incoming: str | None = None,
        outgoing: str | None = None,
    ) -> None:
        body: dict[str, Any] = {}
        if incoming is not None:
            body["incomingScreenList"] = incoming
        if outgoing is not None:
            body["outgoingScreenList"] = outgoing
        if not body:
            return
        self.put(f"/telephony/{billing_account}/screen/{service_name}", body)

    def list_screen_list_ids(self, billing_account: str, service_name: str) -> list[int]:
        return self.get(f"/telephony/{billing_account}/screen/{service_name}/screenLists") or []

    def get_screen_list_entry(
        self, billing_account: str, service_name: str, entry_id: int
    ) -> dict[str, Any]:
        return (
            self.get(f"/telephony/{billing_account}/screen/{service_name}/screenLists/{entry_id}")
            or {}
        )

    def add_screen_list_entry(
        self,
        billing_account: str,
        service_name: str,
        *,
        call_number: str,
        nature: str = "international",
        type_: str = "incomingBlackList",
    ) -> None:
        self.post(
            f"/telephony/{billing_account}/screen/{service_name}/screenLists",
            body={"callNumber": call_number, "nature": nature, "type": type_},
        )

    def delete_screen_list_entry(
        self, billing_account: str, service_name: str, entry_id: int
    ) -> None:
        self.delete(f"/telephony/{billing_account}/screen/{service_name}/screenLists/{entry_id}")

total_throttle_adaptations property

Number of times min_interval_seconds was auto-doubled this session.

Reconciliation pipeline

ovh_voip_spam_filter.reconcile

Reconciliation pipeline: OVH state ← target patterns (Saracroche or ARCEP fallback).

Two modes, determined at runtime by Saracroche reachability:

Normal mode (Saracroche fetched OK): Saracroche is the source of truth. to_add = target - current (POST) to_remove = current - target (DELETE) Strict sync.

Degraded mode (Saracroche unreachable): Fall back to hard-coded ARCEP prefixes (23 entries, 100% démarchage légal FR). to_add = arcep - current (POST) to_remove = [] (NEVER delete — partial knowledge could regress) Additive-only.

The dry-run variant returns the plan without applying it.

CurrentEntry dataclass

An existing screenList entry on OVH side.

Source code in src/ovh_voip_spam_filter/reconcile.py
@dataclass(frozen=True)
class CurrentEntry:
    """An existing screenList entry on OVH side."""

    id: int
    call_number: str
    nature: str
    type: str

ensure_blacklist_enabled(client, billing_account, service_name)

If incomingScreenList is 'disabled', PUT it to 'blacklist' preserving outgoing.

Per design (validated with user): auto-activate, never block, WARN log.

Source code in src/ovh_voip_spam_filter/reconcile.py
def ensure_blacklist_enabled(client: OvhClient, billing_account: str, service_name: str) -> None:
    """If incomingScreenList is 'disabled', PUT it to 'blacklist' preserving outgoing.

    Per design (validated with user): auto-activate, never block, WARN log.
    """
    state = client.get_screen_state(billing_account, service_name)
    incoming = state.get("incomingScreenList")
    outgoing = state.get("outgoingScreenList")
    if incoming == "blacklist":
        logger.info("Screen state: incoming=blacklist (already enabled)")
        return
    logger.warning(
        "Screen state: incoming=%s outgoing=%s — auto-activating incoming=blacklist "
        "(preserving outgoing=%s)",
        incoming,
        outgoing,
        outgoing,
    )
    client.set_screen_state(
        billing_account,
        service_name,
        incoming="blacklist",
        outgoing=outgoing,
    )

load_current(client, billing_account, service_name)

Read every existing screenList entry. N + 1 GETs (throttled by client).

Source code in src/ovh_voip_spam_filter/reconcile.py
def load_current(client: OvhClient, billing_account: str, service_name: str) -> list[CurrentEntry]:
    """Read every existing screenList entry. N + 1 GETs (throttled by client)."""
    ids = client.list_screen_list_ids(billing_account, service_name)
    logger.info("OVH has %d existing screenList entries; fetching details", len(ids))
    out: list[CurrentEntry] = []
    for entry_id in ids:
        d = client.get_screen_list_entry(billing_account, service_name, entry_id)
        out.append(
            CurrentEntry(
                id=int(d.get("id", entry_id)),
                call_number=str(d.get("callNumber", "")),
                nature=str(d.get("nature", "")),
                type=str(d.get("type", "")),
            )
        )
    return out

load_target()

Saracroche live → fallback to hard-coded ARCEP. Cache is not used here.

The Phase-1 file cache is bypassed in K8s/cron context: container is ephemeral, Saracroche is cheap to refetch (~150 KB JSON). The CLI mode (Phase 1) still has its own cached path via generate.

Source code in src/ovh_voip_spam_filter/reconcile.py
def load_target() -> TargetState:
    """Saracroche live → fallback to hard-coded ARCEP. Cache is not used here.

    The Phase-1 file cache is bypassed in K8s/cron context: container is
    ephemeral, Saracroche is cheap to refetch (~150 KB JSON). The CLI mode
    (Phase 1) still has its own cached path via `generate`.
    """
    try:
        snap = saracroche.fetch_from_api()
        patterns = snap.block_patterns()
        logger.info(
            "Saracroche live: version=%s, %d patterns total, %d block-only",
            snap.version,
            snap.total_patterns,
            len(patterns),
        )
        return TargetState(mode=SyncMode.NORMAL, patterns=patterns, saracroche_version=snap.version)
    except saracroche.FetchError as exc:
        logger.warning(
            "Saracroche unreachable (%s) — degraded mode with hard-coded ARCEP",
            exc,
        )
        return TargetState(
            mode=SyncMode.DEGRADED,
            patterns=arcep.arcep_fallback_patterns(),
            saracroche_version=None,
        )

summarize_plan(plan, rate_limit_ms)

Human-readable summary of the plan (for dry-run output).

Source code in src/ovh_voip_spam_filter/reconcile.py
def summarize_plan(plan: ReconcilePlan, rate_limit_ms: int) -> str:
    """Human-readable summary of the plan (for dry-run output)."""
    n_ops = len(plan.to_add) + len(plan.to_remove)
    estimated_seconds = n_ops * (rate_limit_ms / 1000.0)
    return (
        f"mode={plan.mode.value} "
        f"target={len(plan.target_prefixes)} prefixes  "
        f"existing={len(plan.current_entries)} entries  "
        f"to_add={len(plan.to_add)}  "
        f"to_remove={len(plan.to_remove)}  "
        f"~estimated_duration={estimated_seconds:.0f}s"
    )

Saracroche client

ovh_voip_spam_filter.saracroche

Client for the Saracroche community blocklist API.

Endpoint: GET https://saracroche.org/api/v1/lists/french-list-arcep-operators Auth: none Schema: { version, name, license, description, blocked_numbers_count, patterns: [{name, pattern, action}] }

Source cascade (see load_block_patterns): 1. Live API 2. Local cache file (last successful snapshot) 3. Caller decides what to do with the FetchError (CLI falls back to ARCEP)

Configuration

ovh_voip_spam_filter.config

Credentials and runtime configuration loader.

Precedence (highest first): 1. CLI flags (handled in cli.py) 2. Environment variables (operational mode: Docker / K8s) 3. JSON config file (developer convenience, default .ovh-credentials.json)

Env vars

OVH_ENDPOINT ovh-eu | ovh-ca | ovh-us (default: ovh-eu) OVH_APPLICATION_KEY (required for signed requests) OVH_APPLICATION_SECRET (required for signed requests) OVH_CONSUMER_KEY (required for signed requests) OVH_BILLING_ACCOUNT target billing account (required for sync) OVH_SERVICE_NAME target SIP line (required for sync) RATE_LIMIT_MS min ms between calls (default: 1000) LOG_LEVEL DEBUG | INFO | WARNING | ERROR (default: INFO)

require_signed_credentials(config)

Validate that we have everything needed for signed OVH API calls.

Raises ConfigError with a clear remediation message if any is missing.

Source code in src/ovh_voip_spam_filter/config.py
def require_signed_credentials(config: AppConfig) -> OvhCredentials:
    """Validate that we have everything needed for signed OVH API calls.

    Raises ConfigError with a clear remediation message if any is missing.
    """
    creds = config.credentials
    missing = []
    if not creds.application_key:
        missing.append("OVH_APPLICATION_KEY")
    if not creds.application_secret:
        missing.append("OVH_APPLICATION_SECRET")
    if not creds.consumer_key:
        missing.append("OVH_CONSUMER_KEY")
    if missing:
        raise ConfigError(
            f"Missing required OVH credentials: {', '.join(missing)}. "
            f"Generate them at https://api.ovh.com/createToken/ (see README → 'OVH API setup')."
        )
    return creds

require_target(config)

Validate billing_account + service_name presence (required for sync).

Source code in src/ovh_voip_spam_filter/config.py
def require_target(config: AppConfig) -> tuple[str, str]:
    """Validate billing_account + service_name presence (required for sync)."""
    if not config.billing_account or not config.service_name:
        raise ConfigError(
            "Missing OVH_BILLING_ACCOUNT and/or OVH_SERVICE_NAME. "
            "Run `discover` to list them, then set them in env or config."
        )
    return config.billing_account, config.service_name

Patterns

ovh_voip_spam_filter.patterns

BlockPattern dataclass

A pattern flagged for blocking.

raw is the Saracroche-style pattern in international form without + and with # as trailing wildcards (e.g. 33162######). name is the human label from the source (Préfixe démarchage ARCEP, etc.).

Source code in src/ovh_voip_spam_filter/patterns.py
@dataclass(frozen=True)
class BlockPattern:
    """A pattern flagged for blocking.

    `raw` is the Saracroche-style pattern in international form without `+`
    and with `#` as trailing wildcards (e.g. `33162######`).
    `name` is the human label from the source (`Préfixe démarchage ARCEP`, etc.).
    """

    name: str
    raw: str

    def to_ovh_prefix(self) -> str:
        digits = self.raw.rstrip("#")
        return f"+{digits}"

    @property
    def is_arcep(self) -> bool:
        return "ARCEP" in self.name

deduplicate_ovh_prefixes(prefixes)

Drop prefixes that are subsumed by a shorter prefix already present.

OVH matches by prefix, so +33162345 is redundant if +33162 is also in the list. Preserves the order of first occurrence.

Source code in src/ovh_voip_spam_filter/patterns.py
def deduplicate_ovh_prefixes(prefixes: list[str]) -> list[str]:
    """Drop prefixes that are subsumed by a shorter prefix already present.

    OVH matches by prefix, so `+33162345` is redundant if `+33162` is also in the list.
    Preserves the order of first occurrence.
    """
    kept: list[str] = []
    for p in prefixes:
        if any(p != k and p.startswith(k) for k in kept):
            continue
        kept.append(p)
    return kept

prioritize(patterns)

Stable sort that puts ARCEP entries first, preserving input order within each group.

Used so that --max-entries N truncation always keeps the ARCEP coverage critical to the user's reported numbers.

Source code in src/ovh_voip_spam_filter/patterns.py
def prioritize(patterns: list[BlockPattern]) -> list[BlockPattern]:
    """Stable sort that puts ARCEP entries first, preserving input order within each group.

    Used so that `--max-entries N` truncation always keeps the ARCEP coverage critical
    to the user's reported numbers.
    """
    arcep = [p for p in patterns if p.is_arcep]
    other = [p for p in patterns if not p.is_arcep]
    return arcep + other

ARCEP fallback

ovh_voip_spam_filter.arcep

Hard-coded ARCEP démarchage prefix ranges.

Source: ARCEP numbering plan decision (effective 2022-09-01). This is the last-resort fallback when the Saracroche API is unreachable and no local cache is available.

Each prefix is the international form (33...) and matches any number starting with that sequence. Saracroche's main list is a superset.