@timmo001/effect-gh 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -130,10 +130,11 @@ entries override inherited values; `undefined` removes a value. The SDK always
130
130
  disables prompts, colour, forced TTY and spinners, and selects `cat` as the pager.
131
131
  Stdin is closed unless supplied explicitly. No operation is automatically retried.
132
132
 
133
- Errors are tagged `GhCommandError` (nonzero exit with exit code and stderr),
134
- `GhPlatformError` (spawn or pipe failure), `GhTimeoutError`, or `GhDecodeError`
135
- (invalid JSON or a schema mismatch). Command errors retain only the last 65,536
136
- UTF-16 code units of stderr and flag truncation with `stderrTruncated`.
133
+ Errors are tagged `GhCommandError` (nonzero exit with exit code, stdout and
134
+ stderr), `GhPlatformError` (spawn or pipe failure), `GhTimeoutError`, or
135
+ `GhDecodeError` (invalid JSON or a schema mismatch). Command errors retain only
136
+ the last 65,536 UTF-16 code units of each output and flag truncation with
137
+ `stdoutTruncated` and `stderrTruncated`.
137
138
  The stream emits trailing output before reporting a nonzero exit.
138
139
 
139
140
  Interruption, timeout and early stream termination close the child scope. The
@@ -178,11 +179,36 @@ through the raw `Gh` interface.
178
179
  ### Retries
179
180
 
180
181
  No SDK operation retries automatically. Mutations can have taken effect even
181
- when the CLI times out or loses the connection. Consumers can apply Effect
182
- `Schedule` and `Effect.retry` to known-idempotent reads with a bounded policy and
183
- their own transient-error classification. Decode errors, authentication failures
184
- and check-status exits are not transient errors. Do not transparently replay a
185
- stream after it has emitted output.
182
+ when the CLI times out or loses the connection, so only retry known-idempotent
183
+ reads, and do not transparently replay a stream after it has emitted output.
184
+
185
+ `Gh.retryTransient(options?)` retries an effect failing with `GhError` while
186
+ `isTransient` holds: timeouts, rate limits, HTTP 408, 429 and 5xx gateway
187
+ statuses, and network failures. It defaults to three retries with jittered
188
+ exponential backoff from 250 milliseconds, capped at 10 seconds
189
+ (`defaultRetrySchedule`); pass `schedule` or `times` to change either. Decode
190
+ errors, authentication failures and check-status exits are not transient.
191
+
192
+ ```ts
193
+ import { Api, Gh } from "@timmo001/effect-gh";
194
+ import { Schema } from "effect";
195
+
196
+ const viewer = Api.json(
197
+ { endpoint: "user", method: "GET" },
198
+ Schema.Struct({ login: Schema.String }),
199
+ ).pipe(Gh.retryTransient({ times: 2 }));
200
+ ```
201
+
202
+ `isRateLimited(error)` and `httpStatus(error)` expose the same classification
203
+ for consumers mapping `GhError` into their own errors.
204
+
205
+ ### Rate limits
206
+
207
+ `RateLimit.get(resource?, options?)` reads the `core` (default), `graphql` or
208
+ `search` quota from `gh api rate_limit`, which does not count against it.
209
+ `RateLimit.cached(timeToLive, options?)` builds an Effect `Cache` of those reads
210
+ keyed by resource. Failed reads are not kept, and `Cache.invalidate` forces a
211
+ fresh read after a rate-limited failure.
186
212
 
187
213
  ## Repository and issues
188
214
 
package/dist/errors.d.ts CHANGED
@@ -2,6 +2,8 @@ import { Schema } from "effect";
2
2
  declare const GhCommandError_base: Schema.Class<GhCommandError, Schema.TaggedStruct<"GhCommandError", {
3
3
  readonly executable: Schema.String;
4
4
  readonly exitCode: Schema.Int;
5
+ readonly stdout: Schema.String;
6
+ readonly stdoutTruncated: Schema.Boolean;
5
7
  readonly stderr: Schema.String;
6
8
  readonly stderrTruncated: Schema.Boolean;
7
9
  }>, import("effect/Cause").YieldableError>;
package/dist/errors.js CHANGED
@@ -2,6 +2,8 @@ import { Schema } from "effect";
2
2
  export class GhCommandError extends Schema.TaggedError()("GhCommandError", {
3
3
  executable: Schema.String,
4
4
  exitCode: Schema.Int,
5
+ stdout: Schema.String,
6
+ stdoutTruncated: Schema.Boolean,
5
7
  stderr: Schema.String,
6
8
  stderrTruncated: Schema.Boolean,
7
9
  }) {
package/dist/gh.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Context, Duration, Effect, Layer, Schema, Stream } from "effect";
2
2
  import { ChildProcessSpawner } from "effect/process";
3
3
  import { type GhError } from "./errors.js";
4
+ import { retryTransient } from "./transient.js";
4
5
  export interface GhOptions {
5
6
  readonly executable?: string;
6
7
  readonly cwd?: string;
@@ -33,6 +34,8 @@ export interface Interface {
33
34
  }
34
35
  declare const Gh_base: Context.ServiceClass<Gh, "@timmo001/effect-gh/Gh", Interface>;
35
36
  export declare class Gh extends Gh_base {
37
+ /** Retries transient `gh` failures with bounded, jittered backoff. See {@link retryTransient}. */
38
+ static readonly retryTransient: typeof retryTransient;
36
39
  }
37
40
  export declare const layer: (defaults?: GhOptions) => Layer.Layer<Gh, never, ChildProcessSpawner.ChildProcessSpawner>;
38
41
  export {};
package/dist/gh.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Context, Duration, Effect, Layer, Match, Predicate, Schema, Stream, } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  import { GhCommandError, GhDecodeError, GhPlatformError, GhTimeoutError, } from "./errors.js";
4
+ import { retryTransient } from "./transient.js";
4
5
  export const GhOutput = Schema.Struct({
5
6
  stdout: Schema.String,
6
7
  stderr: Schema.String,
@@ -11,8 +12,10 @@ export const GhChunk = Schema.TaggedUnion({
11
12
  Stderr: { text: Schema.String },
12
13
  });
13
14
  export class Gh extends Context.Service()("@timmo001/effect-gh/Gh") {
15
+ /** Retries transient `gh` failures with bounded, jittered backoff. See {@link retryTransient}. */
16
+ static retryTransient = retryTransient;
14
17
  }
15
- const stderrLimit = 65_536;
18
+ const outputLimit = 65_536;
16
19
  export const layer = (defaults = {}) => Layer.effect(Gh, Effect.gen(function* () {
17
20
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
18
21
  const open = Effect.fn("Gh.stream")(function* (args, options) {
@@ -40,11 +43,17 @@ export const layer = (defaults = {}) => Layer.effect(Gh, Effect.gen(function* ()
40
43
  forceKillAfter: "1 second",
41
44
  }))
42
45
  .pipe(Effect.mapError((cause) => new GhPlatformError({ executable, cause })));
46
+ let stdout = "";
47
+ let stdoutTruncated = false;
43
48
  let stderr = "";
44
49
  let stderrTruncated = false;
45
- const output = Stream.merge(handle.stdout.pipe(Stream.decodeText(), Stream.map((text) => GhChunk.cases.Stdout.make({ text }))), handle.stderr.pipe(Stream.decodeText(), Stream.map((text) => {
46
- stderrTruncated ||= stderr.length + text.length > stderrLimit;
47
- stderr = (stderr + text).slice(-stderrLimit);
50
+ const output = Stream.merge(handle.stdout.pipe(Stream.decodeText(), Stream.map((text) => {
51
+ stdoutTruncated ||= stdout.length + text.length > outputLimit;
52
+ stdout = (stdout + text).slice(-outputLimit);
53
+ return GhChunk.cases.Stdout.make({ text });
54
+ })), handle.stderr.pipe(Stream.decodeText(), Stream.map((text) => {
55
+ stderrTruncated ||= stderr.length + text.length > outputLimit;
56
+ stderr = (stderr + text).slice(-outputLimit);
48
57
  return GhChunk.cases.Stderr.make({ text });
49
58
  }))).pipe(Stream.mapError((cause) => new GhPlatformError({ executable, cause })));
50
59
  const completion = Effect.gen(function* () {
@@ -53,6 +62,8 @@ export const layer = (defaults = {}) => Layer.effect(Gh, Effect.gen(function* ()
53
62
  return yield* new GhCommandError({
54
63
  executable,
55
64
  exitCode,
65
+ stdout,
66
+ stdoutTruncated,
56
67
  stderr,
57
68
  stderrTruncated,
58
69
  });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { Gh, GhChunk, GhOutput, layer, type GhOptions, type Interface, } from "./gh.js";
2
2
  export { GhCommandError, GhDecodeError, GhPlatformError, GhTimeoutError, type GhError, } from "./errors.js";
3
+ export { defaultRetrySchedule, httpStatus, isRateLimited, isTransient, type RetryTransientOptions, } from "./transient.js";
3
4
  export * as Api from "./api.js";
5
+ export * as RateLimit from "./rate-limit.js";
4
6
  export * as Repository from "./repository.js";
5
7
  export * as Issue from "./issue.js";
6
8
  export * as PullRequest from "./pull-request.js";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { Gh, GhChunk, GhOutput, layer, } from "./gh.js";
2
2
  export { GhCommandError, GhDecodeError, GhPlatformError, GhTimeoutError, } from "./errors.js";
3
+ export { defaultRetrySchedule, httpStatus, isRateLimited, isTransient, } from "./transient.js";
3
4
  export * as Api from "./api.js";
5
+ export * as RateLimit from "./rate-limit.js";
4
6
  export * as Repository from "./repository.js";
5
7
  export * as Issue from "./issue.js";
6
8
  export * as PullRequest from "./pull-request.js";
@@ -0,0 +1,21 @@
1
+ import { Cache, Duration, Effect, Schema } from "effect";
2
+ import type { GhError } from "./errors.js";
3
+ import type { Gh, GhOptions } from "./gh.js";
4
+ export declare const Resource: Schema.Struct<{
5
+ readonly limit: Schema.Int;
6
+ readonly used: Schema.Int;
7
+ readonly remaining: Schema.Int;
8
+ /** Reset time in epoch seconds. */
9
+ readonly reset: Schema.Int;
10
+ }>;
11
+ export interface Resource extends Schema.Schema.Type<typeof Resource> {
12
+ }
13
+ export type ResourceName = "core" | "graphql" | "search";
14
+ /** Reads one REST, GraphQL or search quota from `gh api rate_limit`, which does not count against the quota. */
15
+ export declare const get: (resource?: ResourceName | undefined, options?: Omit<GhOptions, "stdin"> | undefined) => Effect.Effect<Resource, GhError, Gh>;
16
+ /**
17
+ * Builds a cache of {@link get} reads keyed by resource. Successful reads live
18
+ * for `timeToLive`; failed reads are not kept. Use `Cache.get` to read and
19
+ * `Cache.invalidate` after a rate-limited failure.
20
+ */
21
+ export declare const cached: (timeToLive: Duration.Input, options?: Omit<GhOptions, "stdin">) => Effect.Effect<Cache.Cache<ResourceName, Resource, GhError>, never, Gh>;
@@ -0,0 +1,30 @@
1
+ import { Cache, Duration, Effect, Exit, Schema } from "effect";
2
+ import * as Api from "./api.js";
3
+ export const Resource = Schema.Struct({
4
+ limit: Schema.Int,
5
+ used: Schema.Int,
6
+ remaining: Schema.Int,
7
+ /** Reset time in epoch seconds. */
8
+ reset: Schema.Int,
9
+ });
10
+ const response = Schema.Struct({
11
+ resources: Schema.Struct({
12
+ core: Resource,
13
+ graphql: Resource,
14
+ search: Resource,
15
+ }),
16
+ });
17
+ /** Reads one REST, GraphQL or search quota from `gh api rate_limit`, which does not count against the quota. */
18
+ export const get = Effect.fn("RateLimit.get")(function* (resource = "core", options) {
19
+ const { resources } = yield* Api.json({ endpoint: "rate_limit", method: "GET", ...(options && { options }) }, response);
20
+ return resources[resource];
21
+ });
22
+ /**
23
+ * Builds a cache of {@link get} reads keyed by resource. Successful reads live
24
+ * for `timeToLive`; failed reads are not kept. Use `Cache.get` to read and
25
+ * `Cache.invalidate` after a rate-limited failure.
26
+ */
27
+ export const cached = (timeToLive, options) => Cache.makeWith((resource) => get(resource, options), {
28
+ capacity: 3,
29
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? timeToLive : Duration.zero),
30
+ });
@@ -0,0 +1,25 @@
1
+ import { Effect, Option, Schedule } from "effect";
2
+ import { type GhError } from "./errors.js";
3
+ /** HTTP status reported by a failed `gh` command, such as `gh: Not Found (HTTP 404)`. */
4
+ export declare const httpStatus: (error: GhError) => Option.Option<number>;
5
+ /** Whether a `gh` command failed because of a primary or secondary GitHub rate limit. */
6
+ export declare const isRateLimited: (error: GhError) => boolean;
7
+ /**
8
+ * Whether a failure is worth retrying: timeouts, rate limits, HTTP 408, 429 and
9
+ * 5xx gateway statuses, and network failures reported by `gh`.
10
+ */
11
+ export declare const isTransient: (error: GhError) => boolean;
12
+ /** Jittered exponential backoff from 250 milliseconds, capped at 10 seconds. */
13
+ export declare const defaultRetrySchedule: Schedule.Schedule<import("effect/Duration").Duration, unknown, never, never>;
14
+ /** Options for {@link retryTransient}. */
15
+ export interface RetryTransientOptions<B = unknown, ES = never, R = never> {
16
+ /** Delay policy between attempts. Defaults to {@link defaultRetrySchedule}. */
17
+ readonly schedule?: Schedule.Schedule<B, GhError, ES, R>;
18
+ /** Retries after the first attempt. Defaults to 3. */
19
+ readonly times?: number;
20
+ }
21
+ /**
22
+ * Retries failures matched by {@link isTransient}. Apply only to idempotent
23
+ * reads: a mutation can take effect even when the command reports a failure.
24
+ */
25
+ export declare const retryTransient: <B = unknown, ES = never, R1 = never>(options?: RetryTransientOptions<B, ES, R1>) => <A, E extends GhError, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E | ES, R | R1>;
@@ -0,0 +1,44 @@
1
+ import { Effect, Match, Option, Schedule } from "effect";
2
+ import { GhCommandError } from "./errors.js";
3
+ const statusPattern = /\b(?:HTTP|status(?: code)?)\s*(\d{3})\b/i;
4
+ const transientStatuses = new Set([
5
+ 408, 429, 500, 502, 503, 504,
6
+ ]);
7
+ const networkPattern = /connection reset|could not resolve host|error connecting to|no such host|temporary failure in name resolution|network is unreachable|no route to host|temporarily unavailable|tls handshake|i\/o timeout|timed out|context deadline exceeded/i;
8
+ /** HTTP status reported by a failed `gh` command, such as `gh: Not Found (HTTP 404)`. */
9
+ export const httpStatus = (error) => {
10
+ if (!(error instanceof GhCommandError))
11
+ return Option.none();
12
+ const match = statusPattern.exec(error.stderr);
13
+ return match?.[1] === undefined
14
+ ? Option.none()
15
+ : Option.some(Number(match[1]));
16
+ };
17
+ /** Whether a `gh` command failed because of a primary or secondary GitHub rate limit. */
18
+ export const isRateLimited = (error) => error instanceof GhCommandError &&
19
+ (Option.contains(httpStatus(error), 429) ||
20
+ /rate limit/i.test(`${error.stderr}\n${error.stdout}`));
21
+ /**
22
+ * Whether a failure is worth retrying: timeouts, rate limits, HTTP 408, 429 and
23
+ * 5xx gateway statuses, and network failures reported by `gh`.
24
+ */
25
+ export const isTransient = (error) => Match.value(error).pipe(Match.tags({
26
+ GhTimeoutError: () => true,
27
+ GhCommandError: (error) => isRateLimited(error) ||
28
+ Option.exists(httpStatus(error), (status) => transientStatuses.has(status)) ||
29
+ networkPattern.test(error.stderr),
30
+ }), Match.orElse(() => false));
31
+ /** Jittered exponential backoff from 250 milliseconds, capped at 10 seconds. */
32
+ export const defaultRetrySchedule = Schedule.min([
33
+ Schedule.exponential("250 millis"),
34
+ Schedule.spaced("10 seconds"),
35
+ ]).pipe(Schedule.jittered);
36
+ /**
37
+ * Retries failures matched by {@link isTransient}. Apply only to idempotent
38
+ * reads: a mutation can take effect even when the command reports a failure.
39
+ */
40
+ export const retryTransient = (options = {}) => (self) => Effect.retry(self, {
41
+ while: isTransient,
42
+ schedule: options.schedule ?? defaultRetrySchedule,
43
+ times: options.times ?? 3,
44
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/effect-gh",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "An Effect v4 SDK for the GitHub CLI",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "test": "bun test",
39
39
  "format": "prettier --write .",
40
40
  "format:check": "prettier --check .",
41
- "prepare": "effect-tsgo patch",
41
+ "prepare": "effect-tsgo patch --force=false",
42
42
  "prepack": "bun run build"
43
43
  },
44
44
  "peerDependencies": {
@@ -46,9 +46,9 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@effect/platform-node": "4.0.0-rc.118",
49
- "@effect/tsgo": "0.46.1",
49
+ "@effect/tsgo": "0.47.0",
50
50
  "@oxlint/plugins": "1.86.0",
51
- "@timmo001/oxlint-rules": "0.4.0",
51
+ "@timmo001/oxlint-rules": "0.5.0",
52
52
  "@types/bun": "1.4.2",
53
53
  "effect": "4.0.0-rc.118",
54
54
  "oxlint": "1.86.0",
package/src/errors.ts CHANGED
@@ -5,6 +5,8 @@ export class GhCommandError extends Schema.TaggedError<GhCommandError>()(
5
5
  {
6
6
  executable: Schema.String,
7
7
  exitCode: Schema.Int,
8
+ stdout: Schema.String,
9
+ stdoutTruncated: Schema.Boolean,
8
10
  stderr: Schema.String,
9
11
  stderrTruncated: Schema.Boolean,
10
12
  },
package/src/gh.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  GhTimeoutError,
17
17
  type GhError,
18
18
  } from "./errors.js";
19
+ import { retryTransient } from "./transient.js";
19
20
 
20
21
  export interface GhOptions {
21
22
  readonly executable?: string;
@@ -60,9 +61,12 @@ export interface Interface {
60
61
 
61
62
  export class Gh extends Context.Service<Gh, Interface>()(
62
63
  "@timmo001/effect-gh/Gh",
63
- ) {}
64
+ ) {
65
+ /** Retries transient `gh` failures with bounded, jittered backoff. See {@link retryTransient}. */
66
+ static readonly retryTransient = retryTransient;
67
+ }
64
68
 
65
- const stderrLimit = 65_536;
69
+ const outputLimit = 65_536;
66
70
 
67
71
  export const layer = (
68
72
  defaults: GhOptions = {},
@@ -108,19 +112,26 @@ export const layer = (
108
112
  ),
109
113
  );
110
114
 
115
+ let stdout = "";
116
+ let stdoutTruncated = false;
111
117
  let stderr = "";
112
118
  let stderrTruncated = false;
113
119
 
114
120
  const output = Stream.merge(
115
121
  handle.stdout.pipe(
116
122
  Stream.decodeText(),
117
- Stream.map((text) => GhChunk.cases.Stdout.make({ text })),
123
+ Stream.map((text) => {
124
+ stdoutTruncated ||= stdout.length + text.length > outputLimit;
125
+ stdout = (stdout + text).slice(-outputLimit);
126
+
127
+ return GhChunk.cases.Stdout.make({ text });
128
+ }),
118
129
  ),
119
130
  handle.stderr.pipe(
120
131
  Stream.decodeText(),
121
132
  Stream.map((text) => {
122
- stderrTruncated ||= stderr.length + text.length > stderrLimit;
123
- stderr = (stderr + text).slice(-stderrLimit);
133
+ stderrTruncated ||= stderr.length + text.length > outputLimit;
134
+ stderr = (stderr + text).slice(-outputLimit);
124
135
 
125
136
  return GhChunk.cases.Stderr.make({ text });
126
137
  }),
@@ -142,6 +153,8 @@ export const layer = (
142
153
  return yield* new GhCommandError({
143
154
  executable,
144
155
  exitCode,
156
+ stdout,
157
+ stdoutTruncated,
145
158
  stderr,
146
159
  stderrTruncated,
147
160
  });
package/src/index.ts CHANGED
@@ -15,8 +15,18 @@ export {
15
15
  type GhError,
16
16
  } from "./errors.js";
17
17
 
18
+ export {
19
+ defaultRetrySchedule,
20
+ httpStatus,
21
+ isRateLimited,
22
+ isTransient,
23
+ type RetryTransientOptions,
24
+ } from "./transient.js";
25
+
18
26
  export * as Api from "./api.js";
19
27
 
28
+ export * as RateLimit from "./rate-limit.js";
29
+
20
30
  export * as Repository from "./repository.js";
21
31
 
22
32
  export * as Issue from "./issue.js";
@@ -0,0 +1,51 @@
1
+ import { Cache, Duration, Effect, Exit, Schema } from "effect";
2
+ import * as Api from "./api.js";
3
+ import type { GhError } from "./errors.js";
4
+ import type { Gh, GhOptions } from "./gh.js";
5
+
6
+ export const Resource = Schema.Struct({
7
+ limit: Schema.Int,
8
+ used: Schema.Int,
9
+ remaining: Schema.Int,
10
+ /** Reset time in epoch seconds. */
11
+ reset: Schema.Int,
12
+ });
13
+
14
+ export interface Resource extends Schema.Schema.Type<typeof Resource> {}
15
+
16
+ export type ResourceName = "core" | "graphql" | "search";
17
+
18
+ const response = Schema.Struct({
19
+ resources: Schema.Struct({
20
+ core: Resource,
21
+ graphql: Resource,
22
+ search: Resource,
23
+ }),
24
+ });
25
+
26
+ /** Reads one REST, GraphQL or search quota from `gh api rate_limit`, which does not count against the quota. */
27
+ export const get = Effect.fn("RateLimit.get")(function* (
28
+ resource: ResourceName = "core",
29
+ options?: Omit<GhOptions, "stdin">,
30
+ ): Effect.fn.Return<Resource, GhError, Gh> {
31
+ const { resources } = yield* Api.json(
32
+ { endpoint: "rate_limit", method: "GET", ...(options && { options }) },
33
+ response,
34
+ );
35
+
36
+ return resources[resource];
37
+ });
38
+
39
+ /**
40
+ * Builds a cache of {@link get} reads keyed by resource. Successful reads live
41
+ * for `timeToLive`; failed reads are not kept. Use `Cache.get` to read and
42
+ * `Cache.invalidate` after a rate-limited failure.
43
+ */
44
+ export const cached = (
45
+ timeToLive: Duration.Input,
46
+ options?: Omit<GhOptions, "stdin">,
47
+ ): Effect.Effect<Cache.Cache<ResourceName, Resource, GhError>, never, Gh> =>
48
+ Cache.makeWith((resource: ResourceName) => get(resource, options), {
49
+ capacity: 3,
50
+ timeToLive: (exit) => (Exit.isSuccess(exit) ? timeToLive : Duration.zero),
51
+ });
@@ -0,0 +1,76 @@
1
+ import { Effect, Match, Option, Schedule } from "effect";
2
+ import { GhCommandError, type GhError } from "./errors.js";
3
+
4
+ const statusPattern = /\b(?:HTTP|status(?: code)?)\s*(\d{3})\b/i;
5
+
6
+ const transientStatuses: ReadonlySet<number> = new Set([
7
+ 408, 429, 500, 502, 503, 504,
8
+ ]);
9
+
10
+ const networkPattern =
11
+ /connection reset|could not resolve host|error connecting to|no such host|temporary failure in name resolution|network is unreachable|no route to host|temporarily unavailable|tls handshake|i\/o timeout|timed out|context deadline exceeded/i;
12
+
13
+ /** HTTP status reported by a failed `gh` command, such as `gh: Not Found (HTTP 404)`. */
14
+ export const httpStatus = (error: GhError): Option.Option<number> => {
15
+ if (!(error instanceof GhCommandError)) return Option.none();
16
+ const match = statusPattern.exec(error.stderr);
17
+
18
+ return match?.[1] === undefined
19
+ ? Option.none()
20
+ : Option.some(Number(match[1]));
21
+ };
22
+
23
+ /** Whether a `gh` command failed because of a primary or secondary GitHub rate limit. */
24
+ export const isRateLimited = (error: GhError): boolean =>
25
+ error instanceof GhCommandError &&
26
+ (Option.contains(httpStatus(error), 429) ||
27
+ /rate limit/i.test(`${error.stderr}\n${error.stdout}`));
28
+
29
+ /**
30
+ * Whether a failure is worth retrying: timeouts, rate limits, HTTP 408, 429 and
31
+ * 5xx gateway statuses, and network failures reported by `gh`.
32
+ */
33
+ export const isTransient = (error: GhError): boolean =>
34
+ Match.value(error).pipe(
35
+ Match.tags({
36
+ GhTimeoutError: () => true,
37
+ GhCommandError: (error) =>
38
+ isRateLimited(error) ||
39
+ Option.exists(httpStatus(error), (status) =>
40
+ transientStatuses.has(status),
41
+ ) ||
42
+ networkPattern.test(error.stderr),
43
+ }),
44
+ Match.orElse(() => false),
45
+ );
46
+
47
+ /** Jittered exponential backoff from 250 milliseconds, capped at 10 seconds. */
48
+ export const defaultRetrySchedule = Schedule.min([
49
+ Schedule.exponential("250 millis"),
50
+ Schedule.spaced("10 seconds"),
51
+ ]).pipe(Schedule.jittered);
52
+
53
+ /** Options for {@link retryTransient}. */
54
+ export interface RetryTransientOptions<B = unknown, ES = never, R = never> {
55
+ /** Delay policy between attempts. Defaults to {@link defaultRetrySchedule}. */
56
+ readonly schedule?: Schedule.Schedule<B, GhError, ES, R>;
57
+ /** Retries after the first attempt. Defaults to 3. */
58
+ readonly times?: number;
59
+ }
60
+
61
+ /**
62
+ * Retries failures matched by {@link isTransient}. Apply only to idempotent
63
+ * reads: a mutation can take effect even when the command reports a failure.
64
+ */
65
+ export const retryTransient =
66
+ <B = unknown, ES = never, R1 = never>(
67
+ options: RetryTransientOptions<B, ES, R1> = {},
68
+ ) =>
69
+ <A, E extends GhError, R>(
70
+ self: Effect.Effect<A, E, R>,
71
+ ): Effect.Effect<A, E | ES, R | R1> =>
72
+ Effect.retry(self, {
73
+ while: isTransient,
74
+ schedule: options.schedule ?? defaultRetrySchedule,
75
+ times: options.times ?? 3,
76
+ });