Skip to content

REG-1909 Contract Preview 2b - #3620

Open
sirdodger wants to merge 9 commits into
clee/preview-foundationfrom
clee/preview-contract-impl
Open

REG-1909 Contract Preview 2b#3620
sirdodger wants to merge 9 commits into
clee/preview-foundationfrom
clee/preview-contract-impl

Conversation

@sirdodger

@sirdodger sirdodger commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

REG-1909 Contract Preview 2b

Crate-implementation PR (stacked on REG-1909 Foundation 1) for rover contract preview.

Adds operations::contract::preview: the contractPreviewAsync/contractPreviewStatus/contractPreviewResult GraphQL operations, each wrapped as a tower::Service per AGENTS.md's rover-client Service pattern (see contract/preview/service.rs), plus start/result/poll/run orchestration functions built on the shared preview_poll helper added in the foundation PR.

No CLI command calls this yet — rover contract preview wiring lands in the next PR in this stack (REG-1909 Contract Preview 3b).

[x] A CHANGELOG.md entry is not needed for this PR

@sirdodger
sirdodger requested a review from a team as a code owner August 20, 2026 19:23
@apollo-librarian

apollo-librarian Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

✅ Docs preview has no changes

The preview was not built because there were no changes.

Build ID: bac257d328a73239f436545a
Build Logs: View logs


✅ AI Style Review — No Changes Detected

No MDX files were changed in this pull request.

Review Log: View detailed log

This review is AI-generated. Please use common sense when accepting these suggestions, as they may not always be accurate or appropriate for your specific context.

@sirdodger sirdodger changed the title Implement contractPreviewAsync operation (rover-client) REG-1909 Contract Preview 2b Aug 20, 2026
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch 4 times, most recently from a0c34bf to 957c3e9 Compare August 24, 2026 19:51
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch 3 times, most recently from ff37b2e to 918952a Compare August 25, 2026 00:09
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch 2 times, most recently from 3e24da0 to c9be9f4 Compare August 25, 2026 19:25
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch 3 times, most recently from 798e645 to 17bf380 Compare August 26, 2026 17:29
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Code review

Two findings, both against the retry/timeout composition guidance in CLAUDE.md. No correctness bugs found — GraphQL variables, union/enum mapping, and poll polarity all check out against the checked-in schema.


1. poll has no per-attempt TimeoutLayer nested inside its retry

let build_id = status_input.build_id.clone();
let mut status_service = ServiceBuilder::new()
.retry(PollRetryPolicy::new(
Duration::from_secs(5),
Duration::from_secs(checks_timeout_seconds),
{
let build_id = build_id.clone();
move || RoverClientError::PreviewTimeoutError {
build_id: build_id.clone(),
}
},
))
.service(service::ContractPreviewStatus::new(
client
.studio_graphql_service()
.map_err(|err| RoverClientError::ServiceReady(Box::new(err)))?,
));
status_service

poll wraps ContractPreviewStatus in a PollRetryPolicy retry, but nothing bounds an individual attempt.

PollRetryPolicy::retry() implements tower::retry::Policy::retry, which Tower only invokes after the inner call has already resolved to a Result. The deadline comparison lives entirely inside that method, so if a single contractPreviewStatus request hangs, retry() is never reached and the checks_timeout_seconds budget is never enforced — the poll can block indefinitely instead of surfacing PreviewTimeoutError.

CLAUDE.md is explicit about this shape:

nest a short per-attempt TimeoutLayer inside a RetryLayer whose retry budget is the longer elapsed-time window, so a single hung attempt can’t consume the entire retry budget.

Suggested fix: add a rover-http TimeoutLayer with a short per-attempt timeout between .retry(...) and .service(...), so each status call is bounded while PollRetryPolicy keeps owning the overall elapsed-time budget. src/command/auth/whoami/mod.rs is the in-repo reference for the retry-outer / timeout-inner ordering.

For fairness: the pre-existing shared/check_workflow_poll.rs has the same structural gap in its manual loop — but this is new ServiceBuilder-based code, which is exactly what the rule targets.


2. The new operation services expose no default timeout/retry configuration

/// A [`Service`] that starts an async contract preview build, layered over
/// the studio GraphQL service.
#[derive(Clone)]
pub struct ContractPreviewStart<S: Clone> {
inner: S,
}
impl<S: Clone> ContractPreviewStart<S> {
pub const fn new(inner: S) -> Self {
Self { inner }
}
}

None of the three new wrappers — ContractPreviewStart (L21), ContractPreviewStatus (L87), ContractPreviewResult (L155) — expose a default timeout/retry configuration for callers to use or override.

CLAUDE.md states this without hedging:

Each rover-client operation’s Tower Service should expose its own default timeout/retry configuration, not just inherit a one-size-fits-all constant. A service.rs wrapper ... already owns how that operation builds and maps its request; it should also own a sensible default RetryLayer/TimeoutLayer configuration for that specific operation, exposed so callers can use it as-is or override it via ServiceBuilder.

This matters more than usual here because the three operations have genuinely different latency profiles: the __typename-only status query should be fast and tightly bounded, while contractPreviewResult pulls full API + core schema documents and warrants a much longer timeout. A shared constant would be wrong for at least one of them. Where latency data is not available, CLAUDE.md asks for "a conservative default and say so in a comment" rather than leaving it unset.

Flagging transparently: no existing operation service.rs in this crate currently complies with this rule, so this is not a regression unique to this PR — but this is new code landing at exactly the path the rule targets, and per-operation defaults would also supply the per-attempt bound poll is missing above.

@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch from 6702205 to cb48419 Compare August 26, 2026 19:22
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch from cb48419 to 38f953d Compare August 27, 2026 19:21
Adds the `operations::contract::preview` module: the
contractPreviewAsync/contractPreviewStatus/contractPreviewResult GraphQL
operations wrapped as Tower Services (per AGENTS.md's rover-client Service
pattern), plus `start`/`result`/`poll`/`run` orchestration functions
built on the shared `preview_poll` helper from the foundation PR.

No CLI command calls this yet -- `rover contract preview" wiring lands in
the next PR in this stack.
ContractPreviewStatus's Response changes from Option<PollState> to bool
(preview builds never had a target_url to accumulate, so the richer
PollState wrapper added nothing). poll() now builds
ServiceBuilder::new().retry(PollRetryPolicy::new(...)).service(status)
instead of calling the removed poll_preview_build, and maps the one-shot
result fetch's error to PreviewResultUnavailable inline instead of via a
separate remapping helper.
Mirrors WHOAMI_ATTEMPT_TIMEOUT: an inner per-attempt timeout layered under
PollRetryPolicy's overall poll budget, so one hung status check can't
consume checks_timeout_seconds in a single attempt. A timed-out attempt
maps to `false` (not finished) rather than an error, so PollRetryPolicy
just polls again on the normal schedule.
Follows the foundation change removing AttemptTimeoutLayer: per-call
resilience is deferred to each operation's own retry/timeout
composition rather than a bespoke layer bolted onto the poll retry
policy.
Follows the rover-tower change replacing impl PollOutcome for bool
with a dedicated enum.
… with a Tower mock

Mirrors the same fix applied to the sibling subgraph-preview
implementation:

- Module shape: fold types.rs and the GraphQLQuery derives into mod.rs,
  leaving service.rs with only the tower::Service wrappers, per
  CLAUDE.md's target shape for new rover-client operations.
- The four result_* tests exercising ContractPreviewResult's pure
  field mapping now use a Tower mock (rover_tower::mock_service!)
  instead of spinning up a real MockServer, since they were never
  testing wire-level HTTP behavior.
Mirrors the equivalent change on subgraph::preview per PR review
feedback: instead of building the service internally from
&StudioClient, start()/result() now take an already-composed Service,
with default-service constructors (contract_preview_start_service /
contract_preview_result_service) for the common case. This lets tests
inject a rover_tower::mock_service! mock directly instead of spinning
up an HTTP mock server.

run()/poll() keep their existing &StudioClient-based signatures and
build the default services internally.
The service.rs Tower-mock tests and mod.rs tests were checking only
2-3 of PreviewJobResponse's 6 fields via individual assert_eq! calls,
and run_fails_fast_when_the_started_build_is_not_pollable only checked
the RoverClientError::AdhocError variant, not its message -- all would
pass even if an unrelated field silently regressed. Switched to
full-struct equality and a full-value destructure for the error case,
per CLAUDE.md's testing conventions.
@sirdodger
sirdodger force-pushed the clee/preview-contract-impl branch from 340d5df to 2671aab Compare September 1, 2026 03:18
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.

1 participant