SLOPSHOPPER

oops-retry

When a turn dies on a dropped connection or a server error, asks Claude to try again, up to 5 times in a row.

newtoastprompttimer
v0.1.0MITupdated 2026-10-23lperezmo/oops-retry
A shopper browsing a rack in a slop shop
README

oops-retry

A Claude Code mod for those API Error: Connection lost mid-response moments. When a turn dies on an API error, it waits a bit and sends "Oops, something seems to have gone wrong, can you try that again?" for you.

  • Waits 2, 5, 10, 20, then 30 seconds before each retry, with a toast showing the count.
  • Gives up after 5 failures in a row and hands it back to you.
  • Starts the count over after a clean answer, when you type something, or when you press Esc.
  • Skips errors that will not fix themselves: billing, sign-in, bad requests and usage limits.

Runs inside Claude Code as a native mod: no tmux, no watcher process.

Install

At the Claude Code prompt:

/plugin install oops-retry --marketplace lperezmo/oops-retry

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install oops-retry --marketplace lperezmo/oops-retry

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with lperezmo/oops-retry, then Add plugin.

Tests

claude plugin test .

License

MIT

Source 1 files
hooks/register.ts 98 lines
1import type { Register, Timer } from 'claude-code'
2
3const RETRY_TEXT = 'Oops, something seems to have gone wrong, can you try that again?'
4const MAX_RETRIES = 5
5// Seconds to wait before retry 1, 2, 3, 4 and 5.
6const DELAYS = [2, 5, 10, 20, 30]
7
8// Error kinds that will not clear by asking again.
9const HOPELESS = new Set([
10  'authentication_failed',
11  'oauth_org_not_allowed',
12  'account_on_hold',
13  'verification_required',
14  'billing_error',
15  'rate_limit',
16  'invalid_request',
17  'model_not_found',
18  'max_output_tokens',
19  'cloud_credential_error',
20])
21
22export const register: Register = on => {
23  let retries = 0
24  let lastErrorKind: string | undefined
25  let pending: Timer | undefined
26
27  const cancelPending = () => {
28    pending?.cancel()
29    pending = undefined
30  }
31
32  // StopFailure names the kind of API error; turn.complete only says "error".
33  on('classic.StopFailure', ($, e, next) => {
34    lastErrorKind = e.error
35
36    return next(e)
37  }).catch(($, e, next) => next(e))
38
39  // Anything the person sends themselves starts the count over.
40  on('prompt.submit', ($, e, next) => {
41    if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
42      cancelPending()
43      retries = 0
44    }
45
46    return next(e)
47  }).catch(($, e, next) => next(e))
48
49  on('turn.complete', ($, e, next) => {
50    if (e.agentId !== undefined) {
51      return next(e)
52    }
53
54    if (e.reason !== 'error') {
55      // A clean answer, or the person interrupted: stop retrying.
56      cancelPending()
57      retries = 0
58      lastErrorKind = undefined
59
60      return next(e)
61    }
62
63    if (pending !== undefined) {
64      return next(e)
65    }
66
67    if (retries >= MAX_RETRIES) {
68      $.ui.toast(`Still failing after ${MAX_RETRIES} retries, over to you.`)
69      retries = 0
70
71      return next(e)
72    }
73
74    retries += 1
75    const attempt = retries
76    const seconds = DELAYS[attempt - 1] ?? 30
77    $.ui.toast(`Connection hiccup, retrying in ${seconds}s (${attempt}/${MAX_RETRIES})`)
78
79    // The kind can land after turn.complete, so check it when the timer fires.
80    pending = $.clock.after(seconds * 1000, () => {
81      pending = undefined
82      const kind = lastErrorKind
83      lastErrorKind = undefined
84
85      if (kind !== undefined && HOPELESS.has(kind)) {
86        $.ui.toast(`Not retrying: the API said ${kind}.`)
87        retries = 0
88
89        return
90      }
91
92      void $.prompt.submit({ text: RETRY_TEXT })
93    })
94
95    return next(e)
96  })
97}
98