diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index f85175cb7909..4d2d5b3b677c 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -176,6 +176,10 @@ export const Info = Schema.Struct({ mcp_timeout: Schema.optional(PositiveInt).annotate({ description: "Timeout in milliseconds for model context protocol (MCP) requests", }), + max_retries: Schema.optional(PositiveInt).annotate({ + description: + "Maximum number of retry attempts for transient provider errors before stopping the session. When not set, retries are unbounded.", + }), policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({ description: "Policy statements applied to supported resources, such as provider access", }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 907134df013a..a5e62908565f 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -990,6 +990,7 @@ export const layer = Layer.effect( SessionRetry.policy({ provider: input.model.providerID, parse, + maxRetries: (yield* config.get()).experimental?.max_retries, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. const event = mirrorAssistant diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..46bfd3fab74f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -177,12 +177,14 @@ export function policy(opts: { provider: string parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect + maxRetries?: number }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) + if (opts.maxRetries !== undefined && meta.attempt > opts.maxRetries) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) const now = yield* Clock.currentTimeMillis diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index f5edf1af24b1..9bbe2012d7af 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -114,6 +114,38 @@ describe("session.retry.delay", () => { }) }), ) + + it.instance("policy stops after max retry attempts", () => + Effect.gen(function* () { + const sessionID = SessionID.make("session-retry-max-test") + const error = apiError({ "retry-after-ms": "0" }) + const status = yield* SessionStatus.Service + + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: "test", + parse: Schema.decodeUnknownSync(SessionLegacy.APIError.Schema), + maxRetries: 1, + set: (info) => + status.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + }), + ) + yield* step(error) + const result = yield* Effect.exit(step(error)) + + expect(result._tag).toBe("Failure") + expect(yield* status.get(sessionID)).toMatchObject({ + type: "retry", + attempt: 1, + message: "boom", + }) + }), + ) }) describe("session.retry.retryable", () => {