Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions migrations/1679941023060_add-invalid-job-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate';

export const shorthands: ColumnDefinitions | undefined = undefined;

export function up(pgm: MigrationBuilder): void {
pgm.addTypeValue('job_status', 'invalid');
}

export function down(pgm: MigrationBuilder): void {}
36 changes: 33 additions & 3 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ export const TokenNotFoundResponse = Type.Object(
{ title: 'Token Not Found Response' }
);

export const ContractNotFoundResponse = Type.Object(
{
error: Type.Literal('Contract not found'),
},
{ title: 'Contract Not Found Response' }
);

export const TokenNotProcessedResponse = Type.Object(
{
error: Type.Literal('Token metadata fetch in progress'),
Expand All @@ -172,9 +179,32 @@ export const TokenLocaleNotFoundResponse = Type.Object(
{ title: 'Locale Not Found Response' }
);

export const TokenErrorResponse = Type.Union(
[TokenNotProcessedResponse, TokenLocaleNotFoundResponse],
{ title: 'Token Error Response' }
export const InvalidTokenContractResponse = Type.Object(
{
error: Type.Literal('Token contract is invalid or does not conform to its token standard'),
},
{ title: 'Invalid Token Contract Response' }
);

export const InvalidTokenMetadataResponse = Type.Object(
{
error: Type.Literal('Token metadata is unreachable or does not conform to SIP-016'),
},
{ title: 'Invalid Token Metadata Response' }
);

export const NotFoundResponse = Type.Union([TokenNotFoundResponse, ContractNotFoundResponse], {
title: 'Not Found Error Response',
});

export const ErrorResponse = Type.Union(
[
TokenNotProcessedResponse,
TokenLocaleNotFoundResponse,
InvalidTokenContractResponse,
InvalidTokenMetadataResponse,
],
{ title: 'Error Response' }
);

export const FtMetadataResponse = Type.Object(
Expand Down
19 changes: 16 additions & 3 deletions src/api/util/errors.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
import { Value } from '@sinclair/typebox/value';
import { FastifyReply } from 'fastify';
import {
TokenErrorResponse,
InvalidTokenContractResponse,
InvalidTokenMetadataResponse,
ErrorResponse,
TokenLocaleNotFoundResponse,
TokenNotFoundResponse,
TokenNotProcessedResponse,
NotFoundResponse,
ContractNotFoundResponse,
} from '../schemas';
import {
ContractNotFoundError,
InvalidContractError,
InvalidTokenError,
TokenLocaleNotFoundError,
TokenNotFoundError,
TokenNotProcessedError,
} from '../../pg/errors';
import { setReplyNonCacheable } from './cache';

export const TokenErrorResponseSchema = {
404: TokenNotFoundResponse,
422: TokenErrorResponse,
404: NotFoundResponse,
422: ErrorResponse,
};

export async function generateTokenErrorResponse(error: any, reply: FastifyReply) {
setReplyNonCacheable(reply);
if (error instanceof TokenNotFoundError) {
await reply.code(404).send(Value.Create(TokenNotFoundResponse));
} else if (error instanceof ContractNotFoundError) {
await reply.code(404).send(Value.Create(ContractNotFoundResponse));
} else if (error instanceof TokenNotProcessedError) {
await reply.code(422).send(Value.Create(TokenNotProcessedResponse));
} else if (error instanceof TokenLocaleNotFoundError) {
await reply.code(422).send(Value.Create(TokenLocaleNotFoundResponse));
} else if (error instanceof InvalidContractError) {
await reply.code(422).send(Value.Create(InvalidTokenContractResponse));
} else if (error instanceof InvalidTokenError) {
await reply.code(422).send(Value.Create(InvalidTokenMetadataResponse));
} else {
throw error;
}
Expand Down
21 changes: 21 additions & 0 deletions src/pg/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ export class TokenNotFoundError extends Error {
}
}

export class ContractNotFoundError extends Error {
constructor() {
super();
this.name = this.constructor.name;
}
}

export class TokenNotProcessedError extends Error {
constructor() {
super();
Expand All @@ -18,3 +25,17 @@ export class TokenLocaleNotFoundError extends Error {
this.name = this.constructor.name;
}
}

export class InvalidContractError extends Error {
constructor() {
super();
this.name = this.constructor.name;
}
}

export class InvalidTokenError extends Error {
constructor() {
super();
this.name = this.constructor.name;
}
}
51 changes: 43 additions & 8 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ import {
} from './types';
import { connectPostgres } from './postgres-tools';
import { BasePgStore } from './postgres-tools/base-pg-store';
import { TokenLocaleNotFoundError, TokenNotFoundError, TokenNotProcessedError } from './errors';
import {
ContractNotFoundError,
InvalidContractError,
InvalidTokenError,
TokenLocaleNotFoundError,
TokenNotFoundError,
TokenNotProcessedError,
} from './errors';
import { runMigrations } from './migrations';

/**
Expand Down Expand Up @@ -138,6 +145,20 @@ export class PgStore extends BasePgStore {
locale?: string;
}): Promise<DbTokenMetadataLocaleBundle> {
return await this.sqlTransaction(async sql => {
// Is the contract invalid?
const contractJobStatus = await sql<{ status: DbJobStatus }[]>`
SELECT status
FROM jobs
INNER JOIN smart_contracts ON jobs.smart_contract_id = smart_contracts.id
WHERE smart_contracts.principal = ${args.contractPrincipal}
`;
if (contractJobStatus.count === 0) {
throw new ContractNotFoundError();
}
if (contractJobStatus[0].status === DbJobStatus.invalid) {
throw new InvalidContractError();
}
// Get token id
const tokenIdRes = await sql<{ id: number }[]>`
SELECT tokens.id
FROM tokens
Expand All @@ -148,11 +169,14 @@ export class PgStore extends BasePgStore {
if (tokenIdRes.count === 0) {
throw new TokenNotFoundError();
}
if (args.locale && !(await this.isTokenLocaleAvailable(tokenIdRes[0].id, args.locale))) {
const tokenId = tokenIdRes[0].id;
// Is the locale valid?
if (args.locale && !(await this.isTokenLocaleAvailable(tokenId, args.locale))) {
throw new TokenLocaleNotFoundError();
}
// Get metadata
return await this.getTokenMetadataBundleInternal(
tokenIdRes[0].id,
tokenId,
args.contractPrincipal,
args.locale
);
Expand Down Expand Up @@ -449,16 +473,27 @@ export class PgStore extends BasePgStore {
smartContractPrincipal: string,
locale?: string
): Promise<DbTokenMetadataLocaleBundle> {
const tokenRes = await this.sql<DbToken[]>`
SELECT ${this.sql(TOKENS_COLUMNS)} FROM tokens WHERE id = ${tokenId}
// Is token invalid?
const tokenJobStatus = await this.sql<{ status: string }[]>`
SELECT status FROM jobs WHERE token_id = ${tokenId}
`;
if (tokenRes.count === 0) {
if (tokenJobStatus.count === 0) {
throw new TokenNotFoundError();
}
const status = tokenJobStatus[0].status;
if (status === DbJobStatus.invalid) {
throw new InvalidTokenError();
}
// Get token
const tokenRes = await this.sql<DbToken[]>`
SELECT ${this.sql(TOKENS_COLUMNS)} FROM tokens WHERE id = ${tokenId}
`;
const token = tokenRes[0];
if (!token.updated_at) {
// Is it still waiting to be processed?
if (!token.updated_at && (status === DbJobStatus.queued || status === DbJobStatus.pending)) {
throw new TokenNotProcessedError();
}
// Get metadata
let localeBundle: DbMetadataLocaleBundle | undefined;
const metadataRes = await this.sql<DbMetadata[]>`
SELECT ${this.sql(METADATA_COLUMNS)} FROM metadata
Expand All @@ -484,7 +519,7 @@ export class PgStore extends BasePgStore {
}
const smartContract = await this.getSmartContract({ principal: smartContractPrincipal });
if (!smartContract) {
throw new TokenNotFoundError();
throw new ContractNotFoundError();
}
return {
token,
Expand Down
1 change: 1 addition & 0 deletions src/pg/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum DbJobStatus {
queued = 'queued',
done = 'done',
failed = 'failed',
invalid = 'invalid',
}

export enum DbTokenType {
Expand Down
19 changes: 9 additions & 10 deletions src/token-processor/queue/job/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { logger } from '../../../logger';
import { PgStore } from '../../../pg/pg-store';
import { stopwatch } from '../../../pg/postgres-tools/helpers';
import { DbJob, DbJobStatus } from '../../../pg/types';
import { UserError } from '../../util/errors';
import { RetryableJobError } from '../errors';
import { getJobQueueProcessingMode, JobQueueProcessingMode } from '../helpers';

Expand Down Expand Up @@ -34,8 +35,7 @@ export abstract class Job {
* shouldn't be overridden.
*/
async work(): Promise<void> {
let processingFinished = false;
let finishedWithError = false;
let status: DbJobStatus | undefined;
const sw = stopwatch();

// This block will catch any and all errors that are generated while processing the job. Each of
Expand All @@ -44,7 +44,7 @@ export abstract class Job {
// `processed = true` so it can be picked up by the queue at a later time.
try {
await this.handler();
processingFinished = true;
status = DbJobStatus.done;
} catch (error) {
if (error instanceof RetryableJobError) {
const retries = await this.db.increaseJobRetryCount({ id: this.job.id });
Expand All @@ -59,18 +59,17 @@ export abstract class Job {
await this.updateStatus(DbJobStatus.pending);
} else {
logger.warn(error, `Job ${this.description()} max retries reached, giving up`);
processingFinished = true;
finishedWithError = true;
status = DbJobStatus.failed;
}
} else if (error instanceof UserError) {
logger.error(error, `User error on Job ${this.description()}`);
status = DbJobStatus.invalid;
} else {
// Something more serious happened, mark this token as failed.
logger.error(error, `Job ${this.description()}`);
processingFinished = true;
finishedWithError = true;
status = DbJobStatus.failed;
}
} finally {
if (processingFinished) {
const status = finishedWithError ? DbJobStatus.failed : DbJobStatus.done;
if (status) {
if (await this.updateStatus(status)) {
logger.info(`Job ${this.description()} ${status} in ${sw.getElapsed()}ms`);
}
Expand Down
10 changes: 5 additions & 5 deletions src/token-processor/stacks-node/stacks-node-rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { request, errors } from 'undici';
import { ENV } from '../../env';
import { RetryableJobError } from '../queue/errors';
import { HttpError, JsonParseError } from '../util/errors';
import { StacksNodeClarityError, HttpError, StacksNodeJsonParseError } from '../util/errors';

interface ReadOnlyContractCallSuccessResponse {
okay: true;
Expand Down Expand Up @@ -80,7 +80,7 @@ export class StacksNodeRpcClient {
try {
return JSON.parse(text) as ReadOnlyContractCallResponse;
} catch (error) {
throw new JsonParseError(`JSON parse error ${url}: ${text}`);
throw new StacksNodeJsonParseError(`JSON parse error ${url}: ${text}`);
}
} catch (error) {
if (error instanceof errors.UndiciError) {
Expand All @@ -107,7 +107,7 @@ export class StacksNodeRpcClient {
`Runtime error while calling read-only function ${functionName}`
);
}
throw new Error(`Read-only error ${functionName}: ${result.cause}`);
throw new StacksNodeClarityError(`Read-only error ${functionName}: ${result.cause}`);
}
return decodeClarityValue(result.result);
}
Expand All @@ -128,7 +128,7 @@ export class StacksNodeRpcClient {
if (unwrappedClarityValue.type_id === ClarityTypeID.UInt) {
return unwrappedClarityValue;
}
throw new Error(
throw new StacksNodeClarityError(
`Unexpected Clarity type '${unwrappedClarityValue.type_id}' while unwrapping uint`
);
}
Expand All @@ -143,7 +143,7 @@ export class StacksNodeRpcClient {
} else if (unwrappedClarityValue.type_id === ClarityTypeID.OptionalNone) {
return undefined;
}
throw new Error(
throw new StacksNodeClarityError(
`Unexpected Clarity type '${unwrappedClarityValue.type_id}' while unwrapping string`
);
}
Expand Down
19 changes: 15 additions & 4 deletions src/token-processor/util/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { errors } from 'undici';
import { parseRetryAfterResponseHeader } from './helpers';

/** Tags an error as a user error i.e. caused by a bad contract, incorrect SIP-016 metadata, etc. */
export class UserError extends Error {}

/** Thrown when fetching metadata exceeds the max allowed byte size */
export class MetadataSizeExceededError extends Error {
export class MetadataSizeExceededError extends UserError {
constructor(message: string) {
super();
this.message = message;
Expand All @@ -11,7 +14,7 @@ export class MetadataSizeExceededError extends Error {
}

/** Thrown when fetching metadata exceeds the max allowed timeout */
export class MetadataTimeoutError extends Error {
export class MetadataTimeoutError extends UserError {
constructor(message: string) {
super();
this.message = message;
Expand All @@ -20,7 +23,15 @@ export class MetadataTimeoutError extends Error {
}

/** Thrown when there is a parse error that prevented metadata processing */
export class MetadataParseError extends Error {
export class MetadataParseError extends UserError {
constructor(message: string) {
super();
this.message = message;
this.name = this.constructor.name;
}
}

export class StacksNodeClarityError extends UserError {
constructor(message: string) {
super();
this.message = message;
Expand Down Expand Up @@ -51,7 +62,7 @@ export class TooManyRequestsHttpError extends HttpError {
}
}

export class JsonParseError extends Error {
export class StacksNodeJsonParseError extends Error {
constructor(message: string) {
super();
this.message = message;
Expand Down
Loading