AppKit 오류 처리
AppKit은 AppError를 던지며, 그 code(AppResultCode)는 버전이 바뀌어도 값이 유지되는 고정 코드이므로 분기 기준으로 사용할 수 있습니다. 그 밖에 저수준 릴레이는 전용 오류(RelayConnectionError, RelayRpcError)를 던지며, 사용자 정의 커넥터·fetch·WebSocket·플랫폼 라이브러리의 원본 오류는 그대로 전파될 수 있습니다.
지갑 쪽에서 발생하는 오류와 두 SDK의 코드 대응은 WalletKit 오류 처리에서 다룹니다.
오류 유형
| 계층 | 식별 방법 | 대표 사용 위치 |
|---|---|---|
| AppKit | error instanceof AppError | 연결, 요청, 서명, 잔액, 모바일 복구 |
| AppKit 릴레이 | RelayConnectionError, RelayRpcError | 저수준 WSS와 릴레이 JSON-RPC |
| 프로바이더 | isProviderRpcError(error) | 직접 프로바이더 또는 사용자 정의 커넥터 |
| 플랫폼 | 그 밖의 unknown | fetch, TLS, WebSocket, 사용자 주입 의존성 |
AppError
AppError 정의
class AppError extends Error {
readonly code: AppResultCode;
readonly reason: unknown;
constructor(
code: AppResultCode,
message?: string,
reason?: unknown,
);
}
| 필드 | 설명 |
|---|---|
name | 항상 AppError |
code | 분기에 사용하는 안정적인 SDK 코드 |
message | 사람용 설명. 생략하면 코드 문자열 |
reason | 래핑한 원인 또는 백엔드 코드. 없을 수 있으며 모든 경로에서 보존되지는 않음 |
오류 처리 예제
import {
AppError,
AppResultCode,
type WalletConnection,
} from "@scope-connect/appkit";
export async function connectWithMessage(
connect: () => Promise<WalletConnection>,
): Promise<WalletConnection> {
try {
return await connect();
} catch (error) {
if (!(error instanceof AppError)) throw error;
switch (error.code) {
case AppResultCode.USER_REJECTED:
throw new Error("지갑에서 요청을 승인해 주세요.");
case AppResultCode.WALLET_NOT_FOUND:
throw new Error("지원 지갑을 설치하거나 다른 지갑을 선택해 주세요.");
default:
throw error;
}
}
}
AppResultCode
| 코드 | 계층·발생 조건 | 사용자 메시지 | 재시도 | 개발자 조치·안전한 로그 |
|---|---|---|---|---|
INVALID_CONFIG | 생성자 필수 옵션(clientId) 누락·공백, 초기화 전 사용, 체인 허용 목록 위반, 잘못된 페어링 URI, 모바일 딥링크 전략 없음, 네임스페이스 모호 | 앱 설정을 확인해 주세요 | 설정 수정 후 | 메서드명, 누락 필드명, 체인 ID |
WALLET_NOT_FOUND | 지갑 미설치, EIP-6963 지갑 알림 이벤트를 받지 못함, 지갑 목록에 없는 ID, 사용 가능한 커넥터 없음 | 지갑을 설치하거나 다른 지갑을 선택해 주세요 | 사용자 조치 후 | 지갑 ID, 커넥터 ID |
NOT_CONNECTED | 활성 계정·커넥터·릴레이 세션 없음, 재개할 대기 중 모바일 세션 없음 | 먼저 지갑을 연결해 주세요 | 연결 후 | 메서드명, 연결 상태 |
USER_REJECTED | 사용자가 연결·체인 전환·요청·서명을 거부. EIP-1193 4001과 Phantom errorCode "4001"을 정규화 | 요청이 취소되었습니다 | 새 사용자 동작 후 | 메서드명, 체인 ID. 요청 원문은 제외 |
UNSUPPORTED_METHOD | 커넥터나 네임스페이스가 작업을 지원하지 않음. 예: Solana 커넥터가 아닌 연결에서 signSolanaTransaction() | 이 지갑에서는 지원하지 않는 작업입니다 | 같은 조건에서는 아니요 | 메서드명, 커넥터, 네임스페이스 |
INVALID_RESPONSE | 65바이트가 아닌 서명, 계정 없이 연결된 세션, Phantom txSignature 누락·손상, 백엔드 비-JSON 응답 | 응답을 확인할 수 없습니다 | 일시 오류 확인 후 제한적으로 | 응답 상태·필드명. 전체 응답 본문은 제외 |
RPC_ERROR | 잔액·프로젝트 설정 조회 실패, 선택 의존성(@solana/web3.js) 누락, 모바일 지갑이 보고한 오류 | 네트워크 요청에 실패했습니다 | 원인별 | 전송 종류, 상태 코드, 체인 ID |
UNSUPPORTED_CHAIN | 지갑이 지원하지 않는 네임스페이스의 chainId(예: MetaMask에 solana: 체인), 지원 목록에 없는 체인, switchNetwork()에 비-EVM 체인 | 지원하지 않는 네트워크입니다 | 지원 체인 선택 후 | CAIP-2 체인 ID |
BACKEND_ERROR | 백엔드 요청 실패·비-200 응답, Phantom 모바일 폴링이 비-4001 errorCode를 보고(reason에 보존) | 잠시 후 다시 시도해 주세요 | 코드와 멱등성 확인 후 | 상태 코드, 비민감 요청 ID |
SESSION_TIMEOUT | 릴레이 메시지 대기 초과, Phantom 모바일 폴링 제한 시간 초과(기본 120초) | 세션이 만료되었습니다 | 새 세션으로 | 단계, 경과 시간, 지갑 ID |
주소, 페어링 URI, 채널 키, 서명 원문, 개인키, Client Secret은 오류 로그에 남기지 않습니다.
메서드별 오류
각 공개 메서드가 던지는 코드는 JSDoc @throws 선언을 기준으로 합니다.
| 메서드 | 발생하는 AppResultCode | 그 밖에 전달될 수 있는 오류 |
|---|---|---|
connect(options?) | INVALID_CONFIG(초기화 전, 허용 목록 밖 체인), WALLET_NOT_FOUND | 사용자 정의 커넥터 오류 |
selectWallet(walletId, options?) | INVALID_CONFIG, WALLET_NOT_FOUND, UNSUPPORTED_CHAIN, USER_REJECTED, BACKEND_ERROR, RPC_ERROR, SESSION_TIMEOUT, INVALID_RESPONSE | 모바일 세션·선택 의존성 오류 |
pair(options) | INVALID_CONFIG(초기화 전), BACKEND_ERROR, INVALID_RESPONSE | 호출 단계의 RelayConnectionError, 세션 Promise의 RelayRpcError·SESSION_TIMEOUT |
switchNetwork(chainId) | NOT_CONNECTED, UNSUPPORTED_CHAIN, INVALID_CONFIG(허용 목록 밖), USER_REJECTED | 4902 발생 후 체인 추가까지 실패하면 프로바이더 오류 |
addNetwork(input, options?) | NOT_CONNECTED(지갑 미선택), UNSUPPORTED_METHOD(릴레이·Klip A2A·비-EVM 연결), INVALID_CONFIG(EIP-3085 파라미터 위반, switch 시 허용 목록 밖), UNSUPPORTED_CHAIN, USER_REJECTED | 지갑의 -32602 등 프로바이더 오류 |
request(args) | NOT_CONNECTED(릴레이 세션 미수립 포함) | 프로바이더·커넥터 자체 오류 |
signTransferAuthorization(typedData, options?) | NOT_CONNECTED, INVALID_CONFIG, INVALID_RESPONSE, USER_REJECTED | 릴레이 오류 |
signSolanaTransaction(transaction, options?) | NOT_CONNECTED, UNSUPPORTED_METHOD, INVALID_CONFIG, USER_REJECTED, RPC_ERROR, INVALID_RESPONSE, BACKEND_ERROR, SESSION_TIMEOUT | 선택 의존성·모바일 세션 오류 |
getBalance(query) | NOT_CONNECTED(계정 없음), UNSUPPORTED_CHAIN, RPC_ERROR | 주입한 fetch 원인 |
resumeSolanaConnect(params) | NOT_CONNECTED, USER_REJECTED, BACKEND_ERROR, WALLET_NOT_FOUND, SESSION_TIMEOUT, INVALID_RESPONSE | 세션 어댑터 오류 |
resumeSolanaSignTransaction(params) | NOT_CONNECTED, USER_REJECTED, BACKEND_ERROR, SESSION_TIMEOUT, INVALID_RESPONSE | 세션 어댑터 오류 |
resumeSolanaMobileFromCallback(query) | USER_REJECTED, BACKEND_ERROR, SESSION_TIMEOUT, WALLET_NOT_FOUND, INVALID_RESPONSE | 세션 어댑터 오류 |
resumeUniversalLink() | INVALID_CONFIG(초기화 전), USER_REJECTED, RPC_ERROR, SESSION_TIMEOUT, BACKEND_ERROR | — |
config (getter) | INVALID_CONFIG(초기화 전 접근) | — |
disconnect() | 고정 코드 없음 | 사용자 정의 커넥터 거부, 플랫폼 오류 |
request()는 init() 여부를 먼저 검사하지 않습니다. 연결이 없으면 INVALID_CONFIG이 아니라 NOT_CONNECTED입니다. 반대로 getBalance()는 계정과 백엔드 설정이 있으면 연결이나 초기화 없이도 실행할 수 있습니다.
AppKit 릴레이 오류
RelayConnectionError
WebSocket 연결 또는 종료를 나타냅니다.
class RelayConnectionError extends Error {
readonly closeCode: number;
}
closeCode | 의미 | 재시도 |
|---|---|---|
4401 | 릴레이 인증 실패 또는 만료 토큰 | 토큰 재발급 후 한 번 |
4403 | 클라이언트 거부 | 권한·환경 수정 후 |
1013 | 과부하 또는 속도 제한 | 지수 대기 후 |
-1 | 열기 전 전송 오류 또는 로컬 종료 | 원인 확인 후 |
| 그 밖의 값 | WebSocket 종료 코드 | 코드 의미에 따라 |
릴레이 메시지를 기다리는 동안 소켓이 닫히면 제한 시간까지 기다리지 않고 즉시 RelayConnectionError로 거부됩니다. 제한 시간 초과는 AppError의 SESSION_TIMEOUT으로 전달됩니다.
RelayRpcError
릴레이 JSON-RPC 오류입니다.
class RelayRpcError extends Error {
readonly code: number;
readonly rpcMessage: string;
readonly isForbiddenTopic: boolean;
}
| 코드 | 의미 | 조치 |
|---|---|---|
2403 | 인증된 dApp이 아직 만들지 않은 토픽에 지갑이 참여 | dApp 구독을 확인하고 짧게 제한 재시도 |
-32602 | 잘못된 파라미터 | 입력을 수정하고 같은 값으로 재시도하지 않음 |
| 그 밖의 값 | 릴레이가 반환한 JSON-RPC 오류 | rpcMessage와 서버 정책 확인 |
import {
RelayConnectionError,
RelayRpcError,
} from "@scope-connect/appkit";
function relayRetryable(error: unknown): boolean {
if (error instanceof RelayConnectionError) {
return error.closeCode === 1013 || error.closeCode === -1;
}
return error instanceof RelayRpcError && error.isForbiddenTopic;
}
릴레이 토큰 만료
AppKit은 (재)연결 전에 만료가 임박한(5초 여유) 릴레이 토큰을 재발급해, 오래된 ?auth= 토큰이 WSS 핸드셰이크에서 4401로 거부되는 것을 예방합니다. 그럼에도 4401이 발생하면 시계 오차나 서버 측 무효화를 확인합니다.
프로바이더 오류
직접 EIP-1193 프로바이더를 호출하거나 사용자 정의 커넥터가 자체 오류를 전달하면 숫자 코드를 볼 수 있습니다.
interface ProviderRpcError {
code: number;
message?: string;
}
isProviderRpcError(value: unknown): value is ProviderRpcError
const USER_REJECTED_CODE = 4001
기본 AppKit EVM 커넥터는 MetaMask의 data.originalError.code처럼 중첩된 코드도 확인하고, 코드 4001을 AppError(USER_REJECTED)로 정규화합니다. AppKit 메서드를 호출한 코드는 AppError를 먼저 처리하고, 지갑이나 프로바이더가 직접 전달한 오류는 보조 분기로 둡니다. 공개 API로는 위의 isProviderRpcError와 USER_REJECTED_CODE를 사용합니다.
코드 4902는 EVM 지갑이 대상 체인을 아직 모를 때 발생하며, 모바일 MetaMask의 테스트넷 전환에서 흔합니다. switchNetwork()는 이를 감지해 네트워크 기본값으로 wallet_addEthereumChain을 호출하고 다시 전환합니다.
import {
AppError,
AppResultCode,
isProviderRpcError,
USER_REJECTED_CODE,
} from "@scope-connect/appkit";
function wasRejected(error: unknown): boolean {
if (error instanceof AppError) {
return error.code === AppResultCode.USER_REJECTED;
}
return isProviderRpcError(error) && error.code === USER_REJECTED_CODE;
}
제한 시간
| 경로 | 현재 기본값 | 공개 재정의 |
|---|---|---|
| AppKit 릴레이 제안 응답 | 300초 | PairOptions에서 제공하지 않음 |
| AppKit 릴레이 세션 settle | 300초 | PairOptions에서 제공하지 않음 |
| Solana 모바일 폴링 | 기본 120초 | 기본 AppKit API에서 직접 재정의하지 않음 |
지갑 측 제한 시간(연결 20초, 서명 요청 5분)은 WalletKit 오류 처리에서 확인합니다.
제한 시간 뒤 기존 세션을 무조건 다시 사용하지 않습니다. 연결·페어링은 새 사용자 동작으로 시작하고, 트랜잭션 관련 작업은 기존 제출 여부를 먼저 확인해 중복 실행을 방지합니다.
재시도 원칙
| 상황 | 자동 재시도 | 이유 |
|---|---|---|
| 사용자 거부 | 아니요 | 새 사용자 의도가 필요 |
| 잘못된 설정·파라미터·지원하지 않는 체인 | 아니요 | 동일 입력은 같은 결과 |
| 속도 제한·일시 네트워크 오류 | 제한적으로 | 지수 대기와 상한 필요 |
| 릴레이 토픽 준비 지연 | 제한적으로 | dApp이 토픽을 만드는 짧은 경쟁 상태일 수 있음 |
| 세션 제한 시간 | 새 세션만 | 기존 상태가 유효한지 보장할 수 없음 |
| 트랜잭션 제출 결과 불명 | 자동 재제출 금지 | 중복 전송 위험 |