fix(cldy): make the Cloudability upload path actually retry - #240
Open
peatey wants to merge 3 commits into
Open
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #238.
The bug
A failed Cloudability connection test at startup left the agent running with an empty
StorageServicesslice for the life of the process.uploadDataiterated that empty slice, fell through toos.Remove, and deleted every tarball as if it had been uploaded — so samples were silently discarded until the pod was restarted.Reported bugs
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 outlastsUploadFrequencycannot defeat its own gate.StorageServicessilently discarded tarballs.UploadDatareturnsErrNoStorageServicesbefore anyos.Remove, so the tarball survives and is retried on a later cycle.testUploadbackoff 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
doWithRetryhad the identical nanosecond backoff, and never closed the response body of a failed attempt — leaking a connection per retry.connection reset by peerlog.doWithRetryre-issues the same*http.Request, andClient.DoconsultsGetBodyonly 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 theContent-MD5header prevented a truncated object being stored. The body is now rebuilt fromGetBodybefore each attempt, and a request carrying a body withoutGetBodyis attempted once rather than retried with a broken one.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.MetricsCollectorServiceImpl.testUploadwas a line-for-line twin carrying both original defects, on a live production path. Both call sites now share oneuploadProbeloop 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;
-racereports 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 cleandevelopworktree. CI runsgo test ./...without-race.Follow-ups, deliberately not in scope
UPLOAD_RETRY_COUNTis still discarded byNewApptioClient(maxRetries: 3is hardcoded). Plumbing it needs a backoff cap first:1<<iis 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 viaerrors.As.Healthy()— the third suggestion in Failed CloudabilitytestUploadat startup permanently disables uploads (no retry; samples dropped) #238, which lives in the emitter rather thancldy/.contextdeadline 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
developand cleared it to ship.https://claude.ai/code/session_01P95uFVdQCZBHvfzDUv2xdd