quantilica-cli 0.11.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/CHANGELOG.md +11 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/PKG-INFO +1 -1
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/pyproject.toml +1 -1
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/sdk.py +92 -22
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk.py +83 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_force_probe.py +89 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.githooks/pre-commit +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.githooks/pre-push +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.github/workflows/publish.yml +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.github/workflows/test.yml +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/.gitignore +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/LICENSE +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/README.md +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/__init__.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/cli.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/health.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/manifests.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/sources.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/cli/ui.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/src/quantilica/py.typed +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/__init__.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_health.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_manifests.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_package_hygiene.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_mes_string.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sdk_onda_a2.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_sources.py +0 -0
- {quantilica_cli-0.11.0 → quantilica_cli-0.12.0}/tests/test_ui.py +0 -0
|
@@ -5,6 +5,17 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
|
|
5
5
|
O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
|
6
6
|
e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
|
|
7
7
|
|
|
8
|
+
## [0.12.0] - 2026-10-19
|
|
9
|
+
|
|
10
|
+
### Adicionado
|
|
11
|
+
|
|
12
|
+
- `FetcherApp(source_id=...)`: `source_id` explícito, gravado nos manifests de download. Sem ele, continua derivado de `name` sem o sufixo `-fetcher`, e o `default_output` padrão passa a ser `/data/{source_id}`.
|
|
13
|
+
|
|
14
|
+
### Corrigido
|
|
15
|
+
|
|
16
|
+
- `check`: o probe `GET Range` tenta de novo (com backoff) em 429/5xx e em erros de transporte (`DEFAULT_RETRY_EXCEPTIONS`: timeout, reset de conexão) antes de desistir. Antes, uma falha transitória virava 404 e a URL saía como indisponível. Quando o probe desiste, a causa é logada em `WARNING` (`[check] probe Range falhou para <url> após N tentativa(s): ...`), e não mais em `DEBUG`.
|
|
17
|
+
- `check`: uma exceção inesperada em uma entrada (por exemplo, `OSError` ao montar o caminho local) não aborta mais o `check` inteiro com código 1. A entrada é verificada de novo uma vez e, se falhar outra vez, vira `download` com `reason=check-error: ...`, e o plano segue cobrindo as demais.
|
|
18
|
+
|
|
8
19
|
## [0.11.0] - 2026-10-14
|
|
9
20
|
|
|
10
21
|
### Adicionado
|
|
@@ -12,6 +12,7 @@ import email.utils
|
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
14
|
import threading
|
|
15
|
+
import time
|
|
15
16
|
from collections.abc import Callable, Iterable
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from importlib.metadata import entry_points
|
|
@@ -22,6 +23,7 @@ import typer
|
|
|
22
23
|
from quantilica.core.exceptions import FetchError
|
|
23
24
|
from quantilica.core.ftp import FtpClient
|
|
24
25
|
from quantilica.core.http import (
|
|
26
|
+
DEFAULT_RETRY_EXCEPTIONS,
|
|
25
27
|
HttpClient,
|
|
26
28
|
HttpStatusError,
|
|
27
29
|
ProgressCallback,
|
|
@@ -165,6 +167,13 @@ def _short_remote(item: CheckPlanItem) -> str:
|
|
|
165
167
|
return remote
|
|
166
168
|
|
|
167
169
|
|
|
170
|
+
# Probe Range: novas tentativas em status ou erro de transporte transitório
|
|
171
|
+
# (``DEFAULT_RETRY_EXCEPTIONS``) antes de desistir.
|
|
172
|
+
_PROBE_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
173
|
+
_PROBE_ATTEMPTS = 3
|
|
174
|
+
_PROBE_BACKOFF_S = 1.0
|
|
175
|
+
|
|
176
|
+
|
|
168
177
|
class _ProbeNotFoundError(Exception):
|
|
169
178
|
"""O GET de probe Range respondeu 404 (URL removida no servidor)."""
|
|
170
179
|
|
|
@@ -513,6 +522,9 @@ class FetcherApp:
|
|
|
513
522
|
:func:`default_path_builder` (canonical ``DataRepository`` layout).
|
|
514
523
|
default_output: Default output directory path.
|
|
515
524
|
client: HTTP or FTP client instance. Defaults to default_client().
|
|
525
|
+
source_id: Source ID recorded in download manifests. Defaults to
|
|
526
|
+
``name`` without the ``-fetcher`` suffix. When ``default_output``
|
|
527
|
+
is omitted it is derived as ``/data/{source_id}``.
|
|
516
528
|
build_default_commands: If True (default), registers the built-in
|
|
517
529
|
``sync`` and ``list`` commands. Set to False to register only
|
|
518
530
|
custom commands via :meth:`attach_command`.
|
|
@@ -530,6 +542,7 @@ class FetcherApp:
|
|
|
530
542
|
path_builder: Callable[[Path, dict[str, Any], dt.date | None], Path]
|
|
531
543
|
| None = None,
|
|
532
544
|
default_output: Path | None = None,
|
|
545
|
+
source_id: str | None = None,
|
|
533
546
|
client: HttpClient | FtpClient | None = None,
|
|
534
547
|
build_default_commands: bool = True,
|
|
535
548
|
):
|
|
@@ -539,9 +552,10 @@ class FetcherApp:
|
|
|
539
552
|
self.aliases = aliases_dict
|
|
540
553
|
self.list_datasets = list_datasets
|
|
541
554
|
self.path_builder = path_builder or default_path_builder
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
)
|
|
555
|
+
# source_id explícito vence; senão deriva de `name` sem o sufixo
|
|
556
|
+
# "-fetcher" (comportamento histórico).
|
|
557
|
+
self.source_id = source_id or name.replace("-fetcher", "")
|
|
558
|
+
self.default_output = default_output or Path(f"/data/{self.source_id}")
|
|
545
559
|
self.client = client or default_client()
|
|
546
560
|
|
|
547
561
|
self.all_group_keys = list(self.groups.keys())
|
|
@@ -819,7 +833,7 @@ class FetcherApp:
|
|
|
819
833
|
return self.client.download_with_manifest(
|
|
820
834
|
url,
|
|
821
835
|
output,
|
|
822
|
-
source_id=self.
|
|
836
|
+
source_id=self.source_id,
|
|
823
837
|
dataset_id=dataset_id,
|
|
824
838
|
producer=self.name,
|
|
825
839
|
progress=progress,
|
|
@@ -1146,24 +1160,41 @@ class FetcherApp:
|
|
|
1146
1160
|
stream = getattr(self.client, "stream", None)
|
|
1147
1161
|
if not callable(stream):
|
|
1148
1162
|
return None
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1163
|
+
for attempt in range(1, _PROBE_ATTEMPTS + 1):
|
|
1164
|
+
try:
|
|
1165
|
+
with stream(
|
|
1166
|
+
"GET",
|
|
1167
|
+
url,
|
|
1168
|
+
# identity: Content-Length/Content-Range devem refletir o
|
|
1169
|
+
# tamanho real, não o comprimido.
|
|
1170
|
+
headers={"Range": "bytes=0-0", "Accept-Encoding": "identity"},
|
|
1171
|
+
) as response:
|
|
1172
|
+
headers = response.headers
|
|
1173
|
+
status = response.status_code
|
|
1174
|
+
final_url = str(response.url)
|
|
1175
|
+
break
|
|
1176
|
+
except HttpStatusError as exc:
|
|
1177
|
+
if exc.status_code == 404:
|
|
1178
|
+
raise _ProbeNotFoundError(url) from exc
|
|
1179
|
+
failure: Exception = exc
|
|
1180
|
+
retryable = exc.status_code in _PROBE_RETRY_STATUSES
|
|
1181
|
+
except DEFAULT_RETRY_EXCEPTIONS as exc:
|
|
1182
|
+
failure, retryable = exc, True
|
|
1183
|
+
except Exception as exc: # noqa: BLE001 - probe é best effort
|
|
1184
|
+
failure, retryable = exc, False
|
|
1185
|
+
# 429/5xx ou erro de transporte transitório (ex.: 503 intermitente
|
|
1186
|
+
# do gov.br): sem nova tentativa, uma URL removida viraria
|
|
1187
|
+
# "download" e falharia com 404 no sync.
|
|
1188
|
+
if retryable and attempt < _PROBE_ATTEMPTS:
|
|
1189
|
+
time.sleep(_PROBE_BACKOFF_S * attempt)
|
|
1190
|
+
continue
|
|
1191
|
+
# WARNING: o cron não roda com --verbose e precisa registrar a causa.
|
|
1192
|
+
logger.warning(
|
|
1193
|
+
"[check] probe Range falhou para %s após %d tentativa(s): %r",
|
|
1152
1194
|
url,
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
) as response:
|
|
1157
|
-
headers = response.headers
|
|
1158
|
-
status = response.status_code
|
|
1159
|
-
final_url = str(response.url)
|
|
1160
|
-
except HttpStatusError as exc:
|
|
1161
|
-
if exc.status_code == 404:
|
|
1162
|
-
raise _ProbeNotFoundError(url) from exc
|
|
1163
|
-
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1164
|
-
return None
|
|
1165
|
-
except Exception as exc: # noqa: BLE001 - probe é best effort
|
|
1166
|
-
logger.debug("[check] probe Range falhou para %s: %r", url, exc)
|
|
1195
|
+
attempt,
|
|
1196
|
+
failure,
|
|
1197
|
+
)
|
|
1167
1198
|
return None
|
|
1168
1199
|
|
|
1169
1200
|
total: int | None = None
|
|
@@ -1208,6 +1239,45 @@ class FetcherApp:
|
|
|
1208
1239
|
entry=dict(entry),
|
|
1209
1240
|
)
|
|
1210
1241
|
|
|
1242
|
+
def _check_entry_isolated(
|
|
1243
|
+
self, entry: dict[str, Any], output_dir: Path
|
|
1244
|
+
) -> CheckPlanItem:
|
|
1245
|
+
"""Run :meth:`check_entry` without letting one entry abort the check.
|
|
1246
|
+
|
|
1247
|
+
An unexpected error (e.g. ``OSError`` while building the local path)
|
|
1248
|
+
is retried once; if it persists the entry becomes a conservative
|
|
1249
|
+
``download`` verdict, so the plan still covers every other entry
|
|
1250
|
+
instead of the whole ``check`` exiting 1.
|
|
1251
|
+
"""
|
|
1252
|
+
for attempt in (1, 2):
|
|
1253
|
+
try:
|
|
1254
|
+
return self.check_entry(entry, output_dir)
|
|
1255
|
+
except Exception as exc: # noqa: BLE001 - isolate per entry
|
|
1256
|
+
failure = exc
|
|
1257
|
+
if attempt == 1:
|
|
1258
|
+
time.sleep(1.0)
|
|
1259
|
+
eid = str(entry.get("id", "unknown"))
|
|
1260
|
+
logger.warning(
|
|
1261
|
+
"[check] falha ao verificar %s (%s: %s); marcada para download",
|
|
1262
|
+
eid,
|
|
1263
|
+
type(failure).__name__,
|
|
1264
|
+
failure,
|
|
1265
|
+
)
|
|
1266
|
+
return CheckPlanItem(
|
|
1267
|
+
dataset=str(entry.get("group") or entry.get("id") or "datasets"),
|
|
1268
|
+
id=eid,
|
|
1269
|
+
url=str(entry.get("url", "")),
|
|
1270
|
+
partition=_partition_label(entry),
|
|
1271
|
+
local_path="",
|
|
1272
|
+
remote_etag=None,
|
|
1273
|
+
remote_last_modified=None,
|
|
1274
|
+
remote_size=None,
|
|
1275
|
+
local_exists=False,
|
|
1276
|
+
action="download",
|
|
1277
|
+
reason=f"check-error: {type(failure).__name__}: {failure}",
|
|
1278
|
+
entry=dict(entry),
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1211
1281
|
def check_datasets(
|
|
1212
1282
|
self,
|
|
1213
1283
|
entries: list[dict[str, Any]],
|
|
@@ -1227,7 +1297,7 @@ class FetcherApp:
|
|
|
1227
1297
|
results: list[CheckPlanItem | None] = [None] * len(entries)
|
|
1228
1298
|
|
|
1229
1299
|
def _worker(index: int, entry: dict[str, Any]) -> None:
|
|
1230
|
-
results[index] = self.
|
|
1300
|
+
results[index] = self._check_entry_isolated(entry, output_dir)
|
|
1231
1301
|
if (index + 1) % 100 == 0 or index + 1 == len(entries):
|
|
1232
1302
|
logger.info("[check] %d/%d verificados", index + 1, len(entries))
|
|
1233
1303
|
|
|
@@ -497,3 +497,86 @@ def test_check_entry_ephemeral_stale_downloads(tmp_path: Path):
|
|
|
497
497
|
item = app.check_entry(entry, tmp_path)
|
|
498
498
|
assert item.action == "download"
|
|
499
499
|
assert item.reason == "remote-newer"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_source_id_defaults_to_name_without_suffix():
|
|
503
|
+
app = make_app()
|
|
504
|
+
assert app.source_id == "comex"
|
|
505
|
+
assert app.default_output == Path("/data/comex")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def test_explicit_source_id_derives_default_output():
|
|
509
|
+
app = make_app(source_id="custom")
|
|
510
|
+
assert app.source_id == "custom"
|
|
511
|
+
assert app.default_output == Path("/data/custom")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_explicit_default_output_wins_over_source_id(tmp_path: Path):
|
|
515
|
+
app = make_app(source_id="custom", default_output=tmp_path)
|
|
516
|
+
assert app.default_output == tmp_path
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_check_datasets_isolates_entry_failure(tmp_path: Path, monkeypatch):
|
|
520
|
+
"""Uma entrada que levanta (ex.: OSError) não derruba o check inteiro."""
|
|
521
|
+
monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
|
|
522
|
+
app = make_app(
|
|
523
|
+
client=HttpClient(
|
|
524
|
+
attempts=1,
|
|
525
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
entries = sample_list_datasets("exp")
|
|
529
|
+
calls: dict[str, int] = {}
|
|
530
|
+
real = app.check_entry
|
|
531
|
+
|
|
532
|
+
def flaky(entry, output_dir):
|
|
533
|
+
key = str(entry["id"])
|
|
534
|
+
calls[key] = calls.get(key, 0) + 1
|
|
535
|
+
if key == str(entries[0]["id"]):
|
|
536
|
+
raise FileExistsError(17, "File exists", "/data")
|
|
537
|
+
return real(entry, output_dir)
|
|
538
|
+
|
|
539
|
+
monkeypatch.setattr(app, "check_entry", flaky)
|
|
540
|
+
plan = app.check_datasets(entries, tmp_path)
|
|
541
|
+
assert len(plan.items) == len(entries)
|
|
542
|
+
bad = next(i for i in plan.items if i.id == str(entries[0]["id"]))
|
|
543
|
+
assert bad.action == "download"
|
|
544
|
+
assert bad.reason.startswith("check-error: FileExistsError")
|
|
545
|
+
assert calls[str(entries[0]["id"])] == 2 # uma nova tentativa
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_check_datasets_retry_recovers_transient_failure(tmp_path: Path, monkeypatch):
|
|
549
|
+
monkeypatch.setattr("quantilica.cli.sdk.time.sleep", lambda _s: None)
|
|
550
|
+
app = make_app(
|
|
551
|
+
client=HttpClient(
|
|
552
|
+
attempts=1,
|
|
553
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(200)),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
entries = sample_list_datasets("exp")[:1]
|
|
557
|
+
real = app.check_entry
|
|
558
|
+
state = {"n": 0}
|
|
559
|
+
|
|
560
|
+
def once(entry, output_dir):
|
|
561
|
+
state["n"] += 1
|
|
562
|
+
if state["n"] == 1:
|
|
563
|
+
raise OSError("transiente")
|
|
564
|
+
return real(entry, output_dir)
|
|
565
|
+
|
|
566
|
+
monkeypatch.setattr(app, "check_entry", once)
|
|
567
|
+
plan = app.check_datasets(entries, tmp_path)
|
|
568
|
+
assert plan.items[0].reason != "" and not plan.items[0].reason.startswith(
|
|
569
|
+
"check-error"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def test_check_entry_403_head_without_probe_support_downloads(tmp_path: Path):
|
|
574
|
+
"""HEAD 403 e probe Range 403 → download (nunca exceção/exit 1)."""
|
|
575
|
+
app = make_app(
|
|
576
|
+
client=HttpClient(
|
|
577
|
+
attempts=1,
|
|
578
|
+
transport=httpx2.MockTransport(lambda r: httpx2.Response(403)),
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
item = app.check_entry(sample_list_datasets("exp")[0], tmp_path)
|
|
582
|
+
assert item.action == "download"
|
|
@@ -485,3 +485,92 @@ def test_head_403_probe_500_stays_download(tmp_path: Path):
|
|
|
485
485
|
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
486
486
|
assert item.action == "download"
|
|
487
487
|
assert item.reason.startswith("metadata-unavailable")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@pytest.mark.parametrize(
|
|
491
|
+
("statuses", "action", "reason"),
|
|
492
|
+
[
|
|
493
|
+
# 503 transitório e depois 404 → removida de fato
|
|
494
|
+
([503, 404], "skip-unavailable", "remote-404"),
|
|
495
|
+
# 429 e depois 206 → metadados pelo Range
|
|
496
|
+
([429, 206], "download", "range-probe"),
|
|
497
|
+
# 503 persistente → desiste após as tentativas
|
|
498
|
+
([503, 503, 503], "download", "metadata-unavailable"),
|
|
499
|
+
# 403 no GET não é transitório → sem nova tentativa
|
|
500
|
+
([403], "download", "metadata-unavailable"),
|
|
501
|
+
],
|
|
502
|
+
)
|
|
503
|
+
def test_probe_retries_transient_status(
|
|
504
|
+
tmp_path: Path, monkeypatch, statuses, action, reason
|
|
505
|
+
):
|
|
506
|
+
from quantilica.cli import sdk
|
|
507
|
+
|
|
508
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
509
|
+
gets: list[int] = []
|
|
510
|
+
|
|
511
|
+
def handler(request):
|
|
512
|
+
if request.method == "HEAD":
|
|
513
|
+
return httpx2.Response(403)
|
|
514
|
+
status = statuses[min(len(gets), len(statuses) - 1)]
|
|
515
|
+
gets.append(status)
|
|
516
|
+
headers = {"Content-Range": "bytes 0-0/10"} if status == 206 else {}
|
|
517
|
+
return httpx2.Response(status, headers=headers)
|
|
518
|
+
|
|
519
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
520
|
+
assert item.action == action
|
|
521
|
+
assert item.reason.startswith(reason)
|
|
522
|
+
assert gets == statuses
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@pytest.mark.parametrize(
|
|
526
|
+
("failures", "action", "reason"),
|
|
527
|
+
[
|
|
528
|
+
# timeout transitório e depois 404 → removida de fato
|
|
529
|
+
([httpx2.ReadTimeout], "skip-unavailable", "remote-404"),
|
|
530
|
+
# conexão derrubada e depois 206 → metadados pelo Range
|
|
531
|
+
([httpx2.RemoteProtocolError], "download", "range-probe"),
|
|
532
|
+
# erro de transporte persistente → desiste após as tentativas
|
|
533
|
+
([httpx2.ConnectError] * 3, "download", "metadata-unavailable"),
|
|
534
|
+
],
|
|
535
|
+
)
|
|
536
|
+
def test_probe_retries_transport_errors(
|
|
537
|
+
tmp_path: Path, monkeypatch, failures, action, reason
|
|
538
|
+
):
|
|
539
|
+
from quantilica.cli import sdk
|
|
540
|
+
|
|
541
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
542
|
+
gets: list[int] = []
|
|
543
|
+
|
|
544
|
+
def handler(request):
|
|
545
|
+
if request.method == "HEAD":
|
|
546
|
+
return httpx2.Response(403)
|
|
547
|
+
gets.append(1)
|
|
548
|
+
if len(gets) <= len(failures):
|
|
549
|
+
raise failures[len(gets) - 1]("transitório", request=request)
|
|
550
|
+
if reason == "remote-404":
|
|
551
|
+
return httpx2.Response(404)
|
|
552
|
+
return httpx2.Response(206, headers={"Content-Range": "bytes 0-0/10"})
|
|
553
|
+
|
|
554
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
555
|
+
assert item.action == action
|
|
556
|
+
assert item.reason.startswith(reason)
|
|
557
|
+
assert len(gets) == min(len(failures) + 1, 3)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_probe_failure_logs_warning_with_cause(tmp_path: Path, monkeypatch):
|
|
561
|
+
from quantilica.cli import sdk
|
|
562
|
+
|
|
563
|
+
monkeypatch.setattr(sdk, "_PROBE_BACKOFF_S", 0.0)
|
|
564
|
+
warnings: list[str] = []
|
|
565
|
+
monkeypatch.setattr(
|
|
566
|
+
sdk.logger, "warning", lambda msg, *args: warnings.append(msg % args)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def handler(request):
|
|
570
|
+
return httpx2.Response(403 if request.method == "HEAD" else 503)
|
|
571
|
+
|
|
572
|
+
item = _probe_app(handler).check_entry(ENTRY, tmp_path)
|
|
573
|
+
assert item.reason.startswith("metadata-unavailable")
|
|
574
|
+
assert len(warnings) == 1
|
|
575
|
+
assert "após 3 tentativa(s)" in warnings[0]
|
|
576
|
+
assert "503" in warnings[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|