fix: gzip decompression, infinite retry loops, and keychain hangs#16
fix: gzip decompression, infinite retry loops, and keychain hangs#16davidfencik wants to merge 6 commits intosteipete:mainfrom
Conversation
Go's http.Transport handles gzip decompression transparently when Accept-Encoding is not set manually. Setting it explicitly disables automatic decompression, causing raw gzip bytes to reach json.Decoder and producing 'invalid character' errors on most endpoints. Fixes steipete#14
The do() method recursed indefinitely on 429 (rate limit) and 401 (unauthorized) responses. This could hang the CLI forever when the API consistently returns these status codes. Add a retry counter (max 3) with exponential backoff for 429s.
The macOS Keychain backend blocks indefinitely in headless environments (SSH, cron, launchd) when it cannot show the authorization prompt. Detect headless mode via SSH_TTY or missing TERM and fall back to file-only backend. Interactive terminal users keep Keychain as default. Can also be forced with EIGHTCTL_KEYRING_FILE=1. Fixes steipete#10
563dcf4 to
92e8e81
Compare
Corrects field mappings for sleep data parsing: - sleepDuration (not sleepDurationSeconds) - Adds deepDuration, remDuration, lightDuration, sleepStart/End - Properly nests sleepQualityScore with hrv, heartRate, respiratoryRate - Fixes sleep range --from/--to flag parsing (read from cobra, not viper) - Displays durations in hours for readability Inspired by talison's analysis in steipete#11.
…uality) Adds to sleep day/range output: - quality (sleepQualityScore.total) - awake_min (presenceDuration - sleepDuration) - disturbances (renamed from tnt) - avg_rhr (rolling average resting heart rate) - lowest_hr (min from session timeseries heartRate data) - breath_rate (respiratoryRate.current) - snore_min (snoreDuration in minutes) Parses sessions[].timeseries.heartRate array to extract lowest HR. Also adds presenceDuration, snoreDuration, heavySnoreDuration to SleepDay struct.
- Add --side flag to sleep day/range: left, right, partner, or me - Fix --date flag reading from cobra instead of viper (same fix as --from/--to) - Fix OAuth token endpoint using hardcoded 'sleep-client' instead of actual client ID - Add GetSleepDayForUser() to query any user's trends - Add Device().SideUserIDs() and UserIDForSide() helpers
|
Thanks for putting this together. I've been testing this branch for headless macOS environments and ran into a lingering keychain prompt issue where I dropped the full context and a proposed |
…dless envs - Replace infinite 429/401 retry loops with bounded retries (max 3) and exponential backoff to prevent permanent rate-limit storms - Remove explicit Accept-Encoding: gzip header; let Go's http.Transport handle compression transparently (simpler, no manual gzip.NewReader) - Detect headless environments (SSH, no TTY, EIGHTCTL_KEYRING_FILE=1) and fall back to file-based keyring to avoid macOS Keychain hang Credit to @davidfencik (steipete#16) for the retry and keyring patterns, and @petersentaylor (steipete#27) for the simpler gzip approach. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Excellent work on this PR, @davidfencik! Your bounded retry pattern with exponential backoff and the headless keyring fallback were both great ideas. I've incorporated both patterns into #24 (with credit in the commit message and PR description). Closing this in favor of #24, which combines OAuth form-encoding, gzip, your retry/keyring patterns, and tests for all of it. Thanks for the thoughtful contribution — we'll get this released soon! 🙏 |
* fix(client): use form-urlencoded for OAuth token endpoint The Eight Sleep auth server (auth-api.8slp.net/v1/tokens) expects standard OAuth2 form-urlencoded requests, not JSON. The previous implementation sent JSON with hardcoded "sleep-client" credentials, which caused a 400 from Joi validation. The fallback to legacy /login then tripped the rate limiter, resulting in a permanent 429 loop. Changes: - Send application/x-www-form-urlencoded instead of application/json - Use c.ClientID and c.ClientSecret (the real app creds extracted from the Android APK) instead of hardcoded "sleep-client"/"" - Make authURL a var so tests can point it at a local server - Add tests for form encoding, credential passthrough, and legacy login fallback Fixes #7, fixes #8, fixes #12 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(client): decompress gzip API responses The do() method sends Accept-Encoding: gzip but never decompresses the response body, causing json.Decode to fail with: invalid character '\x1f' looking for beginning of value (0x1f is the gzip magic byte.) Check Content-Encoding: gzip on responses and wrap the body in a gzip.Reader before decoding. Added test with a mock gzip response. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: cap retry loops, simplify gzip handling, fix keyring hang in headless envs - Replace infinite 429/401 retry loops with bounded retries (max 3) and exponential backoff to prevent permanent rate-limit storms - Remove explicit Accept-Encoding: gzip header; let Go's http.Transport handle compression transparently (simpler, no manual gzip.NewReader) - Detect headless environments (SSH, no TTY, EIGHTCTL_KEYRING_FILE=1) and fall back to file-based keyring to avoid macOS Keychain hang Credit to @davidfencik (#16) for the retry and keyring patterns, and @petersentaylor (#27) for the simpler gzip approach. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(client): drop legacy /login fallback, OAuth-only auth The legacy /login endpoint no longer works reliably upstream, and the silent fallback was masking real OAuth errors. Remove it so OAuth failures surface directly. Also revert the keyring-backend tweaks from 9f49cf0 — leave tokencache behavior identical to main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): remove contradictory golangci-lint config `disable-all: true` and `disable: [errcheck, unused]` can't be combined — golangci-lint errors with "can't combine options --disable-all and --disable". Remove the redundant `disable` list and stale `revive` settings since only `govet` is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Lobster <lobster@shahine.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Three fixes that resolve CLI hangs and endpoint failures, especially on macOS in headless environments.
Fixes: #10, #14 (and the root cause behind most symptoms in #12)
Changes
1. Remove explicit
Accept-Encoding: gzipheadersGo's
http.Transporthandles gzip transparently whenAccept-Encodingis not set manually. Setting it explicitly disables automatic decompression, causing raw gzip bytes to reachjson.Decoder→invalid character '\\x1f'errors on most endpoints.2. Add max retries for 429/401 responses
do()recursed infinitely on 429 (rate limit) and 401 (unauthorized). Added a retry cap (3) with linear backoff to prevent the CLI from hanging forever.3. Skip macOS Keychain in headless environments
The macOS Keychain backend blocks indefinitely in headless environments (SSH, cron, launchd) waiting for an auth prompt that can never be shown. Now detects headless mode (
SSH_TTYset,TERMmissing) and falls back to file backend. Interactive users keep Keychain as default. Can also be forced withEIGHTCTL_KEYRING_FILE=1.Testing
go test ./...)status,sleep day,whoami,device infoall work on macOS (Apple Silicon, headless via launchd)Context
I was setting up eightctl to run as part of a home automation system (via launchd on macOS). Hit all three issues in sequence: gzip errors, infinite retry loops, and keychain hangs. Debugged with the help of an AI coding assistant (Claude/OpenClaw) by reading the source, tracing the issues, and building locally.
Each fix is a separate commit for easy review.