Un respaldo de búsqueda sin credencial se omite en vez de romper la carga
Python / calidad (push) Successful in 3s
Python / tests (push) Successful in 11s
YAML / yaml (push) Successful in 4s

El CI, apenas empezó a ejecutarse de verdad, hizo fallar dos de sus tres
trabajos. La causa es un defecto de diseño, no del CI: la config del agente
declara a Brave como respaldo, y la validación exigía la credencial para poder
*leer* el archivo. Como la config vive en el repositorio y la credencial no, un
clon limpio quedaba sin poder cargar su propia configuración. Mis pruebas en
Gigastar no lo veían porque acá el archivo .env existe.

Ahora se distingue por rol, que es lo que corresponde:

- El primario sin credencial sigue siendo error fatal. Sin él no queda ninguna
  búsqueda en pie, y descubrirlo en la primera consulta real es tarde.
- Un respaldo sin credencial se cae de la cadena y el primario sigue andando.
  Degradarse es la respuesta correcta: tener red de seguridad es mejor que no
  tenerla, pero no tenerla es mejor que no arrancar.

Omitir no es esconder. La propiedad respaldos_omitidos deja el motivo a la
vista, y build_backend emite un aviso al construir la cadena, que es el punto
por el que pasa cualquier entrypoint que use búsqueda.

Verificado escondiendo .env y corriendo la suite y el validador como lo haría
un clon limpio: 124 tests en verde con credencial y sin ella.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 07:47:53 -03:00
parent 03dadc93da
commit 59b7c3acb6
4 changed files with 111 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(*)"
]
}
}
+12
View File
@@ -26,6 +26,7 @@ import time
import urllib.error
import urllib.parse
import urllib.request
import warnings
from dataclasses import dataclass
from typing import Protocol
@@ -374,7 +375,18 @@ def build_backend(config) -> SearchBackend:
Con un solo backend devuelve ese backend pelado, para no envolver en una
cadena algo que no la necesita.
Los respaldos sin credencial ya vienen filtrados de la config, pero se avisa
acá: quedarse sin red de seguridad no puede pasar en silencio, y este es el
punto por el que pasa cualquier entrypoint que use búsqueda.
"""
for nombre, variable in config.respaldos_omitidos:
warnings.warn(
f"búsqueda: el respaldo '{nombre}' queda deshabilitado porque falta "
f"{variable}. Si el primario '{config.backend}' falla, no hay red.",
RuntimeWarning,
stacklevel=2,
)
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
+51 -21
View File
@@ -250,33 +250,63 @@ class SearchConfig(_Base):
searxng_url: str | None = None
brave_api_key: str | None = None
# Qué credencial necesita cada backend, y de qué variable de entorno sale.
_CREDENCIALES = {
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
}
def _falta_credencial(self, nombre: str) -> str | None:
requisito = self._CREDENCIALES.get(nombre)
if requisito and not getattr(self, requisito[0]):
return requisito[1]
return None
@property
def cadena(self) -> list[str]:
"""El primario seguido de los respaldos, sin repetidos."""
orden = [self.backend, *self.fallbacks]
"""El primario seguido de los respaldos utilizables, sin repetidos.
Un respaldo sin su credencial se omite en vez de romper la carga. La
razón es concreta: la config vive en el repositorio y las credenciales
no, así que exigirlas para poder *leerla* dejaba el proyecto sin poder
clonarse — el CI y cualquier máquina nueva fallaban antes de empezar.
Omitir no es esconder: `respaldos_omitidos` los expone y `build_backend`
avisa. El primario es otra cosa y sigue siendo error fatal (ver abajo):
sin él no hay búsqueda posible.
"""
vistos: list[str] = []
for nombre in orden:
if nombre not in vistos:
vistos.append(nombre)
for nombre in [self.backend, *self.fallbacks]:
if nombre in vistos:
continue
if nombre != self.backend and self._falta_credencial(nombre):
continue
vistos.append(nombre)
return vistos
@property
def respaldos_omitidos(self) -> list[tuple[str, str]]:
"""Respaldos descartados por falta de credencial, con qué variable falta."""
omitidos = []
for nombre in self.fallbacks:
if nombre == self.backend:
continue
variable = self._falta_credencial(nombre)
if variable:
omitidos.append((nombre, variable))
return omitidos
@model_validator(mode="after")
def _check_backends(self) -> SearchConfig:
# Un backend sin credencial se descubriría recién en la primera consulta
# real, que es justo cuando el primario ya falló y el respaldo tiene que
# funcionar. Se valida al arrancar.
requisitos = {
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
}
for nombre in self.cadena:
requisito = requisitos.get(nombre)
if requisito and not getattr(self, requisito[0]):
rol = "backend" if nombre == self.backend else "fallback"
raise ValueError(
f"search: el {rol} '{nombre}' exige {requisito[0]} "
f"(definí {requisito[1]} en .env)."
)
def _check_backend_primario(self) -> SearchConfig:
# El primario sí es fatal: sin él no queda ninguna búsqueda en pie, y
# descubrirlo en la primera consulta real es tarde.
variable = self._falta_credencial(self.backend)
if variable:
campo = self._CREDENCIALES[self.backend][0]
raise ValueError(
f"search: el backend primario '{self.backend}' exige {campo} "
f"(definí {variable} en .env)."
)
return self
+41 -10
View File
@@ -193,7 +193,7 @@ def test_el_primario_no_necesita_credenciales():
assert isinstance(primero, DuckDuckGoBackend)
def test_searxng_exige_url():
def test_searxng_como_primario_exige_url():
with pytest.raises(ValueError, match="searxng_url"):
SearchConfig(backend="searxng")
@@ -307,22 +307,53 @@ def test_el_fallback_repetido_no_se_duplica():
assert cfg.cadena == ["duckduckgo", "brave"]
def test_un_fallback_sin_credencial_falla_al_arrancar():
"""No en la primera consulta real — que es justo cuando el primario ya
falló y el respaldo tiene que funcionar."""
with pytest.raises(ValueError, match="fallback 'brave'"):
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
def test_un_respaldo_sin_credencial_se_omite_en_vez_de_romper():
"""La config vive en el repositorio y las credenciales no.
Exigir la clave para poder *leer* la config dejaba el proyecto sin poder
clonarse: el CI y cualquier máquina nueva fallaban antes de empezar. El
respaldo se cae de la cadena; el primario sigue funcionando.
"""
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
assert cfg.cadena == ["duckduckgo"]
def test_brave_como_primario_tambien_exige_credencial():
with pytest.raises(ValueError, match="backend 'brave'"):
def test_el_respaldo_omitido_queda_a_la_vista():
"""Omitir no es esconder: quedarse sin red de seguridad tiene que verse."""
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
assert cfg.respaldos_omitidos == [("brave", "ENLACE_BRAVE_API_KEY")]
def test_construir_la_cadena_avisa_del_respaldo_faltante():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
with pytest.warns(RuntimeWarning, match="ENLACE_BRAVE_API_KEY"):
build_backend(cfg)
def test_con_credencial_el_respaldo_si_entra():
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
assert cfg.cadena == ["duckduckgo", "brave"]
assert cfg.respaldos_omitidos == []
def test_el_primario_sin_credencial_si_es_fatal():
"""Sin primario no queda ninguna búsqueda en pie: eso no se degrada."""
with pytest.raises(ValueError, match="primario 'brave'"):
SearchConfig(backend="brave")
def test_la_config_del_repo_declara_brave_de_respaldo():
def test_la_config_del_repo_carga_sin_credenciales(monkeypatch):
"""Un clon limpio, sin .env, tiene que poder leer la config del repositorio.
Es exactamente lo que rompió en el CI: la validación exigía la clave de
Brave para poder cargar el archivo.
"""
monkeypatch.delenv("ENLACE_BRAVE_API_KEY", raising=False)
monkeypatch.setattr("enlace.config.load.load_dotenv", lambda *a, **k: [])
cfg = load_agent_config()
assert cfg.search.backend == "duckduckgo"
assert "brave" in cfg.search.fallbacks
assert "brave" in cfg.search.fallbacks # la intención queda declarada
assert cfg.search.cadena == ["duckduckgo"] # pero no se usa sin credencial
# --- parser de Brave -------------------------------------------------------