@timmo001/effect-gh 0.1.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 +35 -9
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/gh.d.ts +4 -1
- package/dist/gh.js +16 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/rate-limit.d.ts +21 -0
- package/dist/rate-limit.js +30 -0
- package/dist/transient.d.ts +25 -0
- package/dist/transient.js +44 -0
- package/package.json +9 -9
- package/src/errors.ts +2 -0
- package/src/gh.ts +19 -6
- package/src/index.ts +10 -0
- package/src/rate-limit.ts +51 -0
- package/src/transient.ts +76 -0
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
|
|
134
|
-
`GhPlatformError` (spawn or pipe failure), `GhTimeoutError`, or
|
|
135
|
-
(invalid JSON or a schema mismatch). Command errors retain only
|
|
136
|
-
UTF-16 code units of
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
import { ChildProcessSpawner } from "effect/
|
|
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
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
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
|
|
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) =>
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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,20 +38,20 @@
|
|
|
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": {
|
|
45
|
-
"effect": "4.0.0-rc.
|
|
45
|
+
"effect": "4.0.0-rc.118"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@effect/platform-node": "4.0.0-rc.
|
|
49
|
-
"@effect/tsgo": "0.
|
|
50
|
-
"@oxlint/plugins": "1.
|
|
51
|
-
"@timmo001/oxlint-rules": "0.
|
|
48
|
+
"@effect/platform-node": "4.0.0-rc.118",
|
|
49
|
+
"@effect/tsgo": "0.47.0",
|
|
50
|
+
"@oxlint/plugins": "1.86.0",
|
|
51
|
+
"@timmo001/oxlint-rules": "0.5.0",
|
|
52
52
|
"@types/bun": "1.4.2",
|
|
53
|
-
"effect": "4.0.0-rc.
|
|
54
|
-
"oxlint": "1.
|
|
53
|
+
"effect": "4.0.0-rc.118",
|
|
54
|
+
"oxlint": "1.86.0",
|
|
55
55
|
"oxlint-tsgolint": "7.0.2003",
|
|
56
56
|
"prettier": "3.9.9",
|
|
57
57
|
"typescript": "7.0.2"
|
package/src/errors.ts
CHANGED
package/src/gh.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Schema,
|
|
9
9
|
Stream,
|
|
10
10
|
} from "effect";
|
|
11
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
11
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
12
12
|
import {
|
|
13
13
|
GhCommandError,
|
|
14
14
|
GhDecodeError,
|
|
@@ -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
|
|
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) =>
|
|
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 >
|
|
123
|
-
stderr = (stderr + text).slice(-
|
|
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
|
+
});
|
package/src/transient.ts
ADDED
|
@@ -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
|
+
});
|