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
- 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
- Verify the token's audience/permissions cover the company and line scope of the saved cursor
- Confirm the Photon address points at the environment that issued the token (no staging/prod mismatch)
- 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
- Refresh line tokens proactively before their TTL expires instead of reusing cached ones across reconnects
- Pin each line to the Photon environment that issued its token (avoid staging/prod address mixups)
- Monitor for recurring 'credentials' errors — they usually indicate rotation or permission drift, not transient faults
- Always tear down the old channel (transport.close()) before rebuilding with new credentials
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
- quota
- ACPX recovery identity does not match the persisted runtime…
- Agent identity is required
- ANTHROPIC_API_KEY is required in the CLI process environment
- Cloud control assertion is not a compact JWS
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)