Skip to content

Fix 401 on file downloads#278

Merged
jeremy merged 14 commits intomainfrom
fix-uploads-download-auth
Apr 23, 2026
Merged

Fix 401 on file downloads#278
jeremy merged 14 commits intomainfrom
fix-uploads-download-auth

Conversation

@flavorjones
Copy link
Copy Markdown
Member

@flavorjones flavorjones commented Apr 22, 2026

Problem

Every call to UploadsService.Download returned 401. SDK users couldn't
download any uploaded file.

ref: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/9812641548

Solution

Authenticate the download URL. upload.download_url is an authenticated
endpoint (requires Bearer auth, 302-redirects to a signed URL), not a
pre-signed URL as the old code assumed.

Why this fix

AccountClient.DownloadURL already implemented the correct flow for
authenticated storage URLs and continued to work in production. Extracting
that logic into a shared Client.fetchAPIDownload helper and routing
UploadsService.Download through it reuses proven code and keeps the two
methods in sync.

In-PR scope additions (review cycle)

  • Retry the auth'd hop. fetchAPIDownload now retries on network
    errors and 429/502/503/504 (matching Client.singleRequest's
    @retryable set), with exponential backoff and Retry-After on 429.
    500 is surfaced non-retryable, mirroring singleRequest.
  • Keep-alive drains. Redirect and retry paths drain bodies up to
    MaxErrorBodyBytes before Close() so connections can return to the
    keep-alive pool.
  • URL validation inside the helper. fetchAPIDownload now rejects
    non-absolute / non-http(s) URLs itself, so UploadsService.Download
    is guarded against malformed download_url values from API responses
    in addition to the existing AccountClient.DownloadURL check.
  • Fixture-driven download tests. loadUploadFixture seeds
    hand-rolled tests from spec/fixtures/uploads/get.json so the API
    response shape can't silently drift from what the server actually
    returns — the failure mode that let the original bug ship.
  • Download conformance suite. New conformance/tests/downloads.json
    codifies the 2-hop invariant (auth'd first hop, unauthenticated signed
    hop, retry shape) cross-SDK. Go runner is wired; Ruby/Python/TS/Kotlin
    runners skip these cases with a follow-up note. The suite asserts
    Authorization present on hop 1 and absent on hop 2 — catches the
    exact regression pattern this PR fixes.

Potentially breaking

  • NewClient now panics on MaxRetries < 1 (previously < 0). Zero
    was already broken before this PR: doRequestURL ran zero iterations
    and returned a malformed %!w(<nil>) error; fetchAPIDownload hit
    its ErrUsage fallback. The panic surfaces the misconfiguration at
    construction instead of hiding it. No known caller sets 0.

Additional info

Manually verified against live Basecamp:

  • SaaS production: byte-identical 1 MB PDF download across three CLI modes.
  • Local bc3 dev: direct-2xx branch works end-to-end (bc3 send_file
    returns 200 in one hop).

UploadsService.Download returned 401 for every upload because it
fetched `upload.download_url` unauthenticated, assuming a pre-signed
storage URL — but the API returns an authenticated endpoint.

Extract the auth'd-hop + 302-follow flow from AccountClient.DownloadURL
into a shared Client.fetchAPIDownload helper and route both callers
through it.

Four existing tests mocked download_url as a bare S3 URL — a shape the
API never returns. _Success and _S3Error encoded the same wrong
assumption as the bug. SecondLegNoAuth and SecondLegNoTimeout were
silently neutralized by the refactor (URL rewriting sent the rewritten
URL to the apiServer, skipping their s3Server handlers). Rewrite all
four mocks to match the real API and add a secondLegHit assertion so
the SecondLeg invariants bite.
The direct-2xx response branch — when bc3 serves blobs directly
instead of redirecting to storage — was not exercised by any test
through UploadsService.Download, even though the filename-precedence
override at vaults.go:1044-1046 runs on that branch.

Add TestUploadsService_Download_DirectBody: API-host download_url,
download endpoint auth-gated and returns 200 + body (no 302). Uses a
URL path filename that differs from the metadata filename so the
assertion proves the override is in effect.

TestDownloadURL_DirectDownload covers the helper's 2xx branch through
AccountClient.DownloadURL, but that path has no filename override.
Copilot AI review requested due to automatic review settings April 22, 2026 16:10
@github-actions github-actions Bot added the go label Apr 22, 2026
@github-actions github-actions Bot added the bug Something isn't working label Apr 22, 2026
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 4 files

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes UploadsService.Download returning 401 by treating upload.download_url as an authenticated API endpoint (Bearer auth + 302 to a signed URL), and reusing the proven AccountClient.DownloadURL flow via a shared helper.

Changes:

  • Extract shared authenticated-download flow into Client.fetchAPIDownload and route AccountClient.DownloadURL through it.
  • Update UploadsService.Download to use the authenticated hop + redirect flow and prefer metadata filename over URL-derived filename.
  • Update and extend tests to reflect real API behavior (auth-required first hop, unauthenticated signed second hop, plus direct-2xx body variant).

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
go/pkg/basecamp/download.go Adds Client.fetchAPIDownload and routes AccountClient.DownloadURL through it to share the authenticated-download logic.
go/pkg/basecamp/vaults.go Switches UploadsService.Download to use the shared authenticated-download flow and overrides filename from upload metadata.
go/pkg/basecamp/download_test.go Updates regression tests to assert first-hop auth and second-hop behavior (no auth / no timeout) with the new API-shaped download_url.
go/pkg/basecamp/vaults_test.go Updates upload download tests to model the authenticated 302 flow and adds coverage for the direct-2xx body branch.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@flavorjones flavorjones requested a review from jeremy April 22, 2026 21:20
@jeremy
Copy link
Copy Markdown
Member

jeremy commented Apr 23, 2026

Audit of hand-rolled helpers in go/pkg/basecamp/ that consume URLs from API responses: 9 helpers/callers reviewed, 0 new bugs found beyond the one this PR fixes.

Site Audit Verdict
go/pkg/basecamp/download.go:16-42 Client.fetchSignedDownload Takes a caller-supplied URL that, in production, is the resolved Location from hop 1; the contract is an already-signed second-hop URL, the helper intentionally sends no Bearer and uses a bare streaming client, and the redirect tests model that shape correctly. Correct
go/pkg/basecamp/download.go:53-159 AccountClient.DownloadURL Takes a caller-supplied absolute blob/API-routable URL; production contract is Bearer-authenticated API hop 1 plus optional 302 to a signed URL, the helper rewrites to cfg.BaseURL, authenticates hop 1, resolves Location, and uses fetchSignedDownload only for hop 2, and the redirect/direct-download tests match that two-hop design. Correct
go/pkg/basecamp/client.go:491-571 Client.followPagination / FollowPagination Takes Link: rel=next from API headers; production contract is same-origin API pagination that still requires Bearer, the helper resolves relative links against httpResp.Request.URL, rejects cross-origin targets, and fetches via doRequestURL so auth/retry semantics are preserved, and the pagination/security tests cover same-origin, relative, and poisoned-link cases. Correct
go/pkg/basecamp/timeline.go:296-369 TimelineService.PersonProgress custom pagination loop Takes Link: rel=next from wrapped person-progress responses; production contract is same-origin Bearer-authenticated API pagination, the helper mirrors followPagination by resolving against the current page and enforcing same-origin before doRequestURL, and the wrapped-pagination tests exercise that shape. Correct
go/pkg/basecamp/authorization.go:97-168 AuthorizationService.GetInfo Takes a fixed Launchpad endpoint or explicit operator override; production contract is HTTPS auth API with Bearer on the request itself, the helper applies Bearer manually and enforces secure custom endpoints, and there is no response-derived URL ambiguity here. Correct
go/pkg/basecamp/auth.go:304-361 AuthManager.refreshLocked Takes creds.TokenEndpoint from stored credentials; production contract is OAuth token refresh with refresh_token in the body rather than Bearer auth, the helper enforces HTTPS and posts form-encoded credentials, and no response-derived URL is consumed. Correct
go/pkg/basecamp/oauth/discovery.go:26-56 Discoverer.Discover Takes a caller base URL and appends the well-known discovery path; production contract is a public discovery document, the helper performs an unauthenticated GET, and there is no API-response URL reuse involved. Correct
go/pkg/basecamp/oauth/exchange.go:100-144 Exchanger.doTokenRequest Takes a caller-provided token endpoint; production contract is OAuth code/token exchange with credentials in the form body, the helper enforces HTTPS and sends no Bearer, and no response-derived URL is consumed. Correct
go/pkg/basecamp/vaults.go:1007-1040 UploadsService.Download Takes upload.download_url from the upload JSON body; production contract is an API-host URL that requires Bearer on hop 1 and then redirects to a signed URL (see spec/fixtures/uploads/get.json:54 and spec/fixtures/campfires/upload_line_get.json:19), but main currently passes it straight to fetchSignedDownload with no auth, and the old regression tests masked that by stubbing download_url as an S3-host URL. Broken, fixed by this PR

The bug class is narrow. Outside UploadsService.Download, the only other hand-rolled sites that fetch a URL obtained from an API response/header are followPagination and the PersonProgress pagination loop, and both deliberately:

  • resolve relative URLs against the current request URL
  • reject cross-origin targets before sending credentials
  • reuse doRequestURL, so Bearer/retry behavior stays on the normal authenticated path

I reran the non-generated, non-test grep passes for NewRequestWithContext, http.Client{, .Do(req), http.DefaultClient, and field consumers of DownloadURL|AvatarURL|AppURL|BookmarkURL|SubscriptionURL; no additional response-derived fetch sites surfaced.

Reference patterns going forward:

  • Use fetchSignedDownload only for a resolved second-hop signed URL.
  • Use the authenticated two-hop path (AccountClient.DownloadURL on main, fetchAPIDownload in this PR) for any API-host download URL that may 302 to a signed URL.

jeremy added 2 commits April 23, 2026 12:31
…xture

The authenticated first hop in fetchAPIDownload was a bare apiClient.Do with
no retry, so a transient 429/503 or network blip failed immediately. GETs
elsewhere in the SDK retry with exponential backoff (see c.doWithRetry);
uploads.Download(id) was quietly less resilient than uploads.Get(id).

Wrap the hop-1 request in a loop that mirrors doWithRetry: MaxRetries
attempts, exponential backoff via c.backoffDelay, Retry-After honored on
429, OnRetry hook fires each retry, re-authenticate per attempt (in case
the token refreshed). Retry only on network error or 429/5xx — once the
response enters 2xx/3xx dispatch the body belongs to the caller or is
being handed to the signed-hop fetch, so retrying there would be unsafe.
Not hoisting a shared retry helper yet; doWithRetry is tightly coupled to
the JSON-response generated client path.

New tests cover all four retry branches: 503 retry, 429 with Retry-After,
synthetic network error via a flakyRoundTripper, and 404 no-retry.

Also harden the hand-rolled Download tests so they can't drift from the
real API response shape the way the original bug did. Add loadUploadFixture
in test_helpers_test.go that parses spec/fixtures/uploads/get.json and
substitutes the test server's host into download_url, and migrate the
Success / S3Error / SecondLegNoAuth / SecondLegNoTimeout tests to consume
it. DirectBody stays hand-rolled because it deliberately mismatches URL
filename and metadata filename to prove the metadata-filename override.

Trim 500 out of TestDownloadURL_APIError's table — the retry loop would
burn default backoff (1s+2s+jitter) per entry and the 5xx retry path is
already covered by TestDownloadURL_AuthHopRetriesOn503.
The original UploadsService.Download 401 bug survived CI because download
had no conformance coverage — the Go convenience method shipped with
hand-rolled tests that encoded a shape the API never returns.

Add downloads.json codifying five invariants for DownloadURL: auth'd
first hop with 302 to a signed URL, direct 2xx body, retry on 503, honor
Retry-After on 429, and error on 302-without-Location. The suite is
cross-SDK by construction so other runners can adopt it incrementally;
only the Go runner is wired now. Ruby / Python / TypeScript runners get
skip entries for these five cases with a follow-up note. Parity work on
whether those SDKs should expose a convenience uploads.download(id) is
still deferred.
@jeremy jeremy requested a review from Copilot April 23, 2026 19:31
@github-actions github-actions Bot added the conformance Conformance test suite label Apr 23, 2026
@jeremy
Copy link
Copy Markdown
Member

jeremy commented Apr 23, 2026

Folded in the review scope from the plan. Two commits on top of the existing fix:

69e0a40 — Retry UploadsService.Download auth'd hop and drive tests from spec fixture

  • fetchAPIDownload's first hop now runs through an SDK-standard GET retry loop: MaxRetries attempts, exponential backoff via c.backoffDelay, Retry-After honored on 429, OnRetry fires each retry, re-auth per attempt (token may have refreshed). Retries stop once the response enters 2xx/3xx dispatch — the body belongs to the caller (2xx) or hop-2 is about to start (3xx). Addresses codex's retry-regression note — uploads.Download(id) is no longer less resilient than uploads.Get(id). Didn't hoist a shared helper: doWithRetry is tightly coupled to the generated-client path, extract later if a third site appears.
  • Added four tests covering every retry branch: 503 retry, 429 with Retry-After, synthetic network error via a flakyRoundTripper, and 404 no-retry.
  • Added loadUploadFixture in test_helpers_test.go. It reads spec/fixtures/uploads/get.json and rewrites the download_url host to the test server. Migrated Success / S3Error / SecondLegNoAuth / SecondLegNoTimeout to consume it so hand-rolled tests can't silently drift from the real API shape the way the original bug did. DirectBody stayed hand-rolled because it deliberately mismatches URL filename and metadata filename to prove the override.
  • Trimmed 500 out of TestDownloadURL_APIError's table — retries would burn ~3s of default backoff per row, and the 5xx retry path is now directly covered by TestDownloadURL_AuthHopRetriesOn503.

148e127 — Add conformance coverage for the download 2-hop flow

  • New conformance/tests/downloads.json codifies five invariants: auth'd first hop 302 → signed URL, direct 2xx body, retry on 503, honor Retry-After on 429, 302-no-Location → error. Suite is cross-SDK by construction; only the Go runner is wired (DownloadURL dispatch added). Python/Ruby/TypeScript runners get skip entries with a follow-up note, in line with the plan's "baseline" option.

Verification

  • go test ./pkg/basecamp/... — all pass
  • go test -race ./pkg/basecamp/... — race-clean
  • Go conformance runner — 66/66 pass (5 new download cases green)

Not done here

  • Live-Basecamp happy-path re-verify (1 MB PDF byte-identical across CLI modes) — needs credentials, haven't run it from this branch.

Still deferred per the plan: parity decision for Ruby/Python/TS/Swift, Smithy trait for auth-routable URL semantics, audit of other hand-rolled helpers consuming URLs from API responses, and wiring the other conformance runners for downloads.json.

@github-actions github-actions Bot added the breaking Breaking change to public API label Apr 23, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 23, 2026

⚠️ Potential breaking changes detected:

  • Change in default value for MaxRetries: Minimum value has been increased to 1 (was previously allowing 0). NewClient now panics on MaxRetries < 1.
  • Modification to retry behavior: Added constraint that MaxRetries must be at least 1, affecting retry logic in HTTP calls.
  • Updates to fetchAPIDownload: Error classification and retry logic for specific HTTP codes (500, 429, 502, 503, 504) and network errors have been modified, potentially impacting retry behavior.
  • Changes in error handling: Modified behavior for 500 errors to classify them as non-retryable, which might change behavior of previous implementations relying on retry mechanisms for 500 errors.

Review carefully before merging. Consider a major version bump.

@jeremy
Copy link
Copy Markdown
Member

jeremy commented Apr 23, 2026

Appreciated, but I don't think any of these rise to "major version bump." Assessing each:

1. fetchAPIDownload added, integrated into both entry points

fetchAPIDownload is unexported (lowercase). Not a public API surface — internal refactor only. No one could have been "relying on" it.

2. AccountClient.DownloadURL behavior change

Pre-PR DownloadURL already had the correct 2-hop flow (shipped in #178). What this PR changes is that hop-1 now runs through the SDK's standard GET retry loop: network error / 429 / 5xx retry with exponential backoff. The public signature is unchanged.

This is additive — a failure that used to surface immediately now surfaces after MaxRetries attempts. And it aligns with what the SDK has always documented for GETs (client.go:175-178):

  • Retries failed GET requests with exponential backoff
  • Respects Retry-After headers on 429 responses

DownloadURL just wasn't respecting that contract before. Bringing a GET into compliance with documented SDK-wide GET behavior isn't a break — it's the fix.

3. UploadsService.Download retry + filename selection

Pre-PR UploadsService.Download called fetchSignedDownload (unauthenticated) on an API-host URL. It returned 401 on every call — see PR body. Any behavior change here is from "never works" to "works." Not a break in any useful sense.

On filename: pre-PR set Filename: upload.Filename unconditionally. Current fetchAPIDownload seeds Filename = filenameFromURL(rawURL) and UploadsService.Download then overrides with upload.Filename if non-empty (vaults.go:1044-1047). So the change is: when upload.Filename is empty, you now get a URL-derived fallback instead of an empty string. Strictly better, and the only code path that could have observed the old empty-string behavior was the always-401 one.

Version bump

SDK is 0.7.3, pre-1.0 — no strict semver stability commitment. And the non-broken path (DownloadURL) is gaining retries it should have had all along. I'd lean minor bump for the behavioral addition (retry on a previously retry-less GET), not major.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/download_test.go Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="conformance/runner/go/main.go">

<violation number="1" location="conformance/runner/go/main.go:493">
P2: Do not ignore `io.Copy` errors when draining download responses; propagate read failures so tests fail on partial/broken downloads.</violation>
</file>

<file name="conformance/tests/downloads.json">

<violation number="1" location="conformance/tests/downloads.json:48">
P2: These retry cases never assert hop-1 Authorization/User-Agent, so a regression that retries the download unauthenticated would still pass.

(Based on your team's feedback about scoping required headers by request type.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread conformance/runner/go/main.go Outdated
Comment thread conformance/tests/downloads.json
- TestDownloadURL_AuthHopRetriesOn503 asserted elapsed >= 2*baseDelay, but
  two sleeps at exponential backoff total baseDelay + 2*baseDelay, so the
  looser bound would still pass if the second delay regressed to baseDelay.
  Require 3*baseDelay total.
- Propagate io.Copy errors when draining download bodies in the conformance
  runner so partial/broken downloads surface instead of silently passing.
- Assert hop-1 Authorization on the retry conformance cases. The
  happy-path case already does this; retry cases were missing it, so a
  regression that issued the first attempt unauthenticated could have
  slipped through.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/vaults_test.go Outdated
Comment thread go/pkg/basecamp/download_test.go Outdated
Comment thread go/pkg/basecamp/download_test.go Outdated
Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go
The Kotlin conformance runner had no name-based skip list — it relied on
tag matching and runtime exception handling. With downloads.json in play
it went straight to the operation dispatcher's "Unknown operation" throw
and failed all 5 cases.

Add a KOTLIN_SKIPS map mirroring the Ruby/Python/TypeScript runners.
Parity wiring for DownloadURL in the Kotlin dispatcher is deferred; this
keeps CI green in the meantime.
@jeremy jeremy requested a review from Copilot April 23, 2026 20:02
@github-actions github-actions Bot added kotlin and removed breaking Breaking change to public API labels Apr 23, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go Outdated
- Narrow the retryable-status set to 429 / 502 / 503 / 504 (plus
  transport errors), matching Client.singleRequest's @retryable markings.
  Previously retried all 5xx, so downloads retried on 500s even though
  the main GET path surfaces those immediately via ErrAPI(500, ...).
- Drain error bodies up to MaxErrorBodyBytes before Close() on the
  retry branch so the TCP connection can return to the keep-alive pool
  ahead of the next attempt. checkResponse only consumes the first
  maxErrorMessageLen*2 bytes for the error message; the rest goes to
  io.Discard.
- Rewrite the loop header comment to describe what the code actually
  does on MaxRetries<=0 (skip + ErrUsage fallback) and on exhaustion
  (return last per-attempt error directly) instead of claiming
  bug-for-bug parity with doRequestURL's error shape.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread conformance/tests/downloads.json
Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download_test.go
- Update the retry doc comment to say 429/502/503/504 (and note 500+
  surface without retry), matching the actual retryable set after
  aligning with Client.singleRequest.
- Collapse the resp==nil fallback to just the ErrUsage path; exhaustion
  after real attempts is already handled inside the loop via
  `return nil, lastErr`, so the %w branch was unreachable.
- Restore 500 to TestDownloadURL_APIError's table now that the retry
  loop no longer delays it. Assert only one request was made so a
  future regression that broadens the retry set would surface here.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go
The happy-path conformance test already asserted Authorization was
present on the first hop, but didn't verify the signed-URL hop stayed
unauthenticated — the exact invariant the original UploadsService.Download
bug violated in production. Catching that cross-SDK is directly on-
mission for this suite.

- conformance/schema.json: add headerAbsent to the assertion enum and an
  optional index field (0-based; negative indexes count from the end).
- conformance/runner/go/main.go: add an Assertion.Index field, a
  requestHeadersAt helper, and a headerAbsent case. Thread the index
  through existing headerPresent/headerInjected so either can target any
  captured request (default index 0 preserves prior behavior).
- conformance/tests/downloads.json: on the three 2-hop cases (happy path,
  503 retry, 429 retry), assert Authorization is present on request 0
  and absent on request -1 (the unauthenticated signed-URL hop).

Validated the new assertion catches a real regression: temporarily
re-authenticated fetchSignedDownload — the three 2-hop cases all FAILed
with "Expected header Authorization absent on request index -1, got
Bearer ...", then reverted.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="conformance/runner/go/main.go">

<violation number="1" location="conformance/runner/go/main.go:731">
P2: `headerAbsent` uses `headers.Get(...) == ""`, which cannot distinguish a missing header from a present header with an empty value; this can produce false passes.

(Based on your team's feedback about distinguishing absent vs empty values when using `Get`-style accessors.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread conformance/runner/go/main.go Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/download.go Outdated
MaxRetries:
- NewClient panics on MaxRetries<1, matching the MaxPages<=0 pattern.
  Zero attempts is always a misconfiguration, and both retry loops can
  now assume >=1 without divergent guarding. WithMaxRetries and
  HTTPOptions.MaxRetries docs updated to note the floor. The conformance
  runner's panic-string check picks up the new message.
- Keep fetchAPIDownload's resp==nil fallback as defense-in-depth for
  direct-struct-built Clients that bypass NewClient.

500 error metadata alignment:
- In fetchAPIDownload's non-retry dispatch, emit ErrAPI(500, "Server
  error (500)") directly for 500 instead of delegating to checkResponse.
  checkResponse currently marks all 5xx Retryable=true, which contradicts
  the fact that this loop (and Client.singleRequest) intentionally does
  not retry 500. Other statuses still go through checkResponse.
- Update the fetchAPIDownload docstring to describe what the code actually
  does: retry set matches singleRequest's @retryable; 500 mirrors
  singleRequest's non-retryable ErrAPI; other non-retried statuses flow
  through checkResponse.

Conformance runner headerAbsent:
- Use Values() instead of Get() so a header present with an empty value
  fails the absence assertion. Get conflates missing and empty.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/download.go Outdated
Comment thread go/pkg/basecamp/client.go
- Redirect drain now reads up to MaxErrorBodyBytes (1 MB) instead of
  4 KB. net/http requires reading to EOF for connection reuse; a 4 KB
  cap left larger redirect bodies unreusable. The 1 MB cap still guards
  against unbounded reads.
- Retry branch now reads only the maxErrorMessageLen*2 prefix that
  checkResponse actually uses for the error message, then io.Discard-s
  the remainder up to MaxErrorBodyBytes to enable keep-alive. Previously
  allocated up to 1 MB per retry even though only ~1 KB was consumed.
- Align the NewClient doc-header description of WithMaxRetries with the
  panic-on-<1 validation added in a89afd2: now says "Total attempt
  count for GET (default: 3, minimum 1)" to match WithMaxRetries and
  HTTPOptions.MaxRetries.
@cubic-dev-ai
Copy link
Copy Markdown

cubic-dev-ai Bot commented Apr 23, 2026

You're iterating quickly on this pull request. To help protect your rate limits, cubic has paused automatic reviews on new pushes for now—when you're ready for another review, comment @cubic-dev-ai review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@jeremy jeremy merged commit 867dcc0 into main Apr 23, 2026
49 checks passed
@jeremy jeremy deleted the fix-uploads-download-auth branch April 23, 2026 22:53
jeremy added a commit that referenced this pull request Apr 23, 2026
Marks Upload.download_url and CampfireLineAttachment.download_url as
API-host URLs that require the two-hop authenticated-then-signed fetch
rather than pre-signed external URLs. The trait's /// block carries the
full consumer contract (hop mechanics, test-fidelity requirements,
retry semantics, pagination exemption, public-vs-internal primitive
division) so hand-rolled helpers can implement the flow without reading
SDK source.

Guards regression with scripts/check-auth-routable-consumers.sh — a
call-site deny-list that fails if fetchSignedDownload is invoked outside
go/pkg/basecamp/download.go. After PR #278, the only legitimate caller
is fetchAPIDownload, which runs the authenticated first hop before the
signed fetch; any other caller is either re-inventing the two-hop flow
or skipping hop 1. Wired into make check via auth-routable-check.

Fixes the TypeScript uploads test fixture that still used example.com,
so the spec-shape-only test no longer teaches the wrong URL shape.

openapi.json diff is exactly two new x-basecamp-auth-routable-url: {}
entries on the Upload and CampfireLineAttachment download_url property
schemas; no generator in any SDK consumes field-level x-basecamp-*
extensions, so no downstream code churn.
jeremy added a commit that referenced this pull request Apr 24, 2026
Marks Upload.download_url and CampfireLineAttachment.download_url as
API-host URLs that require the two-hop authenticated-then-signed fetch
rather than pre-signed external URLs. The trait's /// block carries the
full consumer contract (hop mechanics, test-fidelity requirements,
retry semantics, pagination exemption, public-vs-internal primitive
division) so hand-rolled helpers can implement the flow without reading
SDK source.

Guards regression with scripts/check-auth-routable-consumers.sh — a
call-site deny-list that fails if fetchSignedDownload is invoked outside
go/pkg/basecamp/download.go. After PR #278, the only legitimate caller
is fetchAPIDownload, which runs the authenticated first hop before the
signed fetch; any other caller is either re-inventing the two-hop flow
or skipping hop 1. Wired into make check via auth-routable-check.

Fixes the TypeScript uploads test fixture that still used example.com,
so the spec-shape-only test no longer teaches the wrong URL shape.

openapi.json diff is exactly two new x-basecamp-auth-routable-url: {}
entries on the Upload and CampfireLineAttachment download_url property
schemas; no generator in any SDK consumes field-level x-basecamp-*
extensions, so no downstream code churn.
jeremy added a commit that referenced this pull request Apr 24, 2026
Step 5 previously required test stub URLs to use the configured API
base host. After rebasing onto main, that conflicts with two already-
merged conventions:

- Go's `download_test.go` (PR #278) uses `storage.3.basecamp.com` for
  ~16 primitive-level tests so the SDK's host-rewrite step is visible
  in test setup.
- The TS `UploadsService.download` test added by PR #281 follows the
  same pattern at the service level.

Both correctly assert the auth-hop sequence (Bearer on hop 1, none on
hop 2), which is the actual load-bearing guard against the PR #278 bug
shape — that bug passed because the test never asserted the auth hop
fired, not because of the host choice. Relax step 5 to make the
auth-hop assertion the explicit MUST, document both URL host
conventions as acceptable (with API-host preferred for fixture-shape
mirroring), and keep the schema-shape-only exemption.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API bug Something isn't working conformance Conformance test suite go kotlin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants