authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:58:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:58:10-07:00
logeb5eb0d9fa99364aa3b5091c62d6a187f5eef375
treece5e6cebdb735c88da3cd5a8c04d0a53fc9a2c21
parent621616268af212c9550aecc883b75beb42b3c1bd
signature Signed by PGP key B5690EEEBB952194

[codex] Structure managed endpoint allocation failures (#3421)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 177 insertions(+), 130 deletions(-)

infra/relay/src/environments/ManagedEndpointAllocations.ts+1-1
...@@ -120,7 +120,7 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) =>...@@ -120,7 +120,7 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) =>
120 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),120 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),
121 );121 );
122122
123const make = Effect.gen(function* () {123export const make = Effect.gen(function* () {
124 const db = yield* RelayDb.RelayDb;124 const db = yield* RelayDb.RelayDb;
125125
126 return ManagedEndpointAllocations.of({126 return ManagedEndpointAllocations.of({
infra/relay/src/environments/ManagedEndpointProvider.test.ts+57-3
...@@ -133,7 +133,7 @@ function makeDnsClient(...@@ -133,7 +133,7 @@ function makeDnsClient(
133 operation: "update-record",133 operation: "update-record",
134 hostname: request.name,134 hostname: request.name,
135 dnsRecordId,135 dnsRecordId,
136 cause: `DNS record ${dnsRecordId} does not exist.`,136 cause: { _tag: "NotFound", dnsRecordId },
137 });137 });
138 }138 }
139 }),139 }),
...@@ -531,6 +531,59 @@ describe("ManagedEndpointProvider", () => {...@@ -531,6 +531,59 @@ describe("ManagedEndpointProvider", () => {
531 }).pipe(Effect.provide(layer));531 }).pipe(Effect.provide(layer));
532 });532 });
533533
534 it.effect("does not hide non-not-found checkpoint update failures", () => {
535 const dnsCalls: DnsCall[] = [];
536 const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({
537 operation: "update-record",
538 dnsRecordId: "created-record-id",
539 cause: new Error("Cloudflare DNS unavailable"),
540 });
541 let records: ReadonlyArray<{ readonly id: string }> = [];
542 const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({
543 listRecords: (hostname) =>
544 Effect.sync(() => {
545 dnsCalls.push({ operation: "listRecords", input: hostname });
546 return records;
547 }),
548 createRecord: (request) =>
549 Effect.sync(() => {
550 dnsCalls.push({ operation: "createRecord", input: request });
551 const record = { id: "created-record-id" };
552 records = [record];
553 return record;
554 }),
555 updateRecord: (dnsRecordId, request) =>
556 Effect.sync(() => {
557 dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } });
558 }).pipe(Effect.andThen(Effect.fail(failure))),
559 deleteRecord: () => Effect.void,
560 });
561 const layer = providerLayer(makePersistentTunnelClient(), dnsClient, makeAllocations());
562
563 return Effect.gen(function* () {
564 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
565 const request = {
566 userId: "user_ABC",
567 environmentId: "env_ABC",
568 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
569 } as const;
570 yield* provider.provision(request);
571 const error = yield* Effect.flip(provider.provision(request));
572
573 expect(error).toMatchObject({
574 _tag: "ManagedEndpointProvisioningFailed",
575 stage: "ensure-dns-record",
576 userId: "user_ABC",
577 environmentId: "env_ABC",
578 });
579 expect(dnsCalls.map((call) => call.operation)).toEqual([
580 "listRecords",
581 "createRecord",
582 "updateRecord",
583 ]);
584 }).pipe(Effect.provide(layer));
585 });
586
534 it.effect(587 it.effect(
535 "deprovisions checkpointed DNS and tunnel resources before removing the allocation",588 "deprovisions checkpointed DNS and tunnel resources before removing the allocation",
536 () => {589 () => {
...@@ -765,11 +818,11 @@ describe("ManagedEndpointProvider", () => {...@@ -765,11 +818,11 @@ describe("ManagedEndpointProvider", () => {
765 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient)));818 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient)));
766 });819 });
767820
768 it.effect("reports malformed tunnel responses without manufacturing a cause", () => {821 it.effect("reports mismatched tunnel responses without manufacturing a cause", () => {
769 const dnsCalls: DnsCall[] = [];822 const dnsCalls: DnsCall[] = [];
770 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({823 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
771 ...makeTunnelClient(),824 ...makeTunnelClient(),
772 create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }),825 create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }),
773 });826 });
774827
775 return Effect.gen(function* () {828 return Effect.gen(function* () {
...@@ -790,6 +843,7 @@ describe("ManagedEndpointProvider", () => {...@@ -790,6 +843,7 @@ describe("ManagedEndpointProvider", () => {
790 hostname: expectedManagedHostname("env_ABC"),843 hostname: expectedManagedHostname("env_ABC"),
791 tunnelName: expectedManagedTunnelName("env_ABC"),844 tunnelName: expectedManagedTunnelName("env_ABC"),
792 returnedTunnelId: "returned-tunnel-id",845 returnedTunnelId: "returned-tunnel-id",
846 returnedTunnelName: "unexpected-tunnel",
793 });847 });
794 if (error._tag === "ManagedEndpointProvisioningFailed") {848 if (error._tag === "ManagedEndpointProvisioningFailed") {
795 expect(error.cause).toBeUndefined();849 expect(error.cause).toBeUndefined();
infra/relay/src/environments/ManagedEndpointProvider.ts+119-126
...@@ -192,11 +192,8 @@ export class ManagedEndpointTunnelClient extends Context.Service<...@@ -192,11 +192,8 @@ export class ManagedEndpointTunnelClient extends Context.Service<
192 }192 }
193>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {}193>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {}
194194
195export const makeTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>
196 ManagedEndpointTunnelClient.of(client);
197
198export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>195export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>
199 Layer.succeed(ManagedEndpointTunnelClient, makeTunnelClient(client));196 Layer.succeed(ManagedEndpointTunnelClient, client);
200197
201interface ManagedEndpointCnameRecordInput {198interface ManagedEndpointCnameRecordInput {
202 readonly type: "CNAME";199 readonly type: "CNAME";
...@@ -247,11 +244,8 @@ export class ManagedEndpointDnsClient extends Context.Service<...@@ -247,11 +244,8 @@ export class ManagedEndpointDnsClient extends Context.Service<
247 }244 }
248>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {}245>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {}
249246
250export const makeDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
251 ManagedEndpointDnsClient.of(client);
252
253export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>247export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
254 Layer.succeed(ManagedEndpointDnsClient, makeDnsClient(client));248 Layer.succeed(ManagedEndpointDnsClient, client);
255249
256const requireCloudflareSettings = Effect.fnUntraced(function* (250const requireCloudflareSettings = Effect.fnUntraced(function* (
257 settings: RelayConfiguration.RelayConfiguration["Service"],251 settings: RelayConfiguration.RelayConfiguration["Service"],
...@@ -331,7 +325,7 @@ const ignoreNotFound = <A>(...@@ -331,7 +325,7 @@ const ignoreNotFound = <A>(
331 }),325 }),
332 );326 );
333327
334const make = Effect.gen(function* () {328export const make = Effect.gen(function* () {
335 const config = yield* RelayConfiguration.RelayConfiguration;329 const config = yield* RelayConfiguration.RelayConfiguration;
336 const crypto = yield* Crypto.Crypto;330 const crypto = yield* Crypto.Crypto;
337 const tunnels = yield* ManagedEndpointTunnelClient;331 const tunnels = yield* ManagedEndpointTunnelClient;
...@@ -366,7 +360,10 @@ const make = Effect.gen(function* () {...@@ -366,7 +360,10 @@ const make = Effect.gen(function* () {
366 .updateRecord(preferredDnsRecordId, dnsRecord)360 .updateRecord(preferredDnsRecordId, dnsRecord)
367 .pipe(361 .pipe(
368 Effect.as(true),362 Effect.as(true),
369 Effect.orElseSucceed(() => false),363 Effect.catchTags({
364 ManagedEndpointDnsClientError: (error) =>
365 isNotFoundCause(error.cause) ? Effect.succeed(false) : Effect.fail(error),
366 }),
370 );367 );
371 if (checkpointedRecordUpdated) {368 if (checkpointedRecordUpdated) {
372 return preferredDnsRecordId;369 return preferredDnsRecordId;
...@@ -550,7 +547,7 @@ const make = Effect.gen(function* () {...@@ -550,7 +547,7 @@ const make = Effect.gen(function* () {
550 }),547 }),
551 ),548 ),
552 );549 );
553 if (!tunnelResponse.id || !tunnelResponse.name) {550 if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) {
554 return yield* new ManagedEndpointProvisioningFailed({551 return yield* new ManagedEndpointProvisioningFailed({
555 userId: input.userId,552 userId: input.userId,
556 environmentId: input.environmentId,553 environmentId: input.environmentId,
...@@ -712,131 +709,127 @@ export const layerCloudflareBindings = (...@@ -712,131 +709,127 @@ export const layerCloudflareBindings = (
712 layer.pipe(709 layer.pipe(
713 Layer.provide(710 Layer.provide(
714 Layer.mergeAll(711 Layer.mergeAll(
715 layerTunnelClient(712 layerTunnelClient({
716 ManagedEndpointTunnelClient.of({713 list: (request) =>
717 list: (request) =>714 tunnelClient.list(request).pipe(
718 tunnelClient.list(request).pipe(715 Effect.mapError(
719 Effect.mapError(716 (cause) =>
720 (cause) =>717 new ManagedEndpointTunnelClientError({
721 new ManagedEndpointTunnelClientError({718 operation: "list",
722 operation: "list",719 tunnelName: request.name,
723 tunnelName: request.name,720 cause,
724 cause,721 }),
725 }),
726 ),
727 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
728 ),722 ),
729 create: (request) =>723 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
730 tunnelClient.create(request).pipe(724 ),
731 Effect.mapError(725 create: (request) =>
732 (cause) =>726 tunnelClient.create(request).pipe(
733 new ManagedEndpointTunnelClientError({727 Effect.mapError(
734 operation: "create",728 (cause) =>
735 tunnelName: request.name,729 new ManagedEndpointTunnelClientError({
736 cause,730 operation: "create",
737 }),731 tunnelName: request.name,
738 ),732 cause,
739 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),733 }),
740 ),734 ),
741 putConfiguration: (tunnelId, config) =>735 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
742 tunnelClient.putConfiguration(tunnelId, config).pipe(736 ),
743 Effect.mapError(737 putConfiguration: (tunnelId, config) =>
744 (cause) =>738 tunnelClient.putConfiguration(tunnelId, config).pipe(
745 new ManagedEndpointTunnelClientError({739 Effect.mapError(
746 operation: "put-configuration",740 (cause) =>
747 tunnelId,741 new ManagedEndpointTunnelClientError({
748 cause,742 operation: "put-configuration",
749 }),743 tunnelId,
750 ),744 cause,
751 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),745 }),
752 ),746 ),
753 getToken: (tunnelId) =>747 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
754 tunnelClient.getToken(tunnelId).pipe(748 ),
755 Effect.mapError(749 getToken: (tunnelId) =>
756 (cause) =>750 tunnelClient.getToken(tunnelId).pipe(
757 new ManagedEndpointTunnelClientError({751 Effect.mapError(
758 operation: "get-token",752 (cause) =>
759 tunnelId,753 new ManagedEndpointTunnelClientError({
760 cause,754 operation: "get-token",
761 }),755 tunnelId,
762 ),756 cause,
763 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),757 }),
764 ),758 ),
765 delete: (tunnelId) =>759 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
766 tunnelClient.delete(tunnelId).pipe(760 ),
767 Effect.mapError(761 delete: (tunnelId) =>
768 (cause) =>762 tunnelClient.delete(tunnelId).pipe(
769 new ManagedEndpointTunnelClientError({763 Effect.mapError(
770 operation: "delete",764 (cause) =>
771 tunnelId,765 new ManagedEndpointTunnelClientError({
772 cause,766 operation: "delete",
773 }),767 tunnelId,
774 ),768 cause,
775 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),769 }),
776 ),770 ),
777 }),771 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
778 ),772 ),
779 layerDnsClient(773 }),
780 ManagedEndpointDnsClient.of({774 layerDnsClient({
781 listRecords: (hostname) =>775 listRecords: (hostname) =>
782 dnsClient.listDnsRecords({ search: hostname }).pipe(776 dnsClient.listDnsRecords({ search: hostname }).pipe(
783 Effect.map((response) =>777 Effect.map((response) =>
784 response.result.filter(778 response.result.filter(
785 (record): record is typeof record & { readonly id: string } =>779 (record): record is typeof record & { readonly id: string } =>
786 typeof record.id === "string" &&780 typeof record.id === "string" &&
787 normalizeHostname(record.name) === normalizeHostname(hostname),781 normalizeHostname(record.name) === normalizeHostname(hostname),
788 ),
789 ),
790 Effect.mapError(
791 (cause) =>
792 new ManagedEndpointDnsClientError({
793 operation: "list-records",
794 hostname,
795 cause,
796 }),
797 ),782 ),
798 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
799 ),783 ),
800 createRecord: (request) =>784 Effect.mapError(
801 dnsClient.createDnsRecord(request).pipe(785 (cause) =>
802 Effect.map((response) => ({ id: response.id })),786 new ManagedEndpointDnsClientError({
803 Effect.mapError(787 operation: "list-records",
804 (cause) =>788 hostname,
805 new ManagedEndpointDnsClientError({789 cause,
806 operation: "create-record",790 }),
807 hostname: request.name,
808 cause,
809 }),
810 ),
811 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
812 ),791 ),
813 updateRecord: (dnsRecordId, request) =>792 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
814 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(793 ),
815 Effect.mapError(794 createRecord: (request) =>
816 (cause) =>795 dnsClient.createDnsRecord(request).pipe(
817 new ManagedEndpointDnsClientError({796 Effect.map((response) => ({ id: response.id })),
818 operation: "update-record",797 Effect.mapError(
819 hostname: request.name,798 (cause) =>
820 dnsRecordId,799 new ManagedEndpointDnsClientError({
821 cause,800 operation: "create-record",
822 }),801 hostname: request.name,
823 ),802 cause,
824 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),803 }),
825 ),804 ),
826 deleteRecord: (dnsRecordId) =>805 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
827 dnsClient.deleteDnsRecord(dnsRecordId).pipe(806 ),
828 Effect.mapError(807 updateRecord: (dnsRecordId, request) =>
829 (cause) =>808 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(
830 new ManagedEndpointDnsClientError({809 Effect.mapError(
831 operation: "delete-record",810 (cause) =>
832 dnsRecordId,811 new ManagedEndpointDnsClientError({
833 cause,812 operation: "update-record",
834 }),813 hostname: request.name,
835 ),814 dnsRecordId,
836 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),815 cause,
816 }),
837 ),817 ),
838 }),818 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
839 ),819 ),
820 deleteRecord: (dnsRecordId) =>
821 dnsClient.deleteDnsRecord(dnsRecordId).pipe(
822 Effect.mapError(
823 (cause) =>
824 new ManagedEndpointDnsClientError({
825 operation: "delete-record",
826 dnsRecordId,
827 cause,
828 }),
829 ),
830 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
831 ),
832 }),
840 ),833 ),
841 ),834 ),
842 );835 );