feat(garmin): all-JSON wrapper protocol and log stream
Error responses from wrapper.py now carry error_type and traceback in
the protocol itself, logged as attrs on the Go side's per-call record.
wrapper.py's free-text stderr debug prints become JSON log lines
({level,msg,...attrs}); client.go parses that stream and re-emits it
through the application logger (level-mapped, source=garmin-wrapper),
wrapping any non-JSON line instead of passing it through raw. The
wrapper also survives malformed request lines and unserializable
results with JSON responses instead of crashing with a raw traceback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,14 +24,21 @@ _login_result_queue = queue.Queue()
|
||||
_startup_login_attempted = False
|
||||
|
||||
|
||||
def _debug(msg):
|
||||
print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True)
|
||||
def _log(level, msg, **attrs):
|
||||
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
|
||||
stderr line by line and re-emits these through the Go application
|
||||
logger, so the backend's combined output stays a single JSON stream --
|
||||
never print free text to stderr or stdout from this process (stdout is
|
||||
reserved for the request/response protocol)."""
|
||||
entry = {"level": level, "msg": msg}
|
||||
entry.update(attrs)
|
||||
print(json.dumps(entry), file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _prompt_mfa():
|
||||
_debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
|
||||
_log("info", "garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
|
||||
code = _mfa_input_queue.get(timeout=300)
|
||||
_debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect")
|
||||
_log("debug", "prompt_mfa() handing code back to garminconnect", code_length=len(code))
|
||||
return code
|
||||
|
||||
|
||||
@@ -91,12 +98,13 @@ def _startup_login():
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
else:
|
||||
_debug(f"startup tokenstore login failed: {err}")
|
||||
_log("warn", "startup tokenstore login failed", error=err)
|
||||
_auth_state = "unauthenticated"
|
||||
except queue.Empty:
|
||||
_debug(
|
||||
_log(
|
||||
"warn",
|
||||
"startup tokenstore login hit the 10s timeout -- still running in the "
|
||||
"background and will update auth state whenever it finishes"
|
||||
"background and will update auth state whenever it finishes",
|
||||
)
|
||||
_auth_state = "unauthenticated"
|
||||
|
||||
@@ -118,14 +126,19 @@ def _handle_authenticate(_params):
|
||||
}
|
||||
|
||||
def _do_login():
|
||||
_debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})")
|
||||
_log("debug", "background login thread starting _client.login()", tokenstore=TOKENSTORE)
|
||||
try:
|
||||
_client.login(tokenstore=TOKENSTORE)
|
||||
_debug("_client.login() returned successfully")
|
||||
_log("debug", "_client.login() returned successfully")
|
||||
_login_result_queue.put(("success", None))
|
||||
except Exception as exc:
|
||||
_debug(f"_client.login() raised {type(exc).__name__}: {exc}")
|
||||
_debug(traceback.format_exc())
|
||||
_log(
|
||||
"error",
|
||||
"_client.login() raised",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
_login_result_queue.put(("error", str(exc)))
|
||||
|
||||
_client = Garmin(email, password)
|
||||
@@ -134,17 +147,18 @@ def _handle_authenticate(_params):
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=10)
|
||||
_debug(f"authenticate got result within 10s timeout: status={status}")
|
||||
_log("debug", "authenticate got result within 10s timeout", status=status)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "Authenticated successfully."}
|
||||
return {"status": "failed", "message": f"Authentication failed: {err}"}
|
||||
except queue.Empty:
|
||||
_debug(
|
||||
_log(
|
||||
"warn",
|
||||
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, "
|
||||
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
|
||||
"whether the 'REAL MFA challenge' debug line above appears to tell real MFA "
|
||||
"apart from a merely slow login."
|
||||
"whether the 'REAL MFA challenge' log line above appears to tell real MFA "
|
||||
"apart from a merely slow login.",
|
||||
)
|
||||
_auth_state = "mfa_pending"
|
||||
return {
|
||||
@@ -160,12 +174,12 @@ def _handle_complete_mfa(params):
|
||||
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
|
||||
|
||||
code = params["code"]
|
||||
_debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue")
|
||||
_log("debug", "complete_mfa received a code, pushing to mfa queue", code_length=len(code))
|
||||
_mfa_input_queue.put(code)
|
||||
|
||||
try:
|
||||
status, err = _login_result_queue.get(timeout=30)
|
||||
_debug(f"complete_mfa got result: status={status} err={err}")
|
||||
_log("debug", "complete_mfa got result", status=status, error=err)
|
||||
if status == "success":
|
||||
_auth_state = "authenticated"
|
||||
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
|
||||
@@ -206,9 +220,15 @@ def dispatch(req):
|
||||
result = handler(req.get("params") or {})
|
||||
return {"id": req["id"], "result": result}
|
||||
except Exception as exc:
|
||||
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
|
||||
_debug(traceback.format_exc())
|
||||
resp = {"id": req.get("id"), "error": str(exc)}
|
||||
# The full failure detail travels IN the response -- error text,
|
||||
# exception class, and traceback -- so the Go side can log one
|
||||
# complete structured record instead of correlating stderr noise.
|
||||
resp = {
|
||||
"id": req.get("id"),
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
|
||||
# side after being linked to an activity) is definitive, not a
|
||||
# transient failure worth retrying forever -- marked specifically so
|
||||
@@ -224,10 +244,40 @@ def main():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
req = json.loads(line)
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
# A malformed request has no id to answer to -- log and keep
|
||||
# serving rather than crashing the subprocess.
|
||||
_log("error", "malformed request line", error=str(exc))
|
||||
continue
|
||||
resp = dispatch(req)
|
||||
print(json.dumps(resp), flush=True)
|
||||
try:
|
||||
print(json.dumps(resp), flush=True)
|
||||
except (TypeError, ValueError) as exc:
|
||||
# A non-JSON-serializable handler result must still produce a
|
||||
# protocol response, or the Go side would block on a reply.
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"id": req.get("id"),
|
||||
"error": f"unserializable result: {exc}",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # last resort: die loudly, but still in JSON
|
||||
_log(
|
||||
"error",
|
||||
"wrapper crashed",
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
Reference in New Issue
Block a user