@distilled.cloud/fly-io 1.0.0-rc.11 → 1.0.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/protocol.ts CHANGED
@@ -19,16 +19,18 @@
19
19
  * `Authorization: Bearer` on sprite ops. There is no `SPRITES_TOKEN`.
20
20
  */
21
21
  import * as Context from "effect/Context";
22
+ import * as Deferred from "effect/Deferred";
22
23
  import * as Effect from "effect/Effect";
24
+ import * as Exit from "effect/Exit";
23
25
  import * as Layer from "effect/Layer";
24
26
  import * as Option from "effect/Option";
25
27
  import * as Redacted from "effect/Redacted";
26
28
  import * as Schema from "effect/Schema";
27
29
  import type * as AST from "effect/SchemaAST";
28
- import * as HttpClient from "effect/unstable/http/HttpClient";
29
- import type * as HttpClientError from "effect/unstable/http/HttpClientError";
30
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
31
- import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
30
+ import * as HttpClient from "effect/http/HttpClient";
31
+ import type * as HttpClientError from "effect/http/HttpClientError";
32
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
33
+ import type * as HttpClientResponse from "effect/http/HttpClientResponse";
32
34
  import * as API from "@distilled.cloud/core/api";
33
35
  import {
34
36
  makeRestProtocol,
@@ -46,6 +48,10 @@ import {
46
48
  InternalServerError,
47
49
  type ConfigError,
48
50
  } from "@distilled.cloud/core/errors";
51
+ import {
52
+ failIfStrict,
53
+ validateResponse,
54
+ } from "@distilled.cloud/core/response-validation";
49
55
  import { parseRetryAfterForStatus } from "@distilled.cloud/core/retry-after";
50
56
  import {
51
57
  Credentials,
@@ -147,6 +153,7 @@ const FlyIoProtocolRest: Layer.Layer<API.Protocol> = makeRestProtocol<Config>({
147
153
  }),
148
154
  errorEnvelope: flyErrorEnvelope,
149
155
  unknownError: ({ message, body }) => new UnknownFlyIoError({ message, body }),
156
+ parseError: ({ body, cause }) => new FlyIoParseError({ body, cause }),
150
157
  });
151
158
 
152
159
  export const FlyIoProtocol: Layer.Layer<API.Protocol> = Layer.effect(
@@ -180,6 +187,7 @@ export const FlyApiProtocol: Layer.Layer<API.Protocol> =
180
187
  errorEnvelope: flyErrorEnvelope,
181
188
  unknownError: ({ message, body }) =>
182
189
  new UnknownFlyIoError({ message, body }),
190
+ parseError: ({ body, cause }) => new FlyIoParseError({ body, cause }),
183
191
  });
184
192
 
185
193
  const parseMaybeNdjson = (body: unknown): unknown => {
@@ -432,34 +440,42 @@ const mintOnce = (fly: Config) =>
432
440
  }),
433
441
  );
434
442
  }
435
- // Cache the raw string — Effect.cached must not hold a Redacted (the
436
- // registry entry is not restored from a memoized Exit).
443
+ // Cache the raw string, not a Redacted whose registry entry may be erased.
437
444
  return token;
438
445
  });
439
446
 
440
447
  const mintedTokens = new Map<string, string>();
441
- const mintInFlight = new Map<string, ReturnType<typeof mintOnce>>();
448
+ const mintInFlight = new Map<
449
+ string,
450
+ Deferred.Deferred<string, Effect.Error<ReturnType<typeof mintOnce>>>
451
+ >();
442
452
 
443
453
  /** Mint (and process-wide cache) a Sprites bearer from `FLY_API_TOKEN`. */
444
454
  const mintSpritesToken = (fly: Config) =>
445
- Effect.gen(function* () {
446
- const key = Redacted.value(fly.apiKey);
447
- const hit = mintedTokens.get(key);
448
- if (hit !== undefined) return hit;
449
- const pending = mintInFlight.get(key);
450
- if (pending !== undefined) return yield* pending;
451
- const run = mintOnce(fly).pipe(
452
- Effect.tap((token) =>
453
- Effect.sync(() => {
454
- mintedTokens.set(key, token);
455
- mintInFlight.delete(key);
456
- }),
457
- ),
458
- Effect.tapError(() => Effect.sync(() => mintInFlight.delete(key))),
459
- );
460
- mintInFlight.set(key, run);
461
- return yield* run;
462
- });
455
+ Effect.uninterruptibleMask((restore) =>
456
+ Effect.suspend(() => {
457
+ const key = Redacted.value(fly.apiKey);
458
+ const hit = mintedTokens.get(key);
459
+ if (hit !== undefined) return Effect.succeed(hit);
460
+ const pending = mintInFlight.get(key);
461
+ if (pending !== undefined) return restore(Deferred.await(pending));
462
+ const result = Deferred.makeUnsafe<
463
+ string,
464
+ Effect.Error<ReturnType<typeof mintOnce>>
465
+ >();
466
+ mintInFlight.set(key, result);
467
+ return restore(mintOnce(fly)).pipe(
468
+ Effect.onExit((exit) =>
469
+ Effect.gen(function* () {
470
+ if (Exit.isSuccess(exit)) mintedTokens.set(key, exit.value);
471
+ // Release all waiters, including on defects and owner interruption.
472
+ mintInFlight.delete(key);
473
+ yield* Deferred.done(result, exit);
474
+ }),
475
+ ),
476
+ );
477
+ }),
478
+ );
463
479
 
464
480
  const encodeSpritesRequest = ({
465
481
  input,
@@ -527,12 +543,17 @@ const decodeSpritesResponse = ({
527
543
  ) {
528
544
  return yield* fail(new SpritesNotEnabled({ message }));
529
545
  }
530
- const typed = matchTypedError(errorClasses, status, [
531
- {
532
- code: typeof env.code === "number" ? env.code : undefined,
533
- message,
534
- },
535
- ]);
546
+ const typed = matchTypedError(
547
+ errorClasses,
548
+ status,
549
+ [
550
+ {
551
+ code: typeof env.code === "number" ? env.code : undefined,
552
+ message,
553
+ },
554
+ ],
555
+ { body: nonJson ? text : json, headers },
556
+ );
536
557
  if (typed !== undefined) return yield* fail(typed);
537
558
 
538
559
  const StatusErrorClass = (HTTP_STATUS_MAP as Record<number, unknown>)[
@@ -567,13 +588,20 @@ const decodeSpritesResponse = ({
567
588
  );
568
589
  }
569
590
 
570
- if (execFrames !== undefined) {
571
- return wrapSensitive(outputAst, mapKeys(outputAst, execFrames, "decode"));
572
- }
573
-
574
- let body: unknown = nonJson ? text : (json ?? {});
575
- body = parseMaybeNdjson(body);
576
- return wrapSensitive(outputAst, mapKeys(outputAst, body, "decode"));
591
+ // Strict mode checks the mapped body (or parsed exec frames) against the
592
+ // output schema.
593
+ const body: unknown =
594
+ execFrames ?? parseMaybeNdjson(nonJson ? text : (json ?? {}));
595
+ const mapped = yield* validateResponse(
596
+ outputAst,
597
+ mapKeys(outputAst, body, "decode"),
598
+ (cause) =>
599
+ new FlyIoParseError({
600
+ body: execFrames ?? (nonJson ? text : json),
601
+ cause,
602
+ }),
603
+ ).pipe(Effect.catch(fail));
604
+ return wrapSensitive(outputAst, mapped);
577
605
  });
578
606
 
579
607
  /** Sprites REST at `https://api.sprites.dev/v1`, minted from `FLY_API_TOKEN`. */
@@ -619,7 +647,10 @@ const matchGraphqlError = (
619
647
  if (envelope._tag === "Some" && envelope.value.errors?.length) {
620
648
  const first = envelope.value.errors[0]!;
621
649
  const message = first.message;
622
- const typed = matchTypedError(errorClasses, status, [{ message }]);
650
+ const typed = matchTypedError(errorClasses, status, [{ message }], {
651
+ body: errorBody,
652
+ headers,
653
+ });
623
654
  if (typed !== undefined) return fail(typed);
624
655
  if (
625
656
  /not authorized to access this createextensiontosagreement/i.test(message)
@@ -730,12 +761,14 @@ const graphqlDecode = ({
730
761
  if (status >= 400) {
731
762
  return yield* matchGraphqlError(status, text, headers, errors);
732
763
  }
733
- return yield* fail(
764
+ // Lenient mode returns the body as read.
765
+ return yield* failIfStrict(
734
766
  new FlyIoParseError({
735
767
  body: text,
736
768
  cause: "response body is not valid JSON",
737
769
  }),
738
- );
770
+ text,
771
+ ).pipe(Effect.catch(fail));
739
772
  }
740
773
 
741
774
  const envelope = json as { data?: unknown; errors?: unknown[] } | null;
@@ -761,7 +794,13 @@ const graphqlDecode = ({
761
794
  : undefined;
762
795
  }
763
796
  }
764
- return payload === undefined ? null : payload;
797
+ // Strict mode checks the payload at the response path against the
798
+ // output schema.
799
+ return yield* validateResponse(
800
+ outputAst,
801
+ payload === undefined ? null : payload,
802
+ (cause) => new FlyIoParseError({ body: json, cause }),
803
+ ).pipe(Effect.catch(fail));
765
804
  });
766
805
 
767
806
  export const FlyGraphqlProtocol: Layer.Layer<API.Protocol> = Layer.succeed(
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { runValidationModes } from "@distilled.cloud/core/testing";
3
+ import * as Effect from "effect/Effect";
4
+ import { credentials } from "./credentials.ts";
5
+ import { FlyIoParseError } from "./errors.ts";
6
+ import * as Retry from "./retry.ts";
7
+ import { listApps } from "./services/machines.ts";
8
+ import type { FlyIoOpError } from "./protocol.ts";
9
+
10
+ // listApps (Machines REST) declares `{ apps?: App[]; total_apps?: number }`.
11
+ const run = (body: string) =>
12
+ runValidationModes(
13
+ listApps({ org_slug: "personal" }).pipe(
14
+ Retry.none,
15
+ Effect.provide(credentials({ apiKey: "test" })),
16
+ ),
17
+ { body },
18
+ );
19
+
20
+ describe("Fly.io Machines response validation", () => {
21
+ test("a matching body succeeds unchanged in both modes", async () => {
22
+ const body = { apps: [{ id: "app-1", name: "my-app" }], total_apps: 1 };
23
+ const { lenient, strict } = await run(JSON.stringify(body));
24
+ expect(lenient).toMatchObject({ _tag: "Success", success: body });
25
+ expect(strict).toMatchObject({ _tag: "Success", success: body });
26
+ });
27
+
28
+ test("a member with the wrong type: lenient returns it, strict fails", async () => {
29
+ const body = { apps: [], total_apps: "one" };
30
+ const { lenient, strict } = await run(JSON.stringify(body));
31
+ expect(lenient).toMatchObject({ _tag: "Success", success: body });
32
+ expect(strict._tag).toBe("Failure");
33
+ expect((strict as any).failure).toBeInstanceOf(FlyIoParseError);
34
+ });
35
+
36
+ test("a non-JSON body: lenient returns the text, strict fails", async () => {
37
+ const { lenient, strict } = await run("not json");
38
+ expect(lenient).toMatchObject({ _tag: "Success", success: "not json" });
39
+ expect((strict as any).failure).toBeInstanceOf(FlyIoParseError);
40
+ });
41
+ });
42
+
43
+ // FlyIoParseError is part of every operation's declared error type.
44
+ export const parseErrorIsDeclared: [FlyIoParseError] extends [FlyIoOpError]
45
+ ? true
46
+ : false = true;