fix(relay): wait through a scheduled reconnect and name why Relay is not ready (#27141)

* fix(relay): wait through a scheduled reconnect and name why Relay is not ready

* fix(relay): add the new mint-failure notice strings to the English catalog

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 22:35:19 -07:00
committed by GitHub
co-authored by m4air
parent c527344685
commit 1f5e8df421
8 changed files with 461 additions and 44 deletions
+17 -13
View File
@@ -10,6 +10,7 @@ import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reas
import { readRelayAuthContext } from './relay-auth-context'
import { relayIdentityKey } from './relay-auth-identity'
import { RelayAuthCoordinator } from './relay-auth-coordinator'
import { RelayUnavailableError } from './relay-readiness'
import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker'
import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer'
import type {
@@ -34,6 +35,10 @@ type DesktopRelayServiceOptions = {
// transient auth read) must not stay dead until the user clicks Retry. The
// cadence is slow because it is a safety net, not the primary retry path.
const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000
// How long a demand caller waits through a scheduled reconnect. Phone requests
// get less than a desktop mint so the reply lands inside the phone's budget.
const PAIRING_MINT_READINESS_WAIT_MS = 20_000
const PHONE_REQUEST_READINESS_WAIT_MS = 10_000
export class DesktopRelayService {
private readonly coordinator: RelayAuthCoordinator
@@ -113,7 +118,7 @@ export class DesktopRelayService {
relayDeviceId: string
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> {
return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => {
const broker = await this.requireActiveBroker()
const broker = await this.requireActiveBroker(PAIRING_MINT_READINESS_WAIT_MS)
const relay = await broker.createPairingRelay(relayDeviceId)
return {
relay,
@@ -151,7 +156,9 @@ export class DesktopRelayService {
return { v: 1, relay: null }
}
return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => {
const broker = await this.activeBrokerForDemand()
const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS).catch(
() => null
)
if (!broker?.endpoint) {
return { v: 1, relay: null }
}
@@ -191,7 +198,7 @@ export class DesktopRelayService {
throw new Error('relay_disabled_for_device')
}
return await this.withTransientDemand(`provision:${context.deviceId}`, async () => {
const broker = await this.requireActiveBroker()
const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS)
if (!broker.endpoint) {
throw new Error('relay_control_not_active')
}
@@ -282,17 +289,14 @@ export class DesktopRelayService {
}
}
private async activeBrokerForDemand(): Promise<RelaySessionBroker | null> {
const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker())
return broker instanceof RelaySessionBroker ? broker : null
}
private async requireActiveBroker(): Promise<RelaySessionBroker> {
const broker = await this.activeBrokerForDemand()
if (!broker) {
throw new Error('relay_control_not_active')
private async requireActiveBroker(waitMs: number): Promise<RelaySessionBroker> {
const readiness = await this.coordinator.waitForReadiness(waitMs)
if (readiness.ready && readiness.broker instanceof RelaySessionBroker) {
return readiness.broker
}
return broker
throw new RelayUnavailableError(
readiness.ready ? { reason: 'control_not_active', retryAt: null } : readiness
)
}
private refreshDemand(): void {
@@ -0,0 +1,189 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mobileRelayMintFailureFromUnknown } from '../../../shared/mobile-relay-mint-failure'
import { DesktopRelayService } from './desktop-relay-service'
import { RelayAuthCoordinator } from './relay-auth-coordinator'
import type { RelayAuthContext } from './relay-auth-identity'
import { RelayHttpError } from './relay-http-client'
import { relayUnavailableReasonFor } from './relay-readiness'
import { RelaySessionBroker } from './relay-session-broker'
const context: RelayAuthContext = {
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
accessToken: 'access-1',
relayEntitled: true
}
function fakeSessionBroker(): RelaySessionBroker {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service only calls the stubbed members below.
const broker = Object.create(RelaySessionBroker.prototype) as RelaySessionBroker
Object.defineProperties(broker, {
closeNow: { value: vi.fn() },
isLive: { value: () => true },
createPairingRelay: { value: vi.fn(async () => ({ inviteExpiresAt: 1 })) },
endpoint: { value: null },
hostId: { value: 'host-1' },
ownerIdentityKey: { value: 'owner-1' }
})
return broker
}
function serviceWith(coordinator: RelayAuthCoordinator): DesktopRelayService {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createPairingRelay touches only the fields assigned here.
const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService
return Object.assign(service, {
coordinator,
stopped: false,
livenessTimer: null,
demandExpiryTimer: null,
demandLedger: { acquireTransient: () => () => {}, nextPendingExpiry: () => null }
})
}
async function mintFailureCode(service: DesktopRelayService): Promise<string> {
const error = await service.createPairingRelay('device-1').then(
() => null,
(caught: unknown) => caught
)
return mobileRelayMintFailureFromUnknown({
stage: 'create_pairing_relay',
error,
fallbackCode: 'relay_mint_failed',
fallbackMessage: 'failed'
}).code
}
describe('relay readiness', () => {
afterEach(() => vi.useRealTimers())
it('waits through the retry a cold-start failure scheduled instead of failing the mint', async () => {
vi.useFakeTimers()
const broker = fakeSessionBroker()
const openBroker = vi
.fn()
.mockRejectedValueOnce(new RelayHttpError('assignment', 503))
.mockResolvedValueOnce(broker)
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker,
onStatus: () => {},
random: () => 1
})
const service = serviceWith(coordinator)
const mint = service.createPairingRelay('device-1')
await vi.advanceTimersByTimeAsync(2_000)
await expect(mint).resolves.toMatchObject({ binding: { relayHostId: 'host-1' } })
expect(openBroker).toHaveBeenCalledTimes(2)
service.stop()
})
it('reports the failure cause when the retry falls outside the wait', async () => {
vi.useFakeTimers()
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker: async () => {
throw new RelayHttpError('assignment', 503, 60_000)
},
onStatus: () => {}
})
const service = serviceWith(coordinator)
await expect(mintFailureCode(service)).resolves.toBe('relay_director_assignment_503')
service.stop()
})
it('names a signed-out or unentitled desktop instead of relay_control_not_active', async () => {
let current: RelayAuthContext | null = null
const coordinator = new RelayAuthCoordinator({
readContext: async () => current,
openBroker: async () => fakeSessionBroker(),
onStatus: () => {}
})
const service = serviceWith(coordinator)
await expect(mintFailureCode(service)).resolves.toBe('relay_signed_out')
current = { ...context, relayEntitled: false }
await expect(mintFailureCode(service)).resolves.toBe('relay_not_entitled')
service.stop()
})
it('stops waiting when a fence cancels the scheduled retry', async () => {
vi.useFakeTimers()
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker: async () => {
throw new RelayHttpError('token-exchange', 502)
},
onStatus: () => {},
random: () => 1
})
coordinator.reconcile()
const waiting = coordinator.waitForReadiness(20_000)
await vi.advanceTimersByTimeAsync(0)
coordinator.fenceAndCloseNow()
await expect(waiting).resolves.toEqual({
ready: false,
reason: 'control_not_active',
retryAt: null
})
})
function coldStartWithHungRetry(): RelayAuthCoordinator {
const openBroker = vi
.fn()
.mockRejectedValueOnce(new RelayHttpError('assignment', 503))
.mockImplementationOnce(() => new Promise(() => {}))
return new RelayAuthCoordinator({
readContext: async () => context,
openBroker,
onStatus: () => {},
random: () => 0.5
})
}
it('returns at its deadline while the retry it followed is still opening', async () => {
vi.useFakeTimers()
const coordinator = coldStartWithHungRetry()
coordinator.reconcile()
let settled: unknown = null
void coordinator.waitForReadiness(10_000).then((readiness) => (settled = readiness))
await vi.advanceTimersByTimeAsync(9_999)
expect(settled).toBeNull()
await vi.advanceTimersByTimeAsync(1)
expect(settled).toMatchObject({ ready: false, reason: 'director_assignment_503' })
coordinator.stop()
})
it.each(['fence', 'stop'] as const)(
'a %s releases a waiter whose followed retry is still opening',
async (action) => {
vi.useFakeTimers()
const coordinator = coldStartWithHungRetry()
coordinator.reconcile()
let settled: unknown = null
void coordinator.waitForReadiness(20_000).then((readiness) => (settled = readiness))
await vi.advanceTimersByTimeAsync(1_000)
expect(settled).toBeNull()
if (action === 'fence') {
coordinator.fenceAndCloseNow()
} else {
coordinator.stop()
}
await vi.advanceTimersByTimeAsync(0)
expect(settled).toMatchObject({ ready: false, retryAt: null })
coordinator.stop()
}
)
it('classifies transport failures into actionable reasons', () => {
const tls = new TypeError('fetch failed', {
cause: Object.assign(new Error('unable to get local issuer certificate'), {
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'
})
})
expect(relayUnavailableReasonFor(tls)).toBe('tls_untrusted')
expect(relayUnavailableReasonFor(new RelayHttpError('assignment', 429))).toBe('rate_limited')
expect(relayUnavailableReasonFor(new Error('relay_control_closed_4429'))).toBe('rate_limited')
expect(relayUnavailableReasonFor(new Error('relay_control_connect_timeout'))).toBe(
'control_connect_timeout'
)
expect(relayUnavailableReasonFor(new Error('ECONNRESET with secret=abc'))).toBe('network')
})
})
@@ -6,6 +6,11 @@ import { relayStatusCellUrl } from '../../../shared/mobile-relay-status'
import type { RelayBrokerStatus } from './relay-session-broker'
import type { RelayAccessTokenRefresh } from './relay-session-broker-contract'
import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client'
import {
RelayReadinessWaiters,
relayUnavailableReasonFor,
type RelayUnavailable
} from './relay-readiness'
import { RelayRetrySchedule } from './relay-retry-schedule'
import { RelayLingerTimer } from './relay-linger-timer'
import { relayIdentityKey, type RelayAuthContext } from './relay-auth-identity'
@@ -29,6 +34,10 @@ type RelayAuthCoordinatorOptions = {
random?: () => number
}
export type RelayReadiness =
| { ready: true; broker: CoordinatedRelayBroker }
| ({ ready: false } & RelayUnavailable)
type BrokerOwnership = {
identityKey: string
broker: CoordinatedRelayBroker | null
@@ -51,6 +60,9 @@ export class RelayAuthCoordinator {
private latestReconcile: Promise<void> = Promise.resolve()
private readonly linger = new RelayLingerTimer()
private readonly retry: RelayRetrySchedule
// Why the latest reconcile left no live broker; cleared when one registers.
private unavailableReason: RelayUnavailable['reason'] | null = null
private readonly readinessWaiters = new RelayReadinessWaiters()
private stopped = false
constructor(options: RelayAuthCoordinatorOptions) {
@@ -87,6 +99,9 @@ export class RelayAuthCoordinator {
this.retry.reset()
this.invalidatePendingOwnerships()
this.invalidateOwnership(hostCloseReason)
this.unavailableReason =
hostCloseReason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT ? 'signed_out' : null
this.readinessWaiters.releaseAll()
this.publish('offline')
}
@@ -128,19 +143,45 @@ export class RelayAuthCoordinator {
this.beginReconcile(false)
}
readiness(): RelayReadiness {
const broker = this.getLiveBroker()
const reason = this.unavailableReason ?? 'control_not_active'
return broker ? { ready: true, broker } : { ready: false, reason, retryAt: this.retry.retryAt }
}
// Awaits the reconcile in flight (and any it was superseded by), never a retry.
async waitForLiveBroker(): Promise<CoordinatedRelayBroker | null> {
while (!this.stopped) {
const broker = this.getLiveBroker()
if (broker) {
return broker
}
const pending = this.latestReconcile
let pending: Promise<void> | null = null
while (!this.stopped && !this.getLiveBroker() && pending !== this.latestReconcile) {
pending = this.latestReconcile
await pending
if (pending === this.latestReconcile) {
return this.getLiveBroker()
}
return this.stopped ? null : this.getLiveBroker()
}
// Why waitMs: a cold start that fails once arms a retry seconds away; a caller
// with time to spare waits for it instead of reporting the first failure. The
// deadline bounds every wait, and a fence releases the caller at once.
async waitForReadiness(waitMs: number): Promise<RelayReadiness> {
const deadline = Date.now() + waitMs
while (!this.stopped && !this.getLiveBroker()) {
const pending = this.latestReconcile
if (!(await this.readinessWaiters.until(pending, deadline))) {
break
}
if (pending !== this.latestReconcile) {
continue
}
const retryAt = this.retry.retryAt
if (this.getLiveBroker() || retryAt === null || retryAt > deadline) {
break
}
const retried = await this.readinessWaiters.until(this.retry.settled(), deadline)
if (!retried || pending === this.latestReconcile) {
break
}
}
return null
return this.readiness()
}
stop(): void {
@@ -159,12 +200,14 @@ export class RelayAuthCoordinator {
this.linger.cancel()
this.retry.reset()
this.invalidateOwnership(authLossCloseReason(context))
this.unavailableReason = context ? 'not_entitled' : 'signed_out'
this.publish('offline')
return
}
const nextIdentityKey = relayIdentityKey(context.identity)
if (expectedIdentityKey && nextIdentityKey !== expectedIdentityKey) {
this.retry.reset()
this.unavailableReason = 'identity_changed'
this.publish('offline')
return
}
@@ -188,6 +231,7 @@ export class RelayAuthCoordinator {
(this.ownership.broker?.isLive?.() ?? true)
) {
this.retry.reset()
this.unavailableReason = null
this.publish('registered')
return
}
@@ -221,6 +265,7 @@ export class RelayAuthCoordinator {
}
this.ownership = ownership
this.retry.reset()
this.unavailableReason = null
this.publish('registered')
} catch (error) {
if (this.isEpochCurrent(epoch)) {
@@ -230,6 +275,7 @@ export class RelayAuthCoordinator {
'[relay] broker reconcile failed:',
error instanceof Error ? error.message : String(error)
)
this.unavailableReason = relayUnavailableReasonFor(error)
this.publish('offline')
if (shouldRetryRelayConnectionError(error)) {
const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0
+110
View File
@@ -0,0 +1,110 @@
import { RelayHttpError } from './relay-http-client'
// Why the last reconcile could not produce a live broker. Every value is a
// lowercase token so `relay_<reason>` stays a valid mint failure code.
export type RelayUnavailableReason =
| 'signed_out'
| 'not_entitled'
| 'identity_changed'
| 'rate_limited'
| 'tls_untrusted'
| 'connect_timeout'
| 'network'
| 'control_not_active'
| `director_${'token_exchange' | 'assignment'}_${number}`
// A relay-coded error message with its `relay_` prefix removed.
| `control_${string}`
export type RelayUnavailable = { reason: RelayUnavailableReason; retryAt: number | null }
const TLS_TRUST_ERROR_CODES = new Set([
'UNABLE_TO_GET_ISSUER_CERT',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'CERT_UNTRUSTED',
'CERT_SIGNATURE_FAILURE'
])
const TIMEOUT_ERROR_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'])
const RELAY_CONTROL_MESSAGE = /^relay_(control_[a-z0-9_]{1,60})$/
function errorCode(error: unknown): string | null {
return typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof error.code === 'string'
? error.code
: null
}
// undici wraps the socket error as `TypeError('fetch failed', { cause })`.
function errorChain(error: unknown): unknown[] {
const chain: unknown[] = []
let current = error
while (current !== undefined && current !== null && chain.length < 4) {
chain.push(current)
current = current instanceof Error ? current.cause : undefined
}
return chain
}
export function relayUnavailableReasonFor(error: unknown): RelayUnavailableReason {
if (error instanceof RelayHttpError) {
return error.statusCode === 429
? 'rate_limited'
: `director_${error.operation === 'token-exchange' ? 'token_exchange' : 'assignment'}_${error.statusCode}`
}
for (const link of errorChain(error)) {
const code = errorCode(link)
if (code && (TLS_TRUST_ERROR_CODES.has(code) || code.startsWith('CERT_'))) {
return 'tls_untrusted'
}
if (code && TIMEOUT_ERROR_CODES.has(code)) {
return 'connect_timeout'
}
}
const message = error instanceof Error ? RELAY_CONTROL_MESSAGE.exec(error.message) : null
if (message?.[1] === 'control_closed_4429') {
return 'rate_limited'
}
return message ? `control_${message[1].slice('control_'.length)}` : 'network'
}
// Bounds a readiness wait by its caller's deadline and lets a fence release it,
// without cancelling the shared reconnect work other callers still need.
export class RelayReadinessWaiters {
private readonly releases = new Set<() => void>()
// Resolves true when `work` settles first, false at the deadline or on release.
async until(work: Promise<unknown>, deadline: number): Promise<boolean> {
let release = (): void => {}
const released = new Promise<false>((resolve) => {
release = () => resolve(false)
})
const timer = setTimeout(release, Math.max(0, deadline - Date.now()))
this.releases.add(release)
try {
return await Promise.race([work.then(() => true), released])
} finally {
clearTimeout(timer)
this.releases.delete(release)
}
}
releaseAll(): void {
for (const release of this.releases) {
release()
}
}
}
// Carries the readiness reason as a `relay_*` code that mint failure reporting forwards as-is.
export class RelayUnavailableError extends Error {
readonly code: string
constructor(readonly unavailable: RelayUnavailable) {
super(`relay_${unavailable.reason}`)
this.code = this.message
}
}
+37 -9
View File
@@ -6,6 +6,8 @@ const RETRY_MAX_MS = 5 * 60_000
export class RelayRetrySchedule {
private timer: ReturnType<typeof setTimeout> | null = null
private attempt = 0
private dueAt: number | null = null
private readonly settledWaiters = new Set<() => void>()
constructor(private readonly random: () => number = Math.random) {}
@@ -13,6 +15,17 @@ export class RelayRetrySchedule {
return this.timer !== null
}
get retryAt(): number | null {
return this.dueAt
}
// Resolves once the pending retry has run or was cancelled.
settled(): Promise<void> {
return this.timer
? new Promise((resolve) => this.settledWaiters.add(resolve))
: Promise.resolve()
}
schedule(retryAfterMs: number, retry: () => void): void {
if (this.timer) {
return
@@ -20,14 +33,13 @@ export class RelayRetrySchedule {
const exponent = Math.min(this.attempt, Math.ceil(Math.log2(RETRY_MAX_MS / RETRY_BASE_MS)))
const capMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** exponent)
this.attempt++
const jitterMs = Math.floor(this.random() * (capMs + 1))
this.timer = setTimeout(
() => {
this.timer = null
retry()
},
Math.max(jitterMs, retryAfterMs)
)
const delayMs = Math.max(Math.floor(this.random() * (capMs + 1)), retryAfterMs)
this.dueAt = Date.now() + delayMs
this.timer = setTimeout(() => {
this.clearTimer()
retry()
this.settle()
}, delayMs)
}
reset(): void {
@@ -36,9 +48,25 @@ export class RelayRetrySchedule {
// Keeps the attempt count: a superseding reconcile still backs off from it.
cancel(): void {
if (this.timer) {
this.clearTimer()
this.settle()
}
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
this.timer = null
this.dueAt = null
}
private settle(): void {
const waiters = [...this.settledWaiters]
this.settledWaiters.clear()
for (const resolve of waiters) {
resolve()
}
}
}
@@ -235,6 +235,28 @@ describe('HeroFlow height', () => {
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
})
it('names a signed-out or unentitled desktop instead of offering a futile retry', () => {
renderFlow(1, {
relayMintFailure: {
code: 'relay_signed_out',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
expect(screen.getByRole('alert')).toHaveTextContent('Sign in again to use Orca Relay')
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
cleanup()
renderFlow(1, {
relayMintFailure: {
code: 'relay_not_entitled',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
expect(screen.getByRole('alert')).toHaveTextContent('isn’t included with this Orca account')
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
})
it('hides the mint-failure notice when a Relay QR is shown', () => {
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' })
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
@@ -25,9 +25,10 @@ export function MobileRelayMintFailureNotice({
}): React.JSX.Element {
const providerMissing = failure.stage === 'provider_missing'
// Why: a revoked cloud session fails every mint; "retry or use LAN" hides the one action that works.
const reconnectRequired = useAppStore(
(state) => state.orcaProfileAuthStatus?.state === 'reconnect-required'
)
const reconnectRequired =
useAppStore((state) => state.orcaProfileAuthStatus?.state === 'reconnect-required') ||
failure.code === 'relay_signed_out'
const notEntitled = failure.code === 'relay_not_entitled'
const [showBusyFeedback, setShowBusyFeedback] = useState(false)
useEffect(() => {
if (!busy) {
@@ -53,10 +54,15 @@ export function MobileRelayMintFailureNotice({
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle',
'Your Orca account session expired.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldn’t create a Relay pairing code.'
)
: notEntitled
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.notEntitledTitle',
'Orca Relay isn’t included with this Orca account.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldn’t create a Relay pairing code.'
)
const body = visibleBusy
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
@@ -72,10 +78,20 @@ export function MobileRelayMintFailureNotice({
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody',
'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
)
: notEntitled
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
)
: failure.code === 'relay_rate_limited'
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.rateLimitedBody',
'Orca Relay is busy. Wait a minute and retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
)
return (
<div
@@ -105,7 +121,7 @@ export function MobileRelayMintFailureNotice({
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
</Button>
{!providerMissing && !reconnectRequired ? (
{!providerMissing && !reconnectRequired && !notEntitled ? (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
+3 -1
View File
@@ -14741,7 +14741,9 @@
"retry": "Retry Relay",
"copyDiagnostics": "Copy diagnostics",
"reconnectTitle": "Your Orca account session expired.",
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi."
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.",
"notEntitledTitle": "Orca Relay isn’t included with this Orca account.",
"rateLimitedBody": "Orca Relay is busy. Wait a minute and retry, or use LAN to pair over Tailscale or the same Wi‑Fi."
}
},
"gitlab": {