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
13 changes: 13 additions & 0 deletions docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,17 @@ environment:
- LOG_DIR=/app/data/logs
- LOG_RETENTION_DAYS=90
- LOG_TO_STDOUT=0
# The image ships a HEALTHCHECK that probes /healthz, which only the dashboard
# serves. With the dashboard off, that probe can never succeed and the container
# is reported unhealthy forever -- which also stops any `depends_on:
# service_healthy` from ever being satisfied. Disable the check along with the
# dashboard:
healthcheck:
disable: true
```

> **Portugues:** a imagem traz um HEALTHCHECK que consulta `/healthz`, servido
> apenas pelo painel. Com o painel desligado a sonda nunca passa e o container
> fica eternamente marcado como *unhealthy*, travando qualquer
> `depends_on: service_healthy`. Desligue a verificacao junto com o painel, como
> no exemplo acima.
29 changes: 23 additions & 6 deletions src/omini_rtksync/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ def _sync_all_locked(self):
log_msg("STATUS", f"[{provider} · {name}] {nota}")
detalhe["actions"].append(nota)

# Trava de rate limit vencida sai antes de qualquer ramo: a janela
# do provedor reabriu, e isso vale para conexão OAuth, de chave ou
# local. Deixar a limpeza dentro de um único ramo mantinha a marca
# gravada — e a conexão amarela — em todos os outros.
trava = parse_expiry_to_ms(c.get("rateLimitedUntil"))
if trava is not None and trava <= int(time.time() * 1000):
if update_connection_health(self.settings.db_path, cid, clear_rate_limit=True):
nota = "Trava de rate limit vencida removida"
log_msg("STATUS", f"[{provider} · {name}] {nota}")
detalhe["actions"].append(nota)

# 1. Google / Antigravity OAuth
if provider in ("antigravity", "gemini-cli"):
local = self.google_provider.read_local_credential()
Expand Down Expand Up @@ -154,8 +165,16 @@ def _sync_all_locked(self):
# Recusado é motivo para renovar agora, não daqui a pouco.
rem_sec = 0
elif veredito.state == STATE_VALID:
# Gravar só o resultado da sonda deixava para trás o
# `test_status='invalid'` de uma falha anterior, que não
# caduca sozinho. 'active' é o único valor que o
# OmniRoute trata como saudável (clearAccountError), e é
# o que a credencial acabou de provar que é.
update_connection_health(
self.settings.db_path, cid, credential_state=veredito.state
self.settings.db_path,
cid,
test_status="active",
credential_state=veredito.state,
)

if rem_sec <= self.settings.refresh_margin or not c.get("accessToken"):
Expand Down Expand Up @@ -249,11 +268,9 @@ def _sync_all_locked(self):
test_status=data.get("testStatus"),
credential_state=data.get("credentialState"),
last_error=data.get("lastError"),
# O provider ja apagou a trava vencida de `data`, entao
# inferir "limpar" da ausencia dela invertia o sentido e
# preservava justamente a trava que devia sair. Quem diz
# e a mensagem do provider.
clear_rate_limit=any("rateLimitedUntil" in n for n in notes),
# A trava vencida ja foi removida no topo do laco, para
# qualquer tipo de conexao. Repetir a decisao aqui so
# duplicaria a regra em um unico ramo.
)
if renovou:
refreshed += 1
Expand Down
90 changes: 82 additions & 8 deletions src/omini_rtksync/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]:
"credentialState": (specific or {}).get("credentialState")
or extra.get("credentialState"),
"lastTested": item.get("last_tested") or extra.get("lastTested"),
# Renovar e verificar são eventos diferentes. Sem projetar este
# campo, "última renovação" no painel caía para o horário da
# última verificação e um token parado há dias parecia recém
# renovado a cada ciclo.
"lastRefreshAt": (specific or {}).get("lastRefreshAt") or extra.get("lastRefreshAt"),
"lastHealthCheckAt": item.get("last_health_check_at"),
"rateLimitedUntil": item.get("rate_limited_until") or extra.get("rateLimitedUntil"),
"lastError": item.get("last_error"),
Expand Down Expand Up @@ -222,14 +227,43 @@ def update_connection(
# trata como saudável (src/sse/services/auth.ts::clearAccountError e
# tokenHealthCheck.ts). 'ok' não é reconhecido e faz a conexão parecer
# estar em estado de erro.
cursor.execute(
f"""
UPDATE {tbl}
SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ?
WHERE id = ?
""",
(access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id),
)
# O horário da renovação vive no JSON de provider_specific_data:
# o schema relacional não tem coluna para ele, e sem esse carimbo o
# painel não distingue "renovado agora" de "apenas verificado".
especifico = {}
if "provider_specific_data" in cols:
cursor.execute(
f"SELECT provider_specific_data FROM {tbl} WHERE id = ?", (connection_id,)
)
linha = cursor.fetchone()
if linha:
especifico = _decode_json(linha["provider_specific_data"]) or {}
especifico["lastRefreshAt"] = now_iso
cursor.execute(
f"""
UPDATE {tbl}
SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active',
provider_specific_data = ?, updated_at = ?
WHERE id = ?
""",
(
access_token,
refresh_token,
to_iso_utc(expires_at_ms),
json.dumps(especifico),
now_iso,
connection_id,
),
)
else:
cursor.execute(
f"""
UPDATE {tbl}
SET access_token = ?, refresh_token = ?, expires_at = ?, test_status = 'active', updated_at = ?
WHERE id = ?
""",
(access_token, refresh_token, to_iso_utc(expires_at_ms), now_iso, connection_id),
)
elif "data" in cols:
# Formato compatível com JSON
cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,))
Expand All @@ -248,6 +282,7 @@ def update_connection(
# 9Router (resetHealthStateOnActivation em connectionsRepo.js);
# 'ok' só é reconhecido pela UI e não limpa travas de erro.
d["testStatus"] = "active"
d["lastRefreshAt"] = now_iso
cursor.execute(
f"UPDATE {tbl} SET data = ?, updatedAt = ? WHERE id = ?",
(json.dumps(d), now_iso, connection_id),
Expand Down Expand Up @@ -319,9 +354,48 @@ def update_connection_health(
campos.append("provider_specific_data = ?")
valores.append(json.dumps(atual))

# Schema de coluna JSON única (o formato que o 9Router usa e que o
# OmniRoute aceita em instalações migradas). Sem este ramo a sondagem
# era descartada inteira nessas bases: não há `test_status` nem
# `provider_specific_data` para receber os campos acima, e a função
# saía por `if not campos` como se não houvesse nada a gravar.
if "data" in cols and "test_status" not in cols:
cursor.execute(f"SELECT data FROM {tbl} WHERE id = ?", (connection_id,))
linha = cursor.fetchone()
d: Dict[str, Any] = {}
if linha and linha["data"]:
try:
carregado = json.loads(linha["data"])
if isinstance(carregado, dict):
d = carregado
except Exception:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# Coluna corrompida ou em formato inesperado: seguimos com o
# dicionário vazio e regravamos a linha com os campos de
# saúde. Abortar aqui faria uma linha ilegível bloquear para
# sempre a gravação da sondagem — justamente na conexão que
# mais precisa ser diagnosticada.
pass
if test_status:
d["testStatus"] = test_status
if credential_state:
d["credentialState"] = credential_state
d["credentialCheckedAt"] = now_iso
if discovered_models is not None:
d["discoveredModels"] = discovered_models
if last_error is not None:
d["lastError"] = last_error or None
if clear_rate_limit:
d.pop("rateLimitedUntil", None)
d["lastTested"] = now_iso
campos.append("data = ?")
valores.append(json.dumps(d))

if "updated_at" in cols:
campos.append("updated_at = ?")
valores.append(now_iso)
elif "updatedAt" in cols:
campos.append("updatedAt = ?")
valores.append(now_iso)

if not campos:
return False
Expand Down
15 changes: 15 additions & 0 deletions src/omini_rtksync/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
"table.models": "models",
"reason.local_ok": "Local instance answered with {count} model(s)",
"reason.local_unreachable": "Local instance did not answer the model catalog",
"reason.local_empty": "Local instance answered, but has no model installed yet",
"egress.bound": "own egress",
"egress.shared": "shares the gateway address with {count} accounts",
"egress.single": "gateway address (only account)",
"egress.unknown": "egress unknown",
"table.combo": "Combo",
"table.cascade": "Model cascade",
"combos.title": "Resilience combos",
Expand Down Expand Up @@ -182,6 +187,11 @@
"table.models": "modelos",
"reason.local_ok": "Instância local respondeu com {count} modelo(s)",
"reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos",
"reason.local_empty": "Instância local respondeu, mas ainda não tem nenhum modelo instalado",
"egress.bound": "saída própria",
"egress.shared": "divide o endereço do gateway com {count} contas",
"egress.single": "endereço do gateway (única conta)",
"egress.unknown": "saída desconhecida",
"table.combo": "Combo",
"table.cascade": "Cascata de modelos",
"combos.title": "Combos de resiliência",
Expand Down Expand Up @@ -291,6 +301,11 @@
"table.models": "modelos",
"reason.local_ok": "La instancia local respondió con {count} modelo(s)",
"reason.local_unreachable": "La instancia local no respondió al catálogo de modelos",
"reason.local_empty": "La instancia local respondió, pero aún no tiene ningún modelo instalado",
"egress.bound": "salida propia",
"egress.shared": "comparte la dirección del gateway con {count} cuentas",
"egress.single": "dirección del gateway (única cuenta)",
"egress.unknown": "salida desconocida",
"table.combo": "Combo",
"table.cascade": "Cascada de modelos",
"combos.title": "Combos de resiliencia",
Expand Down
34 changes: 32 additions & 2 deletions src/omini_rtksync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ def credential_state(self) -> Optional[str]:
state = self.data.get("credentialState")
return str(state) if state else None

@property
def rate_limit_active(self) -> bool:
"""Se a trava de rate limit ainda vale neste instante.

`rateLimitedUntil` é um prazo, não uma bandeira: ele guarda o momento em
que a janela do provedor se reabre. Tratar a simples presença do campo
como "limitada" deixava a conexão amarela para sempre depois do primeiro
429, porque nada apaga a marca quando o prazo vence.
"""
until = parse_expiry_to_ms(self.data.get("rateLimitedUntil"))
if until is None:
return False
return until > int(time.time() * 1000)

@property
def health_status(self) -> str:
"""Semantic classification of the connection state.
Expand All @@ -167,9 +181,25 @@ def health_status(self) -> str:

if self.is_local:
# unreachable is written when the model catalog does not answer.
return "unknown" if self.data.get("testStatus") == "unreachable" else "active"
estado = self.data.get("testStatus")
if estado == "unreachable":
return "unknown"
# Uma conexão recém-criada ainda não foi sondada por ninguém. Com
# CRON_ENABLED=0 ela pode nunca ser, e dizer "ativa" é alegar uma
# saúde que nenhuma sonda confirmou.
return "active" if estado in ("active", "ok", "success") else "not_checked"

if self.is_oauth:
# O gateway já pode ter carimbado a conexão como recusada. **Sem uma
# sonda viva que diga o contrário**, o carimbo dele é a melhor
# informação que existe — ignorá-lo mostrava como saudável uma
# credencial que o próprio OmniRoute sabe estar quebrada. Mas uma
# validação viva vence tudo: o carimbo é do último erro do gateway
# e não caduca sozinho, então honrá-lo mesmo depois de a sonda
# aprovar a credencial repetia, ao contrário, a própria contradição
# entre tela e banco que este arquivo existe para evitar.
if probed != "valid" and self.data.get("testStatus") in ("invalid", "error", "failed"):
return "invalid"
remaining = self.remaining_seconds
if remaining is None:
return "no_expiration"
Expand All @@ -180,7 +210,7 @@ def health_status(self) -> str:
return "active"

if self.has_api_key:
if self.data.get("rateLimitedUntil"):
if self.rate_limit_active:
return "rate_limited"
# Nunca sondada: dizer isso, em vez de alegar saúde que ninguém verificou.
return "active" if probed == "valid" else "not_checked"
Expand Down
14 changes: 12 additions & 2 deletions src/omini_rtksync/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,24 @@ def check_and_refresh(self, conn: Dict[str, Any]) -> Tuple[bool, Optional[Dict[s

if result.state == STATE_VALID:
res["testStatus"] = "active"
messages.append(f"Chave aceita pelo provedor ({result.detail})")
# Um 4xx que nao seja 401/403 continua provando que a
# autenticacao passou -- a sonda manda corpo vazio de
# proposito, e o provedor so chega a reclamar do corpo depois
# de aceitar a chave. Dizer apenas "aceita (HTTP 400)" fazia a
# tela parecer errada; a frase agora explica o que o numero
# significa.
messages.append(
f"Autenticação aceita pelo provedor ({result.detail})"
if result.detail and "200" in str(result.detail)
else f"Autenticação aceita pelo provedor; a sondagem em si foi recusada ({result.detail})"
)
elif result.state == STATE_INVALID:
res["testStatus"] = "invalid"
messages.append(f"Chave RECUSADA pelo provedor ({result.detail})")
elif result.state == STATE_RATE_LIMITED:
messages.append(f"Provedor aplicou rate limit na validação ({result.detail})")
elif result.state == STATE_UNREACHABLE:
messages.append(f"Provedor inacessível, chave não verificada: {result.detail}")
messages.append(f"Chave não verificada: {result.detail}")
else:
messages.append(result.detail or "Credencial não verificável")

Expand Down
Loading
Loading