Skip to content

fix: gzip decompression, infinite retry loops, and keychain hangs#16

Closed
davidfencik wants to merge 6 commits intosteipete:mainfrom
davidfencik:fix/auth-gzip-keyring
Closed

fix: gzip decompression, infinite retry loops, and keychain hangs#16
davidfencik wants to merge 6 commits intosteipete:mainfrom
davidfencik:fix/auth-gzip-keyring

Conversation

@davidfencik
Copy link
Copy Markdown

@davidfencik davidfencik commented Feb 19, 2026

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: gzip headers

Go's http.Transport handles gzip transparently when Accept-Encoding is not set manually. Setting it explicitly disables automatic decompression, causing raw gzip bytes to reach json.Decoderinvalid 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_TTY set, TERM missing) and falls back to file backend. Interactive users keep Keychain as default. Can also be forced with EIGHTCTL_KEYRING_FILE=1.

Testing

  • All existing tests pass (go test ./...)
  • Verified status, sleep day, whoami, device info all work on macOS (Apple Silicon, headless via launchd)
  • Interactive Keychain behavior preserved for terminal users

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.

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
@davidfencik davidfencik force-pushed the fix/auth-gzip-keyring branch from 563dcf4 to 92e8e81 Compare February 19, 2026 15:05
Gerry Fencik added 3 commits February 20, 2026 12:32
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
@drgrib
Copy link
Copy Markdown

drgrib commented Apr 12, 2026

Thanks for putting this together. I've been testing this branch for headless macOS environments and ran into a lingering keychain prompt issue where 99designs/keyring still defaults to the OS keychain before checking the file cache.

I dropped the full context and a proposed --no-keychain fix over in the main thread: #10 (comment)"

omarshahine added a commit to omarshahine/eightctl that referenced this pull request Apr 15, 2026
…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>
@omarshahine
Copy link
Copy Markdown
Collaborator

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! 🙏

omarshahine added a commit that referenced this pull request Apr 16, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants