Skip to content

Retry

Failed fetches are retried automatically. A RetryPolicy says how many times to retry, how long to wait between attempts, and whether to back off exponentially. Queries and mutations share the same policy type but start from different defaults.

Resource Default retry policy
Query 3 retries, exponential backoff, 1s base, 30s cap
Mutation No retries

Queries default to retry because transient network failures are common and the user did not ask for the fetch, so silently retrying is the friendlier behavior. Mutations default to no retries because they are user-initiated and often destructive; silently re-running a “create” or “delete” on failure can duplicate or destroy data. Opt into mutation retry explicitly when you know it is safe.

RetryPolicy is Clone + Debug + Serialize + Deserialize. Build one with the constructor and the chained builders:

use gpui_query::RetryPolicy;
// The query default: 3 retries, exponential, 1s base, 30s cap.
let default = RetryPolicy::default();
// Never retry (the mutation default).
let never = RetryPolicy::no_retries();
// A custom policy: 5 retries, 500ms base, exponential, capped at 10s.
let custom = RetryPolicy::new(5)
.with_delay(500)
.with_exponential_backoff()
.with_max_delay(10_000);
Field new(n) default no_retries() Notes
max_retries n 0 0 means no retries.
retry_delay_ms 1000 0 Base delay between attempts.
exponential_backoff false false Enable for delay * 2^attempt.
max_retry_delay_ms 30_000 0 Cap on the exponential delay.

On a query, set it through QueryOptions::retry_policy:

use gpui_query::{QueryOptions, RetryPolicy};
# use gpui_query::hook::use_query;
let (_e, _) = use_query(
QueryOptions::new("flaky-endpoint")
.retry_policy(RetryPolicy::new(5).with_exponential_backoff()),
|_s| async { Ok::<_, gpui_query::QueryError>(vec![]) },
cx,
);

On a mutation, set it through MutationOptions::retry_policy:

use gpui_query::{MutationOptions, RetryPolicy};
# use gpui_query::hook::use_mutation;
let (entity, _sub) = use_mutation(
MutationOptions::default().retry_policy(RetryPolicy::new(2)),
cx,
);

You can also change a policy on an existing QueryResource at runtime via set_retry_policy, or read the active one with retry_policy().

delay_for_attempt(attempt) returns the delay before the attempt at the given 0-based index:

  • Without exponential backoff: every delay is retry_delay_ms. Five retries at 500ms wait 500ms each.
  • With exponential backoff: delay is retry_delay_ms * 2^attempt, capped first by max_retry_delay_ms and then by a hard ceiling of one hour. The shift is itself capped at 62 so the factor never overflows.
use gpui_query::RetryPolicy;
let p = RetryPolicy::new(5).with_delay(1_000).with_exponential_backoff().with_max_delay(30_000);
assert_eq!(p.delay_for_attempt(0), 1_000); // 1s
assert_eq!(p.delay_for_attempt(1), 2_000); // 2s
assert_eq!(p.delay_for_attempt(2), 4_000); // 4s
assert_eq!(p.delay_for_attempt(5), 30_000); // capped

should_retry(current_retries) returns true while current_retries < max_retries. That is the condition the resource loop checks to decide whether to schedule another attempt.

While the resource is between the initial failure and either a successful retry or exhausted retries, the status stays Loading (specifically LoadingEmpty on first load or LoadingWithData on a refetch). The retry counter increments via increment_retry(), but because the QueryObserver deduplicates by status, your view does not re-render on every retry tick, only on the terminal outcome (Success or Failure).

This is the reason retry can be aggressive by default without thrashing the UI: three retries with exponential backoff look like one loading state to the user, with the spinner shown until the request either succeeds or the policy is exhausted.

A retry in progress is cancelled if a newer request supersedes it (LatestWins) or the resource is reset/unmounted. The signal is checked after the retry delay, so a cancelled fetch does not waste an attempt. See Error handling: Error states in QueryStatus for how cancelled requests surface (QueryStatus::Cancelled, QueryErrorKind::Cancelled).

  • Non-idempotent mutations. A POST /charge that fails after the request was sent but before the response arrived may have already charged the card. Retrying can double-charge. Keep the no_retries default for these.
  • 4xx errors. A 400 or 401 will not succeed on retry; it just burns the retry budget and delays surfacing the real error. Map these to QueryError::response and consider a policy with fewer retries.
  • User-visible destructive actions. Let the user decide whether to retry, so they stay in control of side effects.

Retry is most useful for transient failures: network blips, 5xx responses, timeouts. For these, exponential backoff with an appropriate cap is usually the right call.