authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 07:31:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 07:31:53-07:00
log1877237c7ae99ecf2163b4c5bb7d3e14bebcca4d
tree5bb28c9ce2f991cc90ab17aded77e4a7e3c6f515
parente0513d29839f934df1d5a13551e6ccd76d11006b
signature Signed by PGP key B5690EEEBB952194

fix(connect): reboots no longer strand the relay link, 403s now say why (#4988)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 63 insertions(+), 21 deletions(-)

apps/mobile/src/features/cloud/linkEnvironment.ts+8-1
......@@ -134,7 +134,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
134134 case "RelayEnvironmentLinkProofInvalidError":
135135 return `Relay rejected the environment link proof (${error.reason}).`;
136136 case "RelayEnvironmentConnectNotAuthorizedError":
137 return "Relay rejected the environment connection request.";
137 // "Not authorized" covers non-auth causes too; surface the reason so a
138 // missing link doesn't read as a credential problem.
139 if (error.reason === "environment_link_not_found") {
140 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
141 }
142 return error.reason
143 ? `Relay rejected the environment connection request (${error.reason}).`
144 : "Relay rejected the environment connection request.";
138145 case "RelayEnvironmentEndpointUnavailableError":
139146 return `Relay could not reach the environment endpoint (${error.reason}).`;
140147 case "RelayEnvironmentEndpointTimedOutError":
apps/server/src/server.ts+21-1
......@@ -1,6 +1,8 @@
11import { EnvironmentHttpApi } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
46import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
57import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
68
......@@ -535,7 +537,25 @@ export const makeServerLayer = Layer.unwrap(
535537 yield* Effect.forkScoped(
536538 Effect.sleep("250 millis").pipe(
537539 Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)),
538 Effect.retry({ times: 4 }),
540 // On reboot this races NIC/DNS bring-up, so back off exponentially
541 // (capped at 30s) instead of burning all retries in a second.
542 // Bounded overall so a permanently broken setup still surfaces the
543 // warning below. Bad-request/unauthorized/conflict are
544 // deterministic failures (malformed origin, not linked yet, linked
545 // to a different cloud account) that no amount of retrying
546 // converges.
547 Effect.retry({
548 while: (error) =>
549 error._tag !== "EnvironmentHttpBadRequestError" &&
550 error._tag !== "EnvironmentHttpUnauthorizedError" &&
551 error._tag !== "EnvironmentHttpConflictError",
552 schedule: Schedule.exponential("1 second").pipe(
553 Schedule.modifyDelay(({ duration }) =>
554 Effect.succeed(Duration.min(duration, Duration.seconds(30))),
555 ),
556 Schedule.upTo({ duration: "10 minutes" }),
557 ),
558 }),
539559 Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")),
540560 Effect.catch((cause) =>
541561 Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", {
apps/web/src/cloud/linkEnvironment.ts+8-1
......@@ -145,7 +145,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
145145 case "RelayEnvironmentLinkProofInvalidError":
146146 return `Relay rejected the environment link proof (${error.reason}).`;
147147 case "RelayEnvironmentConnectNotAuthorizedError":
148 return "Relay rejected the environment connection request.";
148 // "Not authorized" covers non-auth causes too; surface the reason so a
149 // missing link doesn't read as a credential problem.
150 if (error.reason === "environment_link_not_found") {
151 return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
152 }
153 return error.reason
154 ? `Relay rejected the environment connection request (${error.reason}).`
155 : "Relay rejected the environment connection request.";
149156 case "RelayEnvironmentEndpointUnavailableError":
150157 return `Relay could not reach the environment endpoint (${error.reason}).`;
151158 case "RelayEnvironmentEndpointTimedOutError":
infra/relay/src/environments/EnvironmentConnector.ts+3-15
......@@ -13,6 +13,7 @@ import {
1313 RelayEnvironmentMintResponse,
1414 RelayEnvironmentMintResponseProofPayload,
1515 RelayCloudMintCredentialProofPayload,
16 RelayEnvironmentConnectNotAuthorizedReason,
1617 type RelayEnvironmentConnectResponse,
1718 type RelayEnvironmentStatusResponse,
1819} from "@t3tools/contracts/relay";
......@@ -44,21 +45,8 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
4445import * as RelayConfiguration from "../Config.ts";
4546import { isManagedEndpointHostname } from "../deploymentConfig.ts";
4647
47export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([
48 "client_proof_key_thumbprint_missing",
49 "environment_link_not_found",
50 "endpoint_provider_not_managed",
51 "managed_endpoint_allocation_not_found",
52 "managed_endpoint_base_domain_not_configured",
53 "managed_endpoint_allocation_not_ready",
54 "managed_endpoint_hostname_invalid",
55 "managed_endpoint_mismatch",
56]);
57export type EnvironmentConnectNotAuthorizedReason =
58 typeof EnvironmentConnectNotAuthorizedReason.Type;
59
6048function environmentConnectNotAuthorizedReasonMessage(
61 reason: EnvironmentConnectNotAuthorizedReason,
49 reason: RelayEnvironmentConnectNotAuthorizedReason,
6250): string {
6351 switch (reason) {
6452 case "client_proof_key_thumbprint_missing":
......@@ -85,7 +73,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass<Env
8573 {
8674 environmentId: Schema.String,
8775 operation: Schema.Literals(["connect", "status"]),
88 reason: EnvironmentConnectNotAuthorizedReason,
76 reason: RelayEnvironmentConnectNotAuthorizedReason,
8977 },
9078) {
9179 override get message(): string {
infra/relay/src/http/Api.ts+4-2
......@@ -778,9 +778,10 @@ export const dpopClientApi = HttpApiBuilder.group(
778778 },
779779 mapRelayCommonApiErrors("invalid_dpop"),
780780 mapErrorTags({
781 EnvironmentConnectNotAuthorized: (_error, traceId) =>
781 EnvironmentConnectNotAuthorized: (error, traceId) =>
782782 new RelayEnvironmentConnectNotAuthorizedError({
783783 code: "environment_connect_not_authorized",
784 reason: error.reason,
784785 traceId,
785786 }),
786787 EnvironmentMintRequestFailed: (_error, traceId) =>
......@@ -820,9 +821,10 @@ export const dpopClientApi = HttpApiBuilder.group(
820821 },
821822 mapRelayCommonApiErrors("invalid_dpop"),
822823 mapErrorTags({
823 EnvironmentConnectNotAuthorized: (_error, traceId) =>
824 EnvironmentConnectNotAuthorized: (error, traceId) =>
824825 new RelayEnvironmentConnectNotAuthorizedError({
825826 code: "environment_connect_not_authorized",
827 reason: error.reason,
826828 traceId,
827829 }),
828830 EnvironmentMintRequestFailed: (_error, traceId) =>
packages/contracts/src/relay.ts+19-1
......@@ -371,16 +371,34 @@ export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorCla
371371 }
372372}
373373
374export const RelayEnvironmentConnectNotAuthorizedReason = Schema.Literals([
375 "client_proof_key_thumbprint_missing",
376 "environment_link_not_found",
377 "endpoint_provider_not_managed",
378 "managed_endpoint_allocation_not_found",
379 "managed_endpoint_base_domain_not_configured",
380 "managed_endpoint_allocation_not_ready",
381 "managed_endpoint_hostname_invalid",
382 "managed_endpoint_mismatch",
383]);
384export type RelayEnvironmentConnectNotAuthorizedReason =
385 typeof RelayEnvironmentConnectNotAuthorizedReason.Type;
386
374387export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass<RelayEnvironmentConnectNotAuthorizedError>()(
375388 "RelayEnvironmentConnectNotAuthorizedError",
376389 {
377390 code: Schema.Literal("environment_connect_not_authorized"),
391 // Optional so responses from relays deployed before the reason was
392 // threaded through still decode.
393 reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason),
378394 traceId: TrimmedNonEmptyString,
379395 },
380396 { httpApiStatus: 403 },
381397) {
382398 override get message(): string {
383 return "Relay environment connection is not authorized";
399 return this.reason
400 ? `Relay environment connection is not authorized: ${this.reason}`
401 : "Relay environment connection is not authorized";
384402 }
385403}
386404