Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cli-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ jobs:
- name: Install locked dependencies
run: uv sync --locked --all-groups
- name: Exercise real CNMV filings
timeout-minutes: 17
env:
CNMV_LIVE_TESTS: '1'
run: uv run --no-sync pytest -v -s tests/test_live.py
4 changes: 3 additions & 1 deletion skills/cnmv-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ terminal(command="uv run cnmv filing compare --older-url 'https://www.cnmv.es/
- Exit code `1` with an error containing `must use HTTPS on cnmv.es` means the source URL or a redirect is outside the allowed official origin. Obtain a URL from `filing list`; do not bypass the guard.
- `unsupported content type` means the response is not HTML/XHTML. Select a `consolidated_xhtml` or `individual_xhtml` URL instead of a ZIP/PDF endpoint.
- `HTML/XHTML document has no body` or `has no visible text` means the response cannot be compared safely. Preserve the failing URL and content type; do not present a partial delta.
- `CNMV request failed` is an upstream/network failure. Retry once after a short delay, then report the failure; do not substitute another source. For `CNMV page is missing`, `multiple ... tables`, or `filing row is missing required cells`, stop and report a CNMV page-format failure; do not retry or guess fields.
- `CNMV request failed: [retryable]` identifies a timeout, connection/transport failure, or temporary HTTP status (408, 429, 500, 502, 503, 504). Retry once after 10 seconds, then report the failure; do not substitute another source. Request errors include the exception type and URL. Errors without this marker, including permanent HTTP errors, certificate failures, and malformed pages, should not be retried.

## Development Verification

Expand All @@ -82,6 +82,8 @@ terminal(command="uv sync --all-groups && uv run pytest -q && uv run ruff check

For a live end-to-end check, first call `filing list`, retain the selected metadata, then compare two real `consolidated_xhtml` URLs with a disposable database directory. Remove generated database directories before staging.

`CNMV_LIVE_TESTS=1 uv run pytest -v -s tests/test_live.py` runs the live smoke plus offline tests of its retry/deadline handling. The smoke shares a 16-minute budget across list, download, compare, and retry delays; the Actions test step is capped at 17 minutes within the 20-minute job. Each attempt logs its limit, duration, and exit status. A subprocess timeout fails immediately with captured output; it does not start another attempt.

## Pitfalls

- CNMV's listing page can expose the same issuer under hyphenated and compact `A`-prefixed NIF forms; `filing list` tries the alternate representation for this pattern. Do not normalize other NIF forms yourself.
Expand Down
2 changes: 1 addition & 1 deletion src/cnmv_cli/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def download_document(url: str, output: Path) -> dict[str, object]:
except (InvalidDocumentUrl, UpstreamError):
raise
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise UpstreamError(f"CNMV request failed: {exc}") from exc
raise UpstreamError.from_request(exc, current_url) from exc


def _write_response(
Expand Down
52 changes: 51 additions & 1 deletion src/cnmv_cli/filings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from __future__ import annotations

import errno
import http.client
import re
import socket
import ssl
import unicodedata
import urllib.error
from urllib.parse import urljoin

import httpx
Expand All @@ -19,6 +24,51 @@ class MalformedPageError(ValueError):
class UpstreamError(RuntimeError):
"""CNMV could not be reached successfully."""

@classmethod
def from_request(cls, exc: Exception, url: str) -> UpstreamError:
reason = (
exc.reason
if isinstance(exc, urllib.error.URLError)
and not isinstance(exc, urllib.error.HTTPError)
else exc
)
retryable = (
isinstance(
reason,
(
httpx.TimeoutException,
httpx.NetworkError,
httpx.RemoteProtocolError,
TimeoutError,
ConnectionError,
http.client.IncompleteRead,
),
)
or (
isinstance(reason, OSError)
and reason.errno in {errno.ENETUNREACH, errno.EHOSTUNREACH}
)
or (
isinstance(reason, socket.gaierror) and reason.errno == socket.EAI_AGAIN
)
)
if isinstance(exc, (httpx.HTTPStatusError, urllib.error.HTTPError)):
status = (
exc.response.status_code
if isinstance(exc, httpx.HTTPStatusError)
else exc.code
)
retryable = status in {408, 429, 500, 502, 503, 504}
# Certificate failures are permanent, even when wrapped as connection errors.
cause = reason
while isinstance(cause, BaseException):
if isinstance(cause, ssl.SSLError):
retryable = False
cause = cause.__cause__ or cause.__context__
marker = "[retryable] " if retryable else ""
kind = type(reason if isinstance(reason, BaseException) else exc).__name__
return cls(f"CNMV request failed: {marker}{kind} at {url}: {exc}")


def fetch_filings(
nif: str, *, transport: httpx.BaseTransport | None = None
Expand All @@ -43,7 +93,7 @@ def fetch_filings(
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise UpstreamError(f"CNMV request failed: {exc}") from exc
raise UpstreamError.from_request(exc, str(exc.request.url)) from exc

try:
return parse_filings(response.content, str(response.url))
Expand Down
2 changes: 2 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
from pathlib import Path

import httpx
from typer.testing import CliRunner

from cnmv_cli import cli
Expand Down Expand Up @@ -51,6 +52,7 @@ def fake_fetch(nif: str):
def test_filing_list_returns_nonzero_for_clear_service_errors(monkeypatch) -> None:
for error in (
UpstreamError("CNMV request failed: 503"),
UpstreamError.from_request(httpx.ReadTimeout(""), "https://www.cnmv.es/list"),
MalformedPageError("missing table"),
):
monkeypatch.setattr(
Expand Down
45 changes: 42 additions & 3 deletions tests/test_download.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import errno
import hashlib
import socket
import urllib.error
from collections.abc import Iterable
from typing import Self
Expand Down Expand Up @@ -213,14 +215,51 @@ def test_download_document_stream_cap_preserves_existing_output_and_cleans_temp(
assert list(tmp_path.glob(".*.part")) == []


def test_download_document_wraps_url_errors(monkeypatch, tmp_path) -> None:
@pytest.mark.parametrize(
("error", "retryable", "kind"),
[
(urllib.error.URLError("offline"), False, "URLError"),
(urllib.error.URLError(TimeoutError("timed out")), True, "TimeoutError"),
(
urllib.error.URLError(OSError(errno.ENETUNREACH, "unreachable")),
True,
"OSError",
),
(
urllib.error.URLError(
socket.gaierror(socket.EAI_AGAIN, "temporary DNS failure")
),
True,
"gaierror",
),
(
urllib.error.URLError(socket.gaierror(socket.EAI_NONAME, "unknown host")),
False,
"gaierror",
),
(ConnectionResetError("reset"), True, "ConnectionResetError"),
(PermissionError("denied"), False, "PermissionError"),
(
FileNotFoundError(errno.ENOENT, "missing directory"),
False,
"FileNotFoundError",
),
],
)
def test_download_document_wraps_url_errors(
monkeypatch, tmp_path, error, retryable, kind
) -> None:
class FailingOpener:
def open(self, request: object, *, timeout: float) -> None:
raise urllib.error.URLError("offline")
raise error

monkeypatch.setattr(
download.urllib.request, "build_opener", lambda *args: FailingOpener()
)

with pytest.raises(download.UpstreamError, match="CNMV request failed:.*offline"):
with pytest.raises(download.UpstreamError, match="CNMV request failed:") as caught:
download_document(DOCUMENT_URL, tmp_path / "document")
message = str(caught.value)
assert ("[retryable]" in message) == retryable
assert kind in message
assert DOCUMENT_URL in message
43 changes: 41 additions & 2 deletions tests/test_filings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import ssl
import urllib.error

import httpx
import pytest

Expand Down Expand Up @@ -170,20 +173,56 @@ def handler(request: httpx.Request) -> httpx.Response:
]


@pytest.mark.parametrize("failure", ["status", "connection"])
@pytest.mark.parametrize("failure", ["status", "connection", "timeout"])
def test_fetch_filings_does_not_retry_upstream_failures(failure: str) -> None:
requested_nifs: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
requested_nifs.append(request.url.params["nif"])
if failure == "connection":
raise httpx.ConnectError("offline", request=request)
if failure == "timeout":
raise httpx.ReadTimeout("", request=request)
return httpx.Response(503, request=request)

with pytest.raises(UpstreamError, match="CNMV request failed"):
with pytest.raises(
UpstreamError, match="CNMV request failed: \\[retryable\\]"
) as error:
fetch_filings("A12345678", transport=httpx.MockTransport(handler))

assert requested_nifs == ["A12345678"]
assert (
"https://www.cnmv.es/portal/consultas/ifa/listadoifa?id=0&lang=es&nif=A12345678"
in str(error.value)
)
if failure == "timeout":
assert "ReadTimeout" in str(error.value)


@pytest.mark.parametrize(
"status", [400, 401, 403, 404, 408, 429, 500, 501, 502, 503, 504]
)
def test_request_error_classifies_http_status_for_both_clients(status: int) -> None:
response = httpx.Response(status, request=httpx.Request("GET", PAGE_URL))
errors = (
httpx.HTTPStatusError(
"HTTP failure", request=response.request, response=response
),
urllib.error.HTTPError(PAGE_URL, status, "HTTP failure", {}, None),
)
for error in errors:
message = str(UpstreamError.from_request(error, PAGE_URL))
assert ("[retryable]" in message) == (status in {408, 429, 500, 502, 503, 504})
assert type(error).__name__ in message
assert PAGE_URL in message


def test_request_error_does_not_retry_certificate_failures() -> None:
certificate_error = ssl.SSLCertVerificationError("certificate verify failed")
httpx_error = httpx.ConnectError("certificate verify failed")
httpx_error.__cause__ = certificate_error
for error in (httpx_error, urllib.error.URLError(certificate_error)):
assert "[retryable]" not in str(UpstreamError.from_request(error, PAGE_URL))


def test_fetch_filings_does_not_retry_other_malformed_pages() -> None:
Expand Down
Loading
Loading