paperclipai/paperclip · error · PhotonError

credentials

credentials

Error message

Photon rejected the selected line credentials; reconnect the channel

What it means

PhotonRecoveryTransport.catchUp() opens a gRPC server-stream (the catchup path) authenticated with a Bearer line token. When the server ends the stream with UNAUTHENTICATED or PERMISSION_DENIED, the transport maps it to a PhotonError with code 'credentials'. It signals that the token or line identity is no longer accepted and the channel must be re-established with fresh credentials rather than retried in place.

Solutions

  1. Refresh the line credentials via authentication.token() (or re-run the line auth flow) and construct a new PhotonRecoveryTransport, since the error message instructs reconnecting the channel
  2. Verify the token's audience/permissions cover the company and line scope of the saved cursor
  3. Confirm the Photon address points at the environment that issued the token (no staging/prod mismatch)
  4. If the token was rotated externally, redistribute the new secret to this instance's configuration

Example fix

// before: reuse a long-lived transport with a stale token
const stream = transport.catchUp(lastSequence);
// after: rebuild credentials and transport on 'credentials' errors
try {
  return transport.catchUp(lastSequence);
} catch (e) {
  if (e instanceof PhotonError && e.code === "credentials") {
    transport.close();
    await authentication.refresh();
    transport = new PhotonRecoveryTransport(authentication);
    return transport.catchUp(lastSequence);
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// check token presence and shape before opening the stream
if (!authentication?.address || typeof authentication.token !== "function")
  throw new Error("Photon line authentication is not configured");
const token = await authentication.token();
if (!token || token.split(".").length !== 2) console.warn("Photon line token looks malformed");

Type guard

function isPhotonCredentialsError(e: unknown): e is PhotonError {
  return e instanceof PhotonError && e.code === "credentials";
}

Try / catch

try {
  stream = transport.catchUp(lastSequence);
} catch (e) {
  if (isPhotonCredentialsError(e)) {
    transport.close();
    await authentication.refresh(); // re-auth, do NOT blind-retry
    transport = new PhotonRecoveryTransport(authentication);
    stream = transport.catchUp(lastSequence);
  } else throw e;
}

Prevention

When it happens

Trigger: Calling catchUp() (directly or via the recovery path) when: the Photon line token is expired or rotated between token fetch and stream start; the token lacks permission for the company/line scope of the cursor; the server revoked the line credentials mid-stream; or the authorization metadata carries a stale/malformed token.

Common situations: Long-running processes holding tokens past their TTL; token rotation on the Photon side while an instance reconnects; connecting a line to a different Photon tenant than the one that issued the token; misconfigured PHOTON address pointing at an environment with different auth (staging vs prod).

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/0002836de8351a5f. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/photon/recovery-transport.ts:158

      controller.signal.addEventListener("abort", abort, { once: true });
      try {
        for await (const bytes of call) {
          const frame = decodePhotonRecoveryFrame(bytes as Buffer);
          if (frame) yield frame;
        }
      } catch (error) {
        if (controller.signal.aborted) return;
        const code = (error as { code?: number }).code;
        if (code === status.OUT_OF_RANGE || code === status.FAILED_PRECONDITION)
          throw new PhotonError(
            "history_gap",
            "Photon cannot recover the saved cursor; reconnect after reviewing the history gap",
          );
        if (
          code === status.UNAUTHENTICATED ||
          code === status.PERMISSION_DENIED
        )
          throw new PhotonError(
            "credentials",
            "Photon rejected the selected line credentials; reconnect the channel",
          );
        if (code === status.RESOURCE_EXHAUSTED)
          throw new PhotonError(
            "quota",
            "Photon recovery is temporarily rate limited",
          );
        throw photonFailure(error);
      } finally {
        controller.signal.removeEventListener("abort", abort);
        call.cancel();
      }
    }
    return new TypedEventStream(receive(), async () => controller.abort());
  }
  close(): void {
    this.client.close();

View on GitHub (pinned to 3f1d897a7c)