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) =>
120120 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),
121121 );
122122
123const make = Effect.gen(function* () {
123export const make = Effect.gen(function* () {
124124 const db = yield* RelayDb.RelayDb;
125125
126126 return ManagedEndpointAllocations.of({
infra/relay/src/environments/ManagedEndpointProvider.test.ts+57-3
......@@ -133,7 +133,7 @@ function makeDnsClient(
133133 operation: "update-record",
134134 hostname: request.name,
135135 dnsRecordId,
136 cause: `DNS record ${dnsRecordId} does not exist.`,
136 cause: { _tag: "NotFound", dnsRecordId },
137137 });
138138 }
139139 }),
......@@ -531,6 +531,59 @@ describe("ManagedEndpointProvider", () => {
531531 }).pipe(Effect.provide(layer));
532532 });
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
534587 it.effect(
535588 "deprovisions checkpointed DNS and tunnel resources before removing the allocation",
536589 () => {
......@@ -765,11 +818,11 @@ describe("ManagedEndpointProvider", () => {
765818 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient)));
766819 });
767820
768 it.effect("reports malformed tunnel responses without manufacturing a cause", () => {
821 it.effect("reports mismatched tunnel responses without manufacturing a cause", () => {
769822 const dnsCalls: DnsCall[] = [];
770823 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
771824 ...makeTunnelClient(),
772 create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }),
825 create: () => Effect.succeed({ id: "returned-tunnel-id", name: "unexpected-tunnel" }),
773826 });
774827
775828 return Effect.gen(function* () {
......@@ -790,6 +843,7 @@ describe("ManagedEndpointProvider", () => {
790843 hostname: expectedManagedHostname("env_ABC"),
791844 tunnelName: expectedManagedTunnelName("env_ABC"),
792845 returnedTunnelId: "returned-tunnel-id",
846 returnedTunnelName: "unexpected-tunnel",
793847 });
794848 if (error._tag === "ManagedEndpointProvisioningFailed") {
795849 expect(error.cause).toBeUndefined();
infra/relay/src/environments/ManagedEndpointProvider.ts+119-126
......@@ -192,11 +192,8 @@ export class ManagedEndpointTunnelClient extends Context.Service<
192192 }
193193>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {}
194194
195export const makeTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>
196 ManagedEndpointTunnelClient.of(client);
197
198195export const layerTunnelClient = (client: ManagedEndpointTunnelClient["Service"]) =>
199 Layer.succeed(ManagedEndpointTunnelClient, makeTunnelClient(client));
196 Layer.succeed(ManagedEndpointTunnelClient, client);
200197
201198interface ManagedEndpointCnameRecordInput {
202199 readonly type: "CNAME";
......@@ -247,11 +244,8 @@ export class ManagedEndpointDnsClient extends Context.Service<
247244 }
248245>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {}
249246
250export const makeDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
251 ManagedEndpointDnsClient.of(client);
252
253247export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
254 Layer.succeed(ManagedEndpointDnsClient, makeDnsClient(client));
248 Layer.succeed(ManagedEndpointDnsClient, client);
255249
256250const requireCloudflareSettings = Effect.fnUntraced(function* (
257251 settings: RelayConfiguration.RelayConfiguration["Service"],
......@@ -331,7 +325,7 @@ const ignoreNotFound = <A>(
331325 }),
332326 );
333327
334const make = Effect.gen(function* () {
328export const make = Effect.gen(function* () {
335329 const config = yield* RelayConfiguration.RelayConfiguration;
336330 const crypto = yield* Crypto.Crypto;
337331 const tunnels = yield* ManagedEndpointTunnelClient;
......@@ -366,7 +360,10 @@ const make = Effect.gen(function* () {
366360 .updateRecord(preferredDnsRecordId, dnsRecord)
367361 .pipe(
368362 Effect.as(true),
369 Effect.orElseSucceed(() => false),
363 Effect.catchTags({
364 ManagedEndpointDnsClientError: (error) =>
365 isNotFoundCause(error.cause) ? Effect.succeed(false) : Effect.fail(error),
366 }),
370367 );
371368 if (checkpointedRecordUpdated) {
372369 return preferredDnsRecordId;
......@@ -550,7 +547,7 @@ const make = Effect.gen(function* () {
550547 }),
551548 ),
552549 );
553 if (!tunnelResponse.id || !tunnelResponse.name) {
550 if (!tunnelResponse.id || tunnelResponse.name !== tunnelName) {
554551 return yield* new ManagedEndpointProvisioningFailed({
555552 userId: input.userId,
556553 environmentId: input.environmentId,
......@@ -712,131 +709,127 @@ export const layerCloudflareBindings = (
712709 layer.pipe(
713710 Layer.provide(
714711 Layer.mergeAll(
715 layerTunnelClient(
716 ManagedEndpointTunnelClient.of({
717 list: (request) =>
718 tunnelClient.list(request).pipe(
719 Effect.mapError(
720 (cause) =>
721 new ManagedEndpointTunnelClientError({
722 operation: "list",
723 tunnelName: request.name,
724 cause,
725 }),
726 ),
727 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
712 layerTunnelClient({
713 list: (request) =>
714 tunnelClient.list(request).pipe(
715 Effect.mapError(
716 (cause) =>
717 new ManagedEndpointTunnelClientError({
718 operation: "list",
719 tunnelName: request.name,
720 cause,
721 }),
728722 ),
729 create: (request) =>
730 tunnelClient.create(request).pipe(
731 Effect.mapError(
732 (cause) =>
733 new ManagedEndpointTunnelClientError({
734 operation: "create",
735 tunnelName: request.name,
736 cause,
737 }),
738 ),
739 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
723 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
724 ),
725 create: (request) =>
726 tunnelClient.create(request).pipe(
727 Effect.mapError(
728 (cause) =>
729 new ManagedEndpointTunnelClientError({
730 operation: "create",
731 tunnelName: request.name,
732 cause,
733 }),
740734 ),
741 putConfiguration: (tunnelId, config) =>
742 tunnelClient.putConfiguration(tunnelId, config).pipe(
743 Effect.mapError(
744 (cause) =>
745 new ManagedEndpointTunnelClientError({
746 operation: "put-configuration",
747 tunnelId,
748 cause,
749 }),
750 ),
751 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
735 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
736 ),
737 putConfiguration: (tunnelId, config) =>
738 tunnelClient.putConfiguration(tunnelId, config).pipe(
739 Effect.mapError(
740 (cause) =>
741 new ManagedEndpointTunnelClientError({
742 operation: "put-configuration",
743 tunnelId,
744 cause,
745 }),
752746 ),
753 getToken: (tunnelId) =>
754 tunnelClient.getToken(tunnelId).pipe(
755 Effect.mapError(
756 (cause) =>
757 new ManagedEndpointTunnelClientError({
758 operation: "get-token",
759 tunnelId,
760 cause,
761 }),
762 ),
763 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
747 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
748 ),
749 getToken: (tunnelId) =>
750 tunnelClient.getToken(tunnelId).pipe(
751 Effect.mapError(
752 (cause) =>
753 new ManagedEndpointTunnelClientError({
754 operation: "get-token",
755 tunnelId,
756 cause,
757 }),
764758 ),
765 delete: (tunnelId) =>
766 tunnelClient.delete(tunnelId).pipe(
767 Effect.mapError(
768 (cause) =>
769 new ManagedEndpointTunnelClientError({
770 operation: "delete",
771 tunnelId,
772 cause,
773 }),
774 ),
775 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
759 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
760 ),
761 delete: (tunnelId) =>
762 tunnelClient.delete(tunnelId).pipe(
763 Effect.mapError(
764 (cause) =>
765 new ManagedEndpointTunnelClientError({
766 operation: "delete",
767 tunnelId,
768 cause,
769 }),
776770 ),
777 }),
778 ),
779 layerDnsClient(
780 ManagedEndpointDnsClient.of({
781 listRecords: (hostname) =>
782 dnsClient.listDnsRecords({ search: hostname }).pipe(
783 Effect.map((response) =>
784 response.result.filter(
785 (record): record is typeof record & { readonly id: string } =>
786 typeof record.id === "string" &&
787 normalizeHostname(record.name) === normalizeHostname(hostname),
788 ),
789 ),
790 Effect.mapError(
791 (cause) =>
792 new ManagedEndpointDnsClientError({
793 operation: "list-records",
794 hostname,
795 cause,
796 }),
771 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
772 ),
773 }),
774 layerDnsClient({
775 listRecords: (hostname) =>
776 dnsClient.listDnsRecords({ search: hostname }).pipe(
777 Effect.map((response) =>
778 response.result.filter(
779 (record): record is typeof record & { readonly id: string } =>
780 typeof record.id === "string" &&
781 normalizeHostname(record.name) === normalizeHostname(hostname),
797782 ),
798 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
799783 ),
800 createRecord: (request) =>
801 dnsClient.createDnsRecord(request).pipe(
802 Effect.map((response) => ({ id: response.id })),
803 Effect.mapError(
804 (cause) =>
805 new ManagedEndpointDnsClientError({
806 operation: "create-record",
807 hostname: request.name,
808 cause,
809 }),
810 ),
811 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
784 Effect.mapError(
785 (cause) =>
786 new ManagedEndpointDnsClientError({
787 operation: "list-records",
788 hostname,
789 cause,
790 }),
812791 ),
813 updateRecord: (dnsRecordId, request) =>
814 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(
815 Effect.mapError(
816 (cause) =>
817 new ManagedEndpointDnsClientError({
818 operation: "update-record",
819 hostname: request.name,
820 dnsRecordId,
821 cause,
822 }),
823 ),
824 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
792 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
793 ),
794 createRecord: (request) =>
795 dnsClient.createDnsRecord(request).pipe(
796 Effect.map((response) => ({ id: response.id })),
797 Effect.mapError(
798 (cause) =>
799 new ManagedEndpointDnsClientError({
800 operation: "create-record",
801 hostname: request.name,
802 cause,
803 }),
825804 ),
826 deleteRecord: (dnsRecordId) =>
827 dnsClient.deleteDnsRecord(dnsRecordId).pipe(
828 Effect.mapError(
829 (cause) =>
830 new ManagedEndpointDnsClientError({
831 operation: "delete-record",
832 dnsRecordId,
833 cause,
834 }),
835 ),
836 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
805 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
806 ),
807 updateRecord: (dnsRecordId, request) =>
808 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(
809 Effect.mapError(
810 (cause) =>
811 new ManagedEndpointDnsClientError({
812 operation: "update-record",
813 hostname: request.name,
814 dnsRecordId,
815 cause,
816 }),
837817 ),
838 }),
839 ),
818 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
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 }),
840833 ),
841834 ),
842835 );