feat(vk): Phase 2.1.1 VK Client Correctness & Official VK Contract Alignment - separated timeout vs cancellation state with VkCancelledError, HTTP vs VK error separation based on VKCOM/vk-api-schema, listener cleanup, pagination truncation guards, and VK_METHOD_CAPABILITIES documentation

This commit is contained in:
Ochenstarik 2026-08-18 02:38:43 +07:00
parent e666161612
commit 7acf4d2d4e
7 changed files with 605 additions and 92 deletions

View file

@ -1,6 +1,6 @@
# VK Client Architecture & Integration Guide
# VK Client Architecture & Official API Specification
The VK Integration layer is structured into modular, decoupled components located under `src/integrations/vk/`.
The VK Integration layer is structured into decoupled components located under `src/integrations/vk/`.
---
@ -25,31 +25,51 @@ The VK Integration layer is structured into modular, decoupled components locate
[VK API]
```
### Core Components
---
1. **`VkClient` (`src/integrations/vk/vk-client.ts`)**:
- Centralizes low-level HTTP communication with VK API.
- Enforces default API version `5.199`.
- Manages timeouts with `AbortController` (default 15s).
- Coordinates outbound rate limiting and retry backoff.
- Provides universal pagination helper `fetchPaginatedVk`.
## Verified VK API Specifications (v5.199)
2. **`VkAuthContext` (`src/integrations/vk/vk-auth.ts`)**:
- Represents typed access tokens (`SERVICE`, `USER`, `COMMUNITY`).
- Ensures tokens are never leaked into logs, error messages, or persistent audit records.
### 1. `wall.getById`
- **Official Docs**: `https://dev.vk.com/ru/method/wall.getById`
- **Method**: POST/GET `https://api.vk.com/method/wall.getById`
- **Parameters**: `posts` (e.g. `"-100_12345"`), `extended=1`.
- **Response**: `{ items: VkWallPost[], profiles?: VkUserProfile[], groups?: VkGroupProfile[] }`.
- **Behavior**: Returns empty `items: []` or error 210 if post is deleted or wall is private.
3. **`VkRateLimiter` (`src/integrations/vk/vk-rate-limit.ts`)**:
- Throttles outbound requests according to VK API thresholds (default: 10 req/sec configurable).
### 2. `likes.getList`
- **Official Docs**: `https://dev.vk.com/ru/method/likes.getList`
- **Method**: POST/GET `https://api.vk.com/method/likes.getList`
- **Parameters**: `type="post"`, `owner_id`, `item_id`, `filter="likes"`, `extended=1`, `count` (max 100), `offset`.
- **Response**: `{ count: number, items: VkUserProfile[] }`.
4. **`executeWithRetry` (`src/integrations/vk/vk-retry.ts`)**:
- Handles exponential backoff with full jitter for retryable transient errors (5xx server errors, rate limits, network timeouts).
- Fast-fails non-retryable errors (auth errors, permissions, private resources, validation).
### 3. `wall.getComments`
- **Official Docs**: `https://dev.vk.com/ru/method/wall.getComments`
- **Method**: POST/GET `https://api.vk.com/method/wall.getComments`
- **Parameters**: `owner_id`, `post_id`, `extended=1`, `count` (max 100), `offset`, `fields="photo_100,photo_200,screen_name"`.
- **Response**: `{ count: number, items: VkCommentItem[], profiles?: VkUserProfile[] }`.
### 4. `groups.isMember`
- **Official Docs**: `https://dev.vk.com/ru/method/groups.isMember`
- **Method**: POST/GET `https://api.vk.com/method/groups.isMember`
- **Parameters**: `group_id`, `user_ids` (comma-separated list of IDs up to **500 max** per batch call).
- **Response**: `Array<{ user_id: number, member: 1 | 0 }>`.
### 5. Reposts Limitation `[CONFIRMED_LIMITATION]`
- **Official Status**: VK API does **not** provide a public method to list all users who reposted an arbitrary third-party post due to user privacy settings. `wall.getReposts` only works for community managers on their own wall posts.
- **Provider Flag**: `capabilities.reposts = false`.
### 6. Admin Detection `[UNVERIFIED]`
- **Official Status**: Checking if a user is an administrator of a target community requires `groups.getMembers` with `filter=managers`, which requires community admin rights.
- **Provider Flag**: `capabilities.adminDetection = false`.
---
## Pagination & Scalability
## Cancellation vs Timeout Lifecycle
- **No Artificial Caps**: Previous limits (e.g. 5,000 likes or 1,000 comments) have been completely removed.
- **Likes**: Uses `likes.getList` with `filter=likes&extended=1` in batches of 100 up to the total post likes count.
- **Comments**: Uses `wall.getComments` with `extended=1` and profile enrichment.
- **Subscription Checks**: Batches up to 500 user IDs per `groups.isMember` call.
| Failure Mode | Error Class | Retryable? | Behavior |
|---|---|---|---|
| Caller `AbortSignal` fires | `VkCancelledError` | **No** | Request aborted immediately; retry engine halts without retry. |
| Client timeout timer expires | `VkTimeoutError` | **Yes** | Attempt aborted; backoff delay computed and retry initiated up to `maxRetries`. |
| HTTP 429 Too Many Requests | `VkRateLimitError` | **Yes** | Retryable with backoff. |
| HTTP 500..504 Server Error | `VkTemporaryError` | **Yes** | Retryable with backoff. |
| HTTP 400/401/403/404 | `VkClientError` subclasses | **No** | Fast fail. |

View file

@ -0,0 +1,41 @@
# VK Method Capabilities Matrix
This document defines the verified VK API method capabilities, token scopes, and known platform constraints according to the official **`VKCOM/vk-api-schema`** (API version 5.199).
---
## Verified VK Method Matrix
| VK Method | Allowed Token Types (Schema) | Required Parameters | Max Batch / Count | Known Privacy & Policy Limitations | Verification Status |
|---|---|---|---|---|---|
| **`wall.getById`** | `service`, `user`, `group`, `open` | `posts` (e.g. `"-123_456"`) | Max 100 posts per call | Cannot access posts on private user walls or restricted groups without user/group authorization. | **`VERIFIED`** |
| **`likes.getList`** | `service`, `user`, `group`, `open` | `type="post"`, `owner_id`, `item_id` | Max 100 with `extended=1` (profiles), max 1000 with IDs only | If a post is from a closed community, requires membership/access. Profiles with deleted accounts are returned with `deactivated` tag. | **`VERIFIED`** |
| **`wall.getComments`** | `service`, `user`, `group`, `open` | `owner_id`, `post_id` | Max 100 comments per request | Nested replies require recursive traversal or standard chronological fetch. Closed comments return error 210. | **`VERIFIED`** |
| **`groups.isMember`** | `service`, `user`, `group`, `open` | `group_id`, `user_ids` | Max **500** `user_ids` per batch call | Closed groups return `member=0` for non-members even if user has pending join request (unless request status inspected). | **`VERIFIED`** |
| **`wall.getReposts`** | `user`, `group` | `owner_id`, `post_id` | Max 100 | **Cannot enumerate all reposters** on arbitrary public posts due to user profile privacy restrictions. Only available to group managers for their own posts. | **`CONFIRMED_LIMITATION`** |
| **`groups.getMembers`** (Managers) | `user`, `group` | `group_id`, `filter="managers"` | Max 1000 | Requires administrative rights in the target community. Not available via standalone public Service Token. | **`CONFIRMED_LIMITATION`** |
---
## Token Type Definitions (`VKCOM/vk-api-schema`)
1. **`service` (Service Token)**:
- Application access token obtained from VK Developer Console.
- Strictly read-only for public methods.
- Never expires, but cannot act on behalf of a user.
2. **`user` (User Access Token)**:
- Obtained via modern **VK ID Web SDK** (OAuth 2.1 with PKCE).
- Can access user-authorized data, private groups user belongs to, and perform user actions.
3. **`group` (VK Group Access Token)**:
- Configured in VK Community Settings (referred to internally as `COMMUNITY` token in Randomayzer).
- Scoped strictly to the managing group/public page.
---
## Authentication Architecture for Phase 2.2
- **Protocol**: OAuth 2.1 + PKCE (`code_verifier`, `code_challenge` SHA-256 base64url).
- **State Security**: Cryptographically secure single-use `state` with TTL, validated on OAuth callback.
- **Endpoints**: Modern `id.vk.com` / `vk.ru` VK ID Web SDK flow (legacy implicit token flow is deprecated).

View file

@ -6,11 +6,14 @@ import {
} from './vk-types';
import {
mapVkApiError,
mapHttpStatusError,
VkTimeoutError,
VkCancelledError,
VkNetworkError,
VkValidationError
VkValidationError,
VkPaginationLimitError
} from './vk-errors';
import { validateAuthContext, redactToken } from './vk-auth';
import { validateAuthContext } from './vk-auth';
import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit';
import { executeWithRetry } from './vk-retry';
@ -48,7 +51,7 @@ export class VkClient implements IVkClient {
}
/**
* Executes a single low-level HTTP call to VK API with timeout and error handling.
* Executes a single low-level HTTP call to VK API with explicit separate timeout/cancellation tracking.
*/
private async executeSingleCall<T>(
method: string,
@ -58,26 +61,42 @@ export class VkClient implements IVkClient {
): Promise<T> {
validateAuthContext(authContext);
// Acquire rate limit slot
// 1. Check if caller already aborted before execution starts
if (options?.signal?.aborted) {
throw new VkCancelledError(`VK request to "${method}" was cancelled by caller before execution`, {
method,
});
}
// 2. Acquire rate limit slot
await this.rateLimiter.acquire();
if (options?.signal?.aborted) {
throw new VkCancelledError(`VK request to "${method}" was cancelled by caller while waiting for rate limiter`, {
method,
});
}
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
const controller = new AbortController();
let timedOut = false;
let callerCancelled = false;
let timeoutId: NodeJS.Timeout | null = null;
if (timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
}
// Merge caller signal if provided
const onCallerAbort = () => {
callerCancelled = true;
controller.abort();
};
if (options?.signal) {
if (options.signal.aborted) {
if (timeoutId) clearTimeout(timeoutId);
throw new Error('VK request aborted by caller signal');
}
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
options.signal.addEventListener('abort', onCallerAbort, { once: true });
}
const url = `${this.baseUrl}${method}`;
@ -103,13 +122,8 @@ export class VkClient implements IVkClient {
signal: controller.signal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
throw new VkNetworkError(
`VK API HTTP error ${response.status}: ${response.statusText}`,
{ method, errorCode: response.status }
);
throw mapHttpStatusError(response.status, response.statusText, method);
}
const text = await response.text();
@ -137,26 +151,42 @@ export class VkClient implements IVkClient {
return json.response;
} catch (err: unknown) {
if (timeoutId) clearTimeout(timeoutId);
// Precise error classification:
if (callerCancelled || options?.signal?.aborted) {
throw new VkCancelledError(`VK API call to "${method}" was cancelled by the caller`, {
method,
});
}
if (controller.signal.aborted) {
if (timedOut) {
throw new VkTimeoutError(`VK API call to "${method}" timed out after ${timeoutMs}ms`, {
method,
});
}
if (err instanceof Error && err.name === 'AbortError') {
throw new VkTimeoutError(`VK API call to "${method}" was aborted`, { method });
if (options?.signal?.aborted) {
throw new VkCancelledError(`VK API call to "${method}" was aborted by caller signal`, {
method,
});
}
throw new VkTimeoutError(`VK API call to "${method}" was aborted by timeout`, { method });
}
if (err instanceof Error && !(err instanceof VkNetworkError) && !(err as any).category) {
// Unhandled fetch network error
if (err instanceof Error && !(err as any).category) {
throw new VkNetworkError(`VK API network error on "${method}": ${err.message}`, {
method,
});
}
throw err;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (options?.signal) {
options.signal.removeEventListener('abort', onCallerAbort);
}
}
}
@ -185,20 +215,25 @@ export class VkClient implements IVkClient {
* Universal pagination abstraction for VK API methods (offset/count based).
*/
export async function fetchPaginatedVk<TItem>(
options: VkPaginationOptions<TItem>
options: VkPaginationOptions<TItem> & { throwOnTruncation?: boolean }
): Promise<TItem[]> {
const pageSize = options.pageSize ?? 100;
const maxPages = options.maxPages ?? 10000;
const throwOnTruncation = options.throwOnTruncation ?? true;
const allItems: TItem[] = [];
let offset = 0;
let page = 0;
let recordedTotalCount: number | undefined;
while (page < maxPages) {
if (options.signal?.aborted) {
break;
throw new VkCancelledError('VK pagination cancelled by caller signal');
}
const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal);
if (totalCount !== undefined) {
recordedTotalCount = totalCount;
}
if (!items || items.length === 0) {
break;
@ -221,6 +256,20 @@ export async function fetchPaginatedVk<TItem>(
}
}
// Safety check against silent truncation
if (
page >= maxPages &&
recordedTotalCount !== undefined &&
allItems.length < recordedTotalCount
) {
if (throwOnTruncation) {
throw new VkPaginationLimitError(
`VK pagination reached maxPages safety ceiling (${maxPages} pages) with only ${allItems.length}/${recordedTotalCount} items loaded. Truncation detected.`,
{ details: { loaded: allItems.length, total: recordedTotalCount, maxPages } }
);
}
}
return allItems;
}

View file

@ -22,7 +22,7 @@ export abstract class VkClientError extends Error {
}
/**
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28)
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28, HTTP 401)
*/
export class VkAuthError extends VkClientError {
readonly isRetryable = false;
@ -30,7 +30,7 @@ export class VkAuthError extends VkClientError {
}
/**
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260)
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260, HTTP 403)
*/
export class VkPermissionError extends VkClientError {
readonly isRetryable = false;
@ -38,7 +38,7 @@ export class VkPermissionError extends VkClientError {
}
/**
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9)
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9, 29, HTTP 429)
*/
export class VkRateLimitError extends VkClientError {
readonly isRetryable = true;
@ -54,7 +54,7 @@ export class VkPrivateResourceError extends VkClientError {
}
/**
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214)
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214, HTTP 404)
*/
export class VkNotFoundError extends VkClientError {
readonly isRetryable = false;
@ -62,7 +62,7 @@ export class VkNotFoundError extends VkClientError {
}
/**
* VK Validation Error: Malformed parameters or bad request (VK error codes: 100, 113, 150)
* VK Validation Error: Malformed parameters or bad request (VK error codes: 8, 100, 113, 150, HTTP 400)
*/
export class VkValidationError extends VkClientError {
readonly isRetryable = false;
@ -70,7 +70,7 @@ export class VkValidationError extends VkClientError {
}
/**
* VK Temporary Error: Unknown error, internal server error, or 5xx response from VK API (VK error codes: 1, 10, 500, 502, 503, 504)
* VK Temporary Error: Unknown error or internal server error from VK API (VK error codes: 1, 10, HTTP 500, 502, 503, 504)
*/
export class VkTemporaryError extends VkClientError {
readonly isRetryable = true;
@ -86,13 +86,32 @@ export class VkNetworkError extends VkClientError {
}
/**
* VK Timeout Error: Request was aborted due to client timeout
* VK Timeout Error: Request was aborted due to internal client timeout or VK method timeout (VK error code: 36)
*/
export class VkTimeoutError extends VkClientError {
readonly isRetryable = true;
readonly category = 'TIMEOUT';
}
/**
* VK Cancelled Error: Request was cancelled by the caller via AbortSignal (NEVER retryable)
*/
export class VkCancelledError extends VkClientError {
readonly isRetryable = false;
readonly category = 'CANCELLED';
}
/**
* VK Pagination Limit Error: Pagination hit maxPages safety threshold before fetching all items
*/
export class VkPaginationLimitError extends VkClientError {
readonly isRetryable = false;
readonly category = 'PAGINATION_LIMIT_REACHED';
}
// Alias for backwards compatibility
export const VkPaginationTruncatedError = VkPaginationLimitError;
/**
* Sanitizes request params returned by VK to redact any sensitive token values.
*/
@ -107,7 +126,7 @@ function sanitizeRequestParams(params?: Array<{ key: string; value: string }>):
}
/**
* Maps raw VK API error_code according to official VK API documentation.
* Maps raw VK API error_code strictly according to official VKCOM/vk-api-schema.
*/
export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError {
const code = raw.error_code;
@ -115,6 +134,10 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError
const msg = raw.error_msg || raw.error_text || `VK API Error (${code})`;
switch (code) {
case 1: // Unknown error occurred
case 10: // Internal server error
return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 4: // Incorrect signature
case 5: // User authorization failed
case 28: // Application authorization failed
@ -129,30 +152,54 @@ export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError
case 260: // Access to the group is denied
return new VkPermissionError(`VK Permission Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 8: // Invalid request
case 100: // One of the parameters specified was missing or invalid
case 113: // Invalid user id
case 150: // Invalid timestamp
return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 15: // Access denied (private object)
case 30: // This profile is private
case 203: // Access to the group is denied
return new VkPrivateResourceError(`VK Private Resource Access Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 100: // One of the parameters specified was missing or invalid
case 113: // Invalid user id
case 150: // Invalid timestamp
return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 36: // Compile or method execution timeout on VK server side
return new VkTimeoutError(`VK Method Execution Timeout (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 104: // Not found
case 210: // Access to wall's post denied or post not found
case 214: // Access to adding post denied / not found
return new VkNotFoundError(`VK Resource Not Found (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
case 1: // Unknown error occurred
case 10: // Internal server error
case 500:
case 502:
case 503:
case 504:
return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
default:
return new VkValidationError(`VK API Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
}
}
/**
* Maps HTTP status codes into typed VK client errors with proper retry classification.
*/
export function mapHttpStatusError(status: number, statusText: string, method: string): VkClientError {
const msg = `VK API HTTP error ${status}: ${statusText || 'Unknown'}`;
if (status === 429) {
return new VkRateLimitError(msg, { errorCode: status, method });
}
if (status >= 500 && status <= 504) {
return new VkTemporaryError(msg, { errorCode: status, method });
}
if (status === 401) {
return new VkAuthError(msg, { errorCode: status, method });
}
if (status === 403) {
return new VkPermissionError(msg, { errorCode: status, method });
}
if (status === 404) {
return new VkNotFoundError(msg, { errorCode: status, method });
}
if (status >= 400 && status < 500) {
return new VkValidationError(msg, { errorCode: status, method });
}
return new VkNetworkError(msg, { errorCode: status, method });
}

View file

@ -1,4 +1,4 @@
import { VkClientError } from './vk-errors';
import { VkClientError, VkCancelledError } from './vk-errors';
export interface VkRetryOptions {
maxRetries?: number;
@ -56,7 +56,7 @@ export async function executeWithRetry<T>(
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
if (signal?.aborted) {
throw new Error('Operation aborted');
throw new VkCancelledError('Operation aborted by caller signal before attempt');
}
try {
@ -71,16 +71,19 @@ export async function executeWithRetry<T>(
const delay = calculateBackoffDelay(attempt, config);
if (delay > 0) {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, delay);
let timeoutId: NodeJS.Timeout | null = null;
const onAbort = () => {
if (timeoutId) clearTimeout(timeoutId);
reject(new VkCancelledError('Operation aborted by caller signal during retry backoff'));
};
timeoutId = setTimeout(() => {
if (signal) signal.removeEventListener('abort', onAbort);
resolve(true);
}, delay);
if (signal) {
signal.addEventListener(
'abort',
() => {
clearTimeout(timeout);
reject(new Error('Operation aborted during retry backoff'));
},
{ once: true }
);
signal.addEventListener('abort', onAbort, { once: true });
}
});
}

View file

@ -0,0 +1,343 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VkClient, fetchPaginatedVk } from '../src/integrations/vk/vk-client';
import { createServiceAuth, redactToken } from '../src/integrations/vk/vk-auth';
import {
VkCancelledError,
VkTimeoutError,
VkRateLimitError,
VkTemporaryError,
VkAuthError,
VkPermissionError,
VkPrivateResourceError,
VkNotFoundError,
VkValidationError,
VkPaginationLimitError
} from '../src/integrations/vk/vk-errors';
import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit';
class ImmediateRateLimiter implements IVkRateLimiter {
async acquire(): Promise<void> {}
reset(): void {}
}
describe('Phase 2.1.1 VK Client Correctness Gate & Official Schema Verification', () => {
const token = 'vk1.a.correctness_gate_secret_token_12345';
const auth = createServiceAuth(token);
let client: VkClient;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
client = new VkClient({
rateLimiter: new ImmediateRateLimiter(),
defaultTimeoutMs: 500,
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetchVkError(errorCode: number, errorMsg: string) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify({ error: { error_code: errorCode, error_msg: errorMsg } }),
json: async () => ({ error: { error_code: errorCode, error_msg: errorMsg } }),
});
}
// --- 1. Cancellation vs Timeout Separation ---
it('caller already aborted before call immediately throws VkCancelledError with 0 retries', async () => {
const controller = new AbortController();
controller.abort();
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
return { ok: true, status: 200, text: async () => '{"response": 1}' };
});
await expect(
client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 })
).rejects.toThrow(VkCancelledError);
expect(fetchCount).toBe(0);
});
it('caller abort during active request throws VkCancelledError and NEVER retries', async () => {
const controller = new AbortController();
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, init) => {
fetchCount++;
return new Promise((_, reject) => {
init.signal.addEventListener('abort', () => {
reject(new Error('AbortError'));
});
});
});
const callPromise = client.call('wall.getById', {}, auth, { signal: controller.signal, maxRetries: 3 });
setTimeout(() => controller.abort(), 10);
await expect(callPromise).rejects.toThrow(VkCancelledError);
expect(fetchCount).toBe(1);
});
it('internal timeout throws VkTimeoutError and retries according to policy', async () => {
const timeoutClient = new VkClient({
rateLimiter: new ImmediateRateLimiter(),
defaultTimeoutMs: 25,
});
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
if (fetchCount < 3) {
return new Promise(resolve => setTimeout(resolve, 80));
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ response: { success: 1 } }),
};
});
const result = await timeoutClient.call<{ success: number }>(
'wall.getById',
{},
auth,
{ maxRetries: 3, retryInitialDelayMs: 5 }
);
expect(result.success).toBe(1);
expect(fetchCount).toBe(3);
});
it('caller abort during retry backoff terminates immediately with VkCancelledError', async () => {
const controller = new AbortController();
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
return {
ok: false,
status: 500,
statusText: 'Internal Server Error',
};
});
const callPromise = client.call(
'wall.getById',
{},
auth,
{ signal: controller.signal, maxRetries: 3, retryInitialDelayMs: 200 }
);
setTimeout(() => controller.abort(), 30);
await expect(callPromise).rejects.toThrow(VkCancelledError);
expect(fetchCount).toBe(1);
});
// --- 2. HTTP Status Code Handling ---
it('HTTP 429 Too Many Requests throws VkRateLimitError and retries', async () => {
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
if (fetchCount < 2) {
return { ok: false, status: 429, statusText: 'Too Many Requests' };
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ response: { ok: true } }),
};
});
const res = await client.call<{ ok: boolean }>('likes.getList', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 });
expect(res.ok).toBe(true);
expect(fetchCount).toBe(2);
});
it('HTTP 500, 502, 503 throw VkTemporaryError and retry', async () => {
for (const status of [500, 502, 503]) {
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
if (fetchCount < 2) {
return { ok: false, status, statusText: 'Server Error' };
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ response: { ok: true } }),
};
});
const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 });
expect(res.ok).toBe(true);
expect(fetchCount).toBe(2);
}
});
it('HTTP 400 throws VkValidationError and does NOT retry', async () => {
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
return { ok: false, status: 400, statusText: 'Bad Request' };
});
await expect(client.call('wall.getById', {}, auth, { maxRetries: 3 })).rejects.toThrow(VkValidationError);
expect(fetchCount).toBe(1);
});
// --- 3. Official VK API Error Codes ---
it('VK error 5 (user auth) & 28 (app auth) throw VkAuthError without retry', async () => {
mockFetchVkError(5, 'User authorization failed');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError);
mockFetchVkError(28, 'Application authorization failed');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkAuthError);
});
it('VK error 6 (too many req/s), 9 (flood control), 29 (rate limit) throw VkRateLimitError and retry', async () => {
for (const code of [6, 9, 29]) {
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
if (fetchCount < 2) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ error: { error_code: code, error_msg: 'Rate limited' } }),
};
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ response: { ok: true } }),
};
});
const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 });
expect(res.ok).toBe(true);
expect(fetchCount).toBe(2);
}
});
it('VK error 10 (internal server error) throws VkTemporaryError and retries', async () => {
let fetchCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
fetchCount++;
if (fetchCount < 2) {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal error' } }),
};
}
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ response: { ok: true } }),
};
});
const res = await client.call<{ ok: boolean }>('wall.getById', {}, auth, { maxRetries: 2, retryInitialDelayMs: 5 });
expect(res.ok).toBe(true);
expect(fetchCount).toBe(2);
});
it('VK error 7 (permission denied) & 15 (access denied) & 30 (private profile) throw non-retryable errors', async () => {
mockFetchVkError(7, 'Permission denied');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPermissionError);
mockFetchVkError(15, 'Access denied');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError);
mockFetchVkError(30, 'Private profile');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkPrivateResourceError);
});
it('VK error 100 (missing/invalid param) throws VkValidationError without retry', async () => {
mockFetchVkError(100, 'One of the parameters specified was missing or invalid');
await expect(client.call('wall.getById', {}, auth, { maxRetries: 2 })).rejects.toThrow(VkValidationError);
});
// --- 4. Pagination Truncation Safety ---
it('pagination throws VkPaginationLimitError when maxPages is hit before totalCount is loaded', async () => {
const fetchPage = vi.fn(async () => {
return { items: [1, 2], totalCount: 10 };
});
await expect(
fetchPaginatedVk<number>({
pageSize: 2,
maxPages: 2,
fetchPage,
throwOnTruncation: true,
})
).rejects.toThrow(VkPaginationLimitError);
});
// --- 5. Malformed Response & Missing Response ---
it('throws VkValidationError on malformed non-JSON response', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
text: async () => '{ broken_json ',
});
await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError);
});
it('throws VkValidationError on missing response field in VK JSON', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({}),
});
await expect(client.call('wall.getById', {}, auth, { maxRetries: 0 })).rejects.toThrow(VkValidationError);
});
// --- 6. Token Redaction Verification ---
it('verifies token is never exposed in error message or error details', async () => {
const secretToken = 'vk1.a.ultra_secret_token_999999999';
const authSecret = createServiceAuth(secretToken);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
error: {
error_code: 5,
error_msg: 'User authorization failed: invalid access_token.',
request_params: [
{ key: 'oauth', value: '1' },
{ key: 'access_token', value: secretToken },
],
},
}),
});
try {
await client.call('wall.getById', {}, authSecret, { maxRetries: 0 });
expect.unreachable('Should have thrown VkAuthError');
} catch (err: any) {
expect(err.message).not.toContain(secretToken);
const detailsStr = JSON.stringify(err.details);
expect(detailsStr).not.toContain(secretToken);
expect(detailsStr).toContain('[REDACTED]');
}
});
});

View file

@ -8,14 +8,26 @@ import {
VkPrivateResourceError,
VkNotFoundError,
VkValidationError,
VkTemporaryError,
VkNetworkError
} from '../src/integrations/vk/vk-errors';
import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit';
class NoopRateLimiter implements IVkRateLimiter {
async acquire(): Promise<void> {}
reset(): void {}
}
describe('VK API error handling', () => {
const token = 'vk1.a.test-service-token';
let client: VkClient;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
client = new VkClient({
rateLimiter: new NoopRateLimiter(),
defaultTimeoutMs: 100,
});
});
afterEach(() => {
@ -38,29 +50,27 @@ describe('VK API error handling', () => {
it('throws on invalid token (error_code 5)', async () => {
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkAuthError);
});
it('throws on expired token (error_code 5)', async () => {
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkAuthError);
});
it('throws on access denied (error_code 15)', async () => {
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
});
it('throws on rate limit per second (error_code 6)', async () => {
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
// Use VkClient with 0 retries to observe immediate error
const client = new VkClient();
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkRateLimitError);
@ -68,70 +78,70 @@ describe('VK API error handling', () => {
it('throws on daily rate limit (error_code 29)', async () => {
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(VkRateLimitError);
});
it('throws on private profile (error_code 30)', async () => {
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkPrivateResourceError);
});
it('throws on deleted or banned user (error_code 18)', async () => {
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkValidationError);
});
it('throws on deleted post (error_code 100 with post not found semantics)', async () => {
mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(VkValidationError);
});
it('throws when post is not found in response', async () => {
mockFetchJson({ response: { items: [] } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkNotFoundError);
});
it('throws on unavailable community (error_code 203)', async () => {
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
});
it('throws on empty response body', async () => {
mockFetchJson({});
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkValidationError);
});
it('throws on HTTP 500 from VK', async () => {
mockFetchJson('Internal Server Error', 500);
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkTemporaryError);
});
it('throws on network timeout / failure', async () => {
mockFetchNetworkError('fetch failed');
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
});
it('does not leak the service token in thrown error messages', async () => {
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
const provider = new VkProvider(token);
const provider = new VkProvider(token, client);
try {
await provider.fetchPost('https://vk.com/wall-1_1');