Skip to content

fix(cldy): make the Cloudability upload path actually retry - #240

Open
peatey wants to merge 3 commits into
developfrom
fix/238-uploader-reliability
Open

fix(cldy): make the Cloudability upload path actually retry#240
peatey wants to merge 3 commits into
developfrom
fix/238-uploader-reliability

Conversation

@peatey

@peatey peatey commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #238.

The bug

A failed Cloudability connection test at startup left the agent running with an empty StorageServices slice for the life of the process. uploadData iterated that empty slice, fell through to os.Remove, and deleted every tarball as if it had been uploaded — so samples were silently discarded until the pod was restarted.

Reported bugs

  • Startup failure permanently disabled the uploader. Storage services are now built through ensureStorageServices, which rebuilds them lazily when the slice is empty instead of only once at startup. The cooldown is stamped after the build finishes, not before, so a build that outlasts UploadFrequency cannot defeat its own gate.
  • Empty StorageServices silently discarded tarballs. UploadData returns ErrNoStorageServices before any os.Remove, so the tarball survives and is retried on a later cycle.
  • testUpload backoff was nanoseconds. time.Duration(math.Pow(2, i)) made the three retries fire ~2ns, ~4ns and ~8ns apart. Now seconds-scale, with no sleep after the final attempt, behind an injectable seam so tests stay fast.

Defects those symptoms were hiding

  • doWithRetry had the identical nanosecond backoff, and never closed the response body of a failed attempt — leaking a connection per retry.
  • The presigned S3 PUT could not be retried at all. This is the request in the issue's connection reset by peer log. doWithRetry re-issues the same *http.Request, and Client.Do consults GetBody only for redirects and its own internal rewind — never for a caller's retry loop. Attempt 1 sent the file, the transport closed it, and attempt 2 put a 0-byte body on a live presigned S3 URL; only the Content-MD5 header prevented a truncated object being stored. The body is now rebuilt from GetBody before each attempt, and a request carrying a body without GetBody is attempted once rather than retried with a broken one.
  • Retaining tarballs exposed a wedge in set.operateAndRemove. It discarded its progress list on the first error while those files had already been removed, stranding phantom entries that failed every subsequent cycle. It now retains partial progress and joins the errors.
  • Retrying every retained tarball each cycle made the pass O(queue depth). A small backlog outran the tick, and dropped ticks stalled the rest of the loop. A pass now starts no new entry once its budget is spent, and abandons the batch when the destination as a whole is unavailable.
  • MetricsCollectorServiceImpl.testUpload was a line-for-line twin carrying both original defects, on a live production path. Both call sites now share one uploadProbe loop so they cannot diverge again.

Tests

Tests no longer reach frontdoor.apptio.com — a suite-level fake serves the login, presign and probe endpoints. That took the package from ~100s to ~4s while keeping every spec.

Every new spec was mutation-checked: the bug it covers was reintroduced, the spec confirmed to fail, and the bug reverted. 66/66 specs green; -race reports zero data races.

Three specs fail under -race (should only login once, should log back in if required, should emit each time emission interval is satisifed). These are pre-existing and timing-sensitive — verified failing identically on a clean develop worktree. CI runs go test ./... without -race.

Follow-ups, deliberately not in scope

  • UPLOAD_RETRY_COUNT is still discarded by NewApptioClient (maxRetries: 3 is hardcoded). Plumbing it needs a backoff cap first: 1<<i is uncapped, so the documented default of 5 would mean ~330s per request and ~990s per upload.
  • doWithRetry's terminal error does not wrap the transport error, so callers cannot classify a connection failure via errors.As.
  • Upload failure still does not reach Healthy() — the third suggestion in Failed Cloudability testUpload at startup permanently disables uploads (no retry; samples dropped) #238, which lives in the emitter rather than cldy/.
  • The drain budget is checked between entries, not during one, so a single slow entry can still overrun the tick. Bounding that properly needs a context deadline plumbed through the client.

Review

Three adversarial review rounds, each of which found real defects in the previous round's fixes. The final round found no correctness regression versus develop and cleared it to ship.

https://claude.ai/code/session_01P95uFVdQCZBHvfzDUv2xdd

A failed connection test at startup left the agent running with an empty
StorageServices slice for the life of the process. uploadData iterated that
empty slice, fell through to os.Remove, and deleted every tarball as if it had
been uploaded, so samples were silently discarded until the pod restarted.

Fixes #238, and the defects those symptoms were hiding.

Reported bugs:

- Storage services are now built through ensureStorageServices, which rebuilds
  them lazily when the slice is empty instead of only once at startup. The
  cooldown is stamped after the build finishes, not before, so a build that
  outlasts UploadFrequency cannot defeat its own gate.
- UploadData returns ErrNoStorageServices before any os.Remove, so the tarball
  survives and is retried on a later cycle.
- testUpload's backoff was time.Duration(math.Pow(2, i)) -- nanoseconds, so the
  three retries fired ~2ns, ~4ns and ~8ns apart. It is now seconds-scale, with
  no sleep after the final attempt, behind an injectable seam so tests stay fast.

Found while fixing those:

- doWithRetry had the same nanosecond backoff and never closed the response body
  of a failed attempt, leaking a connection per retry.
- The presigned S3 PUT -- the request in the issue's connection-reset log --
  could not be retried at all. doWithRetry re-issues the same *http.Request, and
  Client.Do consults GetBody only for redirects and its own internal rewind, not
  for a caller's retry loop. Attempt 1 sent the file, the transport closed it,
  and attempt 2 put a 0-byte body on a live presigned URL. The body is now
  rebuilt from GetBody before each attempt, and a request that carries a body
  without GetBody is attempted once rather than retried with a broken one.
- Retaining tarballs exposed set.operateAndRemove discarding its progress list
  on the first error while their files had already been removed, which stranded
  phantom entries that failed every subsequent cycle. It now retains partial
  progress and joins the errors.
- Attempting every retained tarball each cycle made the pass O(queue depth); a
  small backlog outran the tick, and dropped ticks stalled the rest of the loop.
  A pass now starts no new entry once its budget is spent, and abandons the
  batch when the destination as a whole is unavailable.
- MetricsCollectorServiceImpl.testUpload was a line-for-line twin carrying both
  original defects. Both call sites now share one uploadProbe loop.

Tests no longer reach frontdoor.apptio.com; a suite-level fake serves the login,
presign and probe endpoints, which took the package from ~100s to ~4s. Every new
spec was mutation-checked: the bug it covers was reintroduced, the spec was
confirmed to fail, and the bug reverted.

Known follow-ups, deliberately not in scope: UPLOAD_RETRY_COUNT is still
discarded by NewApptioClient and needs a backoff cap before it can be plumbed;
doWithRetry's terminal error does not wrap the transport error, so callers
cannot classify a connection failure; and upload failure still does not reach
Healthy().

Claude-Session: https://claude.ai/code/session_01P95uFVdQCZBHvfzDUv2xdd
CI runs `make go-fix-check` (`go fix -diff ./...`), which fails on a non-empty
diff. Two loops added with the #238 tests still used the pre-Go 1.22 three-clause
form; `go fix` rewrites both to range-over-int.

Claude-Session: https://claude.ai/code/session_01P95uFVdQCZBHvfzDUv2xdd
The retry fix in the previous commit sets GetBody on the presigned S3 PUT so
doWithRetry can replay the body. That is also precisely the condition net/http
requires before it will follow a 307 or 308 that carries a body: with GetBody
nil the client hands the 3xx back untouched, and with it set the client replays
the body to whatever host the redirect names.

The upload client set no CheckRedirect, so the default follow-up-to-ten-hops
policy applied. Verified against a local harness: before the retry fix a 307
returned "307 Temporary Redirect" and leaked nothing; after it, the full tar and
its Content-MD5 header were delivered to the redirect target, which answered 200.
That 200 is read as a successful upload, so the sample would then be deleted
locally. The stdlib does not block an https to http downgrade on redirect
either, so the replay can land in plaintext.

The upload URL is not chosen by the agent: it is the location field of a
Cloudability API response, and S3 legitimately emits 307 for cross-region
buckets, so redirect handling is on the normal path rather than an exotic one.

Refusing to follow redirects fails closed. A 3xx is not a 200, so the upload
errors and is retried against the original host. This also matters for the
allowlist on fix/mend-215-sast-findings: validateUploadURL checks only the
initial URL, so once that branch merges a single redirect hop would otherwise
have bypassed it.

Claude-Session: https://claude.ai/code/session_01P95uFVdQCZBHvfzDUv2xdd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Failed Cloudability testUpload at startup permanently disables uploads (no retry; samples dropped)

1 participant