npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pi-retry-forced

v1.2.0

Published

Keep Pi running through LLM-relay 400s: retry errors its substring classifier misses, and strip replayed thinking signatures a pooled relay rejects.

Readme

pi-retry-forced

Force retry for proxy 400 responses that Pi's error classifier misses.

A Pi extension. Zero runtime dependencies.

The problem

Pi decides whether to retry a failed assistant turn with isRetryableAssistantError() from @earendil-works/pi-ai. That function is a substring test over the entire error string:

if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);

The retryable pattern contains bare three-digit tokens:

"429" "500" "502" "503" "504" "520" "524"

LLM proxy error strings embed a request id. So whether a given failure gets retried can depend on the digits inside its own request id.

Real recorded errors from one machine, same provider, same model, same shape:

| error string (tail of request id) | regex match | outcome | |---|---|---| | This request is not supported. (...8268d9d69EdzNJOg) | 500 | retried | | This request is not supported. (...8268d9d6Xga8YJbp) | none | failed fast | | This request is not supported. (...8268d9d6AqNdevgF) | none | failed fast |

Over randomly generated 32-digit ids the false-positive rate is ~16.8%. So roughly one in six of these failures got a retry budget it did not earn, and the other five died immediately — even though they are the same class of error.

That matters because these errors are transient, not deterministic. Measured on a real long-running session: after a This request is not supported 400, the next attempt with the same provider and model succeeded 7 out of 9 times, at gaps of 13s–250s.

So the failures that go untried are exactly the ones a retry would have fixed, and the agent session just stops instead.

What this does

It rewrites the failed assistant message's errorMessage so the classifier accepts it — but only for errors genuinely worth another attempt.

  1. The error must be a 400 whose text carries no usable status signal and matches the transient "unsupported request" family.
  2. The error must not name a permanent condition. Model-not-found, auth/permission, content-policy, quota/billing, and unsupported-parameter errors are left untouched and still fail fast.

The rewritten text is prefixed with 503 service unavailable (retry-forced): and the entire original text is preserved, request id included.

503 is not a lie about what happened — a transient upstream refusal is semantically a service-unavailable, and it is the status the proxy should have sent. Pi's own retry path then handles backoff and budget normally.

How it hooks in

Pi emits extension events before it evaluates the retry:

_handleAgentEvent            (agent-session.js)
  └─ await _emitExtensionEvent(event)        ← message_end fires here
       └─ emitMessageEnd() → _replaceMessageInPlace()   ← mutates in place
  └─ this._lastAssistantMessage = assistantMsg

_shouldRetryAfterTurn
  └─ if (this._isRetryableError(message) ...)  ← reads the same object

_replaceMessageInPlace mutates the message object in place, and the retry check reads that same reference afterwards, so the rewrite takes effect. The handler returns { message } — returning a bare message would be silently ignored by the runner.

Second failure class: thinking-signature rejections

The same relays also return:

400 upstream rejected the request: ***.***.content.11: Invalid `signature` in `thinking` block

This one is not a classifier miss — it is a real upstream refusal, and it is route-dependent:

  • Measured on the live relay: a deliberately corrupted signature is accepted 10/10, and a replayed real signature 15/15. The relay normally does not validate thinking signatures at all.
  • The failures cluster after transport trouble (a terminated error, then 22 consecutive Connection error.s), and reuse the same block indices across separate incidents.

So the picture is a pooled relay: after a failure the request is routed to a different upstream account, whose signing key differs from the one that produced the replayed thinking block. Pi only considers provider + api + model when deciding whether a signature is still valid, which cannot see that the key authorising it has changed.

What this does about it

Retrying alone cannot work: the retry would resend the same rejected signature. So both halves are applied together.

  1. Retry. The rejection is classified as retryable (503) so Pi re-issues the turn instead of stopping.
  2. Strip. The before_provider_request hook rewrites the outgoing body so no replayed thinking signature remains. Reasoning is not deleted: a thinking block with text becomes a plain text block, so the model keeps its own prior reasoning while the unsupportable signature claim is dropped. Opaque redacted_thinking blocks and empty thinking are removed.

Verified against the real relay before shipping: thinking→text inside an active tool loop 8/8, on a prior turn 4/4, and with a corrupted signature 10/10. End-to-end through Pi (injecting the rejection once), the retried request arrived as text+text+tool_use with zero signature fields and the turn completed.

Modes

/thinking-sig [auto|always|off] — or set PI_RETRY_FORCED_THINKING.

| mode | behaviour | |---|---| | auto (default) | strip only after this session has seen a signature rejection; sticky for the rest of the session | | always | strip on every proxied request, so the first failure never happens | | off | never strip; rejections are still retried |

auto keeps normal sessions byte-identical to before, so prompt caching is unaffected until a rejection actually happens.

Install

pi install npm:pi-retry-forced

Or try it for a single invocation without touching your settings:

pi -e npm:pi-retry-forced

Scope and safety

  • Proxy endpoints only. Requests to api.anthropic.com, api.openai.com, and localhost are skipped by design — the classifier bug does not affect them and the rewrite would be wrong there.
  • Permanent errors are never rewritten. See PERMANENT_CONDITIONS in src/index.ts.
  • Success turns are never touched. Only a message whose stopReason is error is considered.
  • No retry logic is reimplemented. The extension only makes a misclassified error classifiable; Pi's own backoff and budget apply.

Command

/retry-forced reports what was converted this session, including the original text so you can confirm the request id is preserved.

This is a workaround

The correct fix belongs upstream: classify on a real status field (error.status === 429 || error.status >= 500) instead of regex-matching free text, or at minimum anchor the numeric tokens (\b500\b) and strip the (request id: ...) fragment before matching.

Until that lands, this extension compensates locally. Delete it once @earendil-works/pi-ai classifies by status.

Development

npm run build      # src/index.ts -> dist/index.js
npm test           # regression cases
npm run typecheck

The test suite pins both directions: errors that should be converted, errors that must still fail fast, and the thinking-block conversion (including that tool_use blocks and their order survive).

License

MIT