@profplum700/etsy-v3-api-client
Version:
JavaScript/TypeScript client for the Etsy Open API v3 with OAuth 2.0 authentication
1,383 lines (1,377 loc) • 154 kB
JavaScript
class EtsyApiError extends Error {
constructor(message, _statusCode, _response, _retryAfter, endpoint) {
super(message);
this._statusCode = _statusCode;
this._response = _response;
this._retryAfter = _retryAfter;
this.name = 'EtsyApiError';
this.endpoint = endpoint;
this.timestamp = new Date();
this.details = {
statusCode: _statusCode || 0,
retryAfter: _retryAfter,
endpoint,
timestamp: this.timestamp
};
if (_response && typeof _response === 'object') {
const resp = _response;
this.details.errorCode = (resp.error_code || resp.code);
this.details.field = resp.field;
this.details.suggestion = (resp.suggestion || resp.message);
if (Array.isArray(resp.errors)) {
this.details.validationErrors = resp.errors.map((err) => {
if (err && typeof err === 'object') {
const e = err;
return {
field: String(e.field || 'unknown'),
message: String(e.message || 'Validation error')
};
}
return { field: 'unknown', message: String(err) };
});
}
}
this.suggestions = this.generateSuggestions();
this.docsUrl = this.generateDocsUrl();
}
get statusCode() {
return this._statusCode;
}
get response() {
return this._response;
}
isRetryable() {
if (!this._statusCode) {
return false;
}
if (this._statusCode === 429) {
return true;
}
if (this._statusCode >= 500 && this._statusCode < 600) {
return true;
}
const retryableClientErrors = [408, 409, 423, 425];
if (retryableClientErrors.includes(this._statusCode)) {
return true;
}
return false;
}
getRetryAfter() {
return this._retryAfter || null;
}
getRateLimitReset() {
if (this._statusCode !== 429 || !this._retryAfter) {
return null;
}
return new Date(Date.now() + this._retryAfter * 1000);
}
generateSuggestions() {
const suggestions = [];
if (!this._statusCode) {
return ['Check your network connection and try again'];
}
switch (this._statusCode) {
case 400:
suggestions.push('Review the Etsy API documentation for this endpoint');
suggestions.push('Check all required parameters are provided');
suggestions.push('Validate parameter formats and types');
if (this.details.validationErrors && this.details.validationErrors.length > 0) {
suggestions.push('\nValidation errors:');
this.details.validationErrors.forEach(err => {
suggestions.push(` • ${err.field}: ${err.message}`);
});
}
if (this.details.field) {
suggestions.push(`Field causing issue: ${this.details.field}`);
}
break;
case 401:
suggestions.push('Verify your access token is valid and not expired');
suggestions.push('Check if you need to refresh the token');
suggestions.push('Ensure you completed the OAuth flow correctly');
if (this.endpoint?.includes('/shops/')) {
suggestions.push('Verify the shop_id matches the authenticated user');
}
break;
case 403:
suggestions.push('Check if your OAuth app has the required scopes:');
if (this.endpoint?.includes('/listings')) {
suggestions.push(' • listings_r (for reading listings)');
suggestions.push(' • listings_w (for creating/updating listings)');
}
if (this.endpoint?.includes('/receipts') || this.endpoint?.includes('/transactions')) {
suggestions.push(' • transactions_r (for reading orders)');
}
if (this.endpoint?.includes('/shops')) {
suggestions.push(' • shops_r (for reading shop data)');
suggestions.push(' • shops_w (for updating shop data)');
}
suggestions.push('Verify your app is approved for production access');
suggestions.push('Check if the resource belongs to the authenticated user');
break;
case 404:
suggestions.push('Verify the resource ID exists and is spelled correctly');
if (this.endpoint?.includes('/listings/')) {
suggestions.push('Check if the listing is active and not deleted');
suggestions.push('Ensure the listing belongs to the authenticated shop');
}
if (this.endpoint?.includes('/shops/')) {
suggestions.push('Verify the shop ID is correct');
}
if (this.endpoint?.includes('/receipts/')) {
suggestions.push('Check if the receipt ID is valid');
suggestions.push('Ensure you have access to this shop\'s receipts');
}
break;
case 409:
suggestions.push('Resource state conflict detected');
suggestions.push('Check if the resource was modified by another process');
suggestions.push('Try fetching the latest resource state before updating');
break;
case 429: {
const resetTime = this.getRateLimitReset();
const resetTimeStr = resetTime
? resetTime.toLocaleTimeString()
: 'shortly';
suggestions.push(`Rate limit exceeded. Resets at ${resetTimeStr}`);
suggestions.push('Implement exponential backoff retry logic');
suggestions.push('Consider caching responses to reduce API calls');
suggestions.push('Check if you can batch multiple operations');
if (this._retryAfter) {
suggestions.push(`Wait ${this._retryAfter} seconds before retrying`);
}
break;
}
case 500:
case 502:
case 503:
case 504:
suggestions.push('This is an Etsy server error, not your code');
suggestions.push('Retry the request after a short delay (exponential backoff)');
suggestions.push('Check Etsy API status: https://status.etsy.com');
if (this.isRetryable()) {
suggestions.push('This error is retryable - the request can be safely retried');
}
break;
default:
suggestions.push('Check the Etsy API documentation for this endpoint');
suggestions.push('Review your request parameters and format');
if (this.details.suggestion) {
suggestions.push(`Etsy suggestion: ${this.details.suggestion}`);
}
}
return suggestions;
}
generateDocsUrl() {
const errorCode = this.details.errorCode?.toLowerCase() || this._statusCode?.toString() || 'unknown';
return `https://github.com/profplum700/etsy-v3-api-client/blob/main/docs/troubleshooting/ERROR_CODES.md#${errorCode}`;
}
getUserFriendlyMessage() {
let message = this.message;
if (this.details.suggestion) {
message += `\nSuggestion: ${this.details.suggestion}`;
}
if (this.isRetryable()) {
const retryAfter = this.getRetryAfter();
if (retryAfter) {
message += `\nRetry after ${retryAfter} seconds.`;
}
else {
message += '\nThis error can be retried.';
}
}
return message;
}
toString() {
const parts = [
`EtsyApiError: ${this.message}`,
`Status Code: ${this._statusCode || 'Unknown'}`,
];
if (this.details.errorCode) {
parts.push(`Error Code: ${this.details.errorCode}`);
}
if (this.endpoint) {
parts.push(`Endpoint: ${this.endpoint}`);
}
parts.push(`Timestamp: ${this.timestamp.toISOString()}`);
if (this.suggestions.length > 0) {
parts.push('\nSuggestions:');
this.suggestions.forEach(s => {
if (!s.startsWith(' •')) {
parts.push(` • ${s}`);
}
else {
parts.push(s);
}
});
}
parts.push(`\nDocumentation: ${this.docsUrl}`);
return parts.join('\n');
}
toJSON() {
return {
name: this.name,
message: this.message,
statusCode: this._statusCode,
errorCode: this.details.errorCode,
endpoint: this.endpoint,
suggestions: this.suggestions,
docsUrl: this.docsUrl,
timestamp: this.timestamp.toISOString(),
details: this.details,
isRetryable: this.isRetryable(),
};
}
}
class EtsyAuthError extends Error {
constructor(message, _code) {
super(message);
this._code = _code;
this.name = 'EtsyAuthError';
}
get code() {
return this._code;
}
}
class EtsyRateLimitError extends Error {
constructor(message, _retryAfter, errorType = 'unknown') {
super(message);
this._retryAfter = _retryAfter;
this.name = 'EtsyRateLimitError';
this.errorType = errorType;
}
get retryAfter() {
return this._retryAfter;
}
isRetryable() {
return this.errorType !== 'qpd_exhausted';
}
}
const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
const isNode = false;
const isWebWorker = typeof globalThis.importScripts === 'function' && typeof navigator !== 'undefined';
const hasFetch = typeof fetch !== 'undefined';
const hasWebCrypto = typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined';
const hasLocalStorage = (() => {
try {
return typeof localStorage !== 'undefined';
}
catch {
return false;
}
})();
const hasSessionStorage = (() => {
try {
return typeof sessionStorage !== 'undefined';
}
catch {
return false;
}
})();
function getEnvironmentInfo() {
return {
isBrowser: isBrowser$1,
isNode: Boolean(isNode),
isWebWorker,
hasFetch,
hasWebCrypto,
hasLocalStorage,
hasSessionStorage,
userAgent: isBrowser$1 ? navigator.userAgent : 'Node.js',
nodeVersion: undefined,
};
}
function assertFetchSupport() {
if (!hasFetch) {
throw new Error('Fetch API is not available. Please use Node.js 18+ or a modern browser.');
}
}
function getAvailableStorage() {
if (hasLocalStorage) {
return 'localStorage';
}
else if (hasSessionStorage) {
return 'sessionStorage';
}
else {
return 'memory';
}
}
class MemoryTokenStorage {
constructor() {
this.tokens = null;
}
async save(tokens) {
this.tokens = { ...tokens };
}
async load() {
return this.tokens ? { ...this.tokens } : null;
}
async clear() {
this.tokens = null;
}
}
class TokenManager {
constructor(config, storage, rotationConfig) {
this.currentTokens = null;
this.keystring = config.keystring;
this.refreshCallback = config.refreshSave;
this.storage = storage;
this.rotationConfig = rotationConfig;
this.currentTokens = {
access_token: config.accessToken,
refresh_token: config.refreshToken,
expires_at: config.expiresAt,
token_type: 'Bearer',
scope: ''
};
if (this.rotationConfig?.enabled && this.rotationConfig?.autoSchedule) {
this.startRotationScheduler();
}
}
async getAccessToken() {
if (!this.currentTokens) {
if (this.storage) {
this.currentTokens = await this.storage.load();
}
if (!this.currentTokens) {
throw new EtsyAuthError('No tokens available', 'NO_TOKENS');
}
}
const now = new Date();
const expiresAt = new Date(this.currentTokens.expires_at);
const bufferTime = 60 * 1000;
if (now.getTime() >= (expiresAt.getTime() - bufferTime)) {
await this.refreshToken();
}
return this.currentTokens.access_token;
}
async refreshToken() {
if (this.refreshPromise) {
return this.refreshPromise;
}
if (!this.currentTokens) {
throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
}
this.refreshPromise = this.performTokenRefresh();
try {
const newTokens = await this.refreshPromise;
this.currentTokens = newTokens;
if (this.storage) {
await this.storage.save(newTokens);
}
if (this.refreshCallback) {
this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
}
return newTokens;
}
finally {
this.refreshPromise = undefined;
}
}
async performTokenRefresh() {
if (!this.currentTokens) {
throw new EtsyAuthError('No tokens available', 'NO_TOKENS');
}
const body = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.keystring,
refresh_token: this.currentTokens.refresh_token
});
try {
const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: body.toString()
});
if (!response.ok) {
throw new EtsyAuthError(`Token refresh failed: ${response.status} ${response.statusText}`, 'TOKEN_REFRESH_FAILED');
}
const tokenResponse = await response.json();
return {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expires_at: new Date(Date.now() + (tokenResponse.expires_in * 1000)),
token_type: tokenResponse.token_type,
scope: tokenResponse.scope
};
}
catch (_error) {
if (_error instanceof EtsyAuthError) {
throw _error;
}
throw new EtsyAuthError(`Token refresh failed: ${_error instanceof Error ? _error.message : 'Unknown error'}`, 'TOKEN_REFRESH_ERROR');
}
}
getCurrentTokens() {
return this.currentTokens ? { ...this.currentTokens } : null;
}
updateTokens(tokens) {
this.currentTokens = { ...tokens };
}
isTokenExpired() {
if (!this.currentTokens) {
return true;
}
const now = new Date();
const expiresAt = new Date(this.currentTokens.expires_at);
return now.getTime() >= expiresAt.getTime();
}
willTokenExpireSoon(minutes = 5) {
if (!this.currentTokens) {
return true;
}
const now = new Date();
const expiresAt = new Date(this.currentTokens.expires_at);
const bufferTime = minutes * 60 * 1000;
return now.getTime() >= (expiresAt.getTime() - bufferTime);
}
async clearTokens() {
this.currentTokens = null;
if (this.storage) {
await this.storage.clear();
}
}
getTimeUntilExpiration() {
if (!this.currentTokens) {
return null;
}
const now = new Date();
const expiresAt = new Date(this.currentTokens.expires_at);
return expiresAt.getTime() - now.getTime();
}
needsProactiveRotation() {
if (!this.rotationConfig?.enabled || !this.currentTokens) {
return false;
}
const timeUntilExpiration = this.getTimeUntilExpiration();
if (timeUntilExpiration === null) {
return false;
}
const rotateBeforeExpiry = this.rotationConfig.rotateBeforeExpiry || 15 * 60 * 1000;
return timeUntilExpiration <= rotateBeforeExpiry;
}
async rotateToken() {
if (!this.currentTokens) {
throw new EtsyAuthError('No tokens available to rotate', 'NO_TOKENS');
}
const oldTokens = { ...this.currentTokens };
const newTokens = await this.refreshToken();
if (this.rotationConfig?.onRotation) {
try {
await Promise.resolve(this.rotationConfig.onRotation(oldTokens, newTokens));
}
catch (error) {
console.error('Token rotation callback failed:', error);
}
}
return newTokens;
}
startRotationScheduler() {
if (!this.rotationConfig?.enabled) {
throw new Error('Token rotation is not enabled');
}
this.stopRotationScheduler();
const checkInterval = this.rotationConfig.checkInterval || 60 * 1000;
this.rotationTimer = setInterval(async () => {
try {
if (this.needsProactiveRotation()) {
console.log('Proactively rotating token before expiration');
await this.rotateToken();
}
}
catch (error) {
console.error('Failed to proactively rotate token:', error);
}
}, checkInterval);
if (this.rotationTimer.unref) {
this.rotationTimer.unref();
}
}
stopRotationScheduler() {
if (this.rotationTimer) {
clearInterval(this.rotationTimer);
this.rotationTimer = undefined;
}
}
updateRotationConfig(config) {
const wasAutoScheduleEnabled = this.rotationConfig?.autoSchedule;
this.rotationConfig = config;
if (config.enabled && config.autoSchedule && !wasAutoScheduleEnabled) {
this.startRotationScheduler();
}
else if (!config.enabled || !config.autoSchedule) {
this.stopRotationScheduler();
}
}
getRotationConfig() {
return this.rotationConfig ? { ...this.rotationConfig } : undefined;
}
async fetch(url, options) {
assertFetchSupport();
return fetch(url, options);
}
}
function createDefaultTokenStorage(options) {
if (options?.preferSession && hasSessionStorage) {
return new SessionStorageTokenStorage(options?.storageKey);
}
else if (hasLocalStorage) {
return new LocalStorageTokenStorage(options?.storageKey);
}
else if (hasSessionStorage) {
return new SessionStorageTokenStorage(options?.storageKey);
}
else {
return new MemoryTokenStorage();
}
}
class LocalStorageTokenStorage {
constructor(storageKey = 'etsy_tokens') {
if (!hasLocalStorage) {
throw new Error('localStorage is not available in this environment');
}
this.storageKey = storageKey;
}
async save(tokens) {
const data = JSON.stringify(tokens);
localStorage.setItem(this.storageKey, data);
}
async load() {
try {
const data = localStorage.getItem(this.storageKey);
if (!data)
return null;
const tokens = JSON.parse(data);
if (tokens.expires_at) {
tokens.expires_at = new Date(tokens.expires_at);
}
return tokens;
}
catch {
return null;
}
}
async clear() {
localStorage.removeItem(this.storageKey);
}
}
class SessionStorageTokenStorage {
constructor(storageKey = 'etsy_tokens') {
if (!hasSessionStorage) {
throw new Error('sessionStorage is not available in this environment');
}
this.storageKey = storageKey;
}
async save(tokens) {
const data = JSON.stringify(tokens);
sessionStorage.setItem(this.storageKey, data);
}
async load() {
try {
const data = sessionStorage.getItem(this.storageKey);
if (!data)
return null;
const tokens = JSON.parse(data);
if (tokens.expires_at) {
tokens.expires_at = new Date(tokens.expires_at);
}
return tokens;
}
catch {
return null;
}
}
async clear() {
sessionStorage.removeItem(this.storageKey);
}
}
class FileTokenStorage {
constructor(filePath) {
{
throw new Error('FileTokenStorage is only available in Node.js environments');
}
}
async save(tokens) {
{
throw new Error('FileTokenStorage is only available in Node.js');
}
}
async load() {
{
return null;
}
}
async clear() {
{
return;
}
}
async _writeFile(filePath, data) {
if (typeof process === 'undefined')
return;
/* writeFile not available in browser */
}
async _setFilePermissions(filePath, mode) {
if (typeof process === 'undefined')
return;
try {
const fs = { promises: { writeFile: () => {}, readFile: () => {}, unlink: () => {} } };
await fs.promises.chmod(filePath, mode);
}
catch {
}
}
async _readFile(filePath) {
if (typeof process === 'undefined')
throw new Error('Not available');
throw new Error('readFile not available in browser');
}
async _deleteFile(filePath) {
if (typeof process === 'undefined')
return;
/* unlink not available in browser */
}
}
const ETSY_RATE_LIMITS = {
MAX_REQUESTS_PER_DAY: 5000,
MAX_REQUESTS_PER_SECOND: 5,
MIN_REQUEST_INTERVAL: 200,
};
class EtsyRateLimiter {
constructor(config) {
this.requestCount = 0;
this.dailyReset = new Date();
this.lastRequestTime = 0;
this.isHeaderBasedLimiting = false;
this.currentRetryCount = 0;
this.config = {
maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
minRequestInterval: ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL,
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 30000,
jitter: 0.1,
qpdWarningThreshold: 80,
onApproachingLimit: undefined,
...config
};
this.setNextDailyReset();
}
setNextDailyReset() {
const now = new Date();
this.dailyReset = new Date(now);
this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
this.dailyReset.setUTCHours(0, 0, 0, 0);
}
updateFromHeaders(headers) {
if (!headers) {
return;
}
const parsed = this.parseRateLimitHeaders(headers);
if (parsed.limitPerSecond !== undefined) {
this.headerLimitPerSecond = parsed.limitPerSecond;
this.isHeaderBasedLimiting = true;
}
if (parsed.remainingThisSecond !== undefined) {
this.headerRemainingThisSecond = parsed.remainingThisSecond;
}
if (parsed.limitPerDay !== undefined) {
this.headerLimitPerDay = parsed.limitPerDay;
this.isHeaderBasedLimiting = true;
}
if (parsed.remainingToday !== undefined) {
this.headerRemainingToday = parsed.remainingToday;
this.checkApproachingLimit();
}
}
parseRateLimitHeaders(headers) {
const getHeader = (name) => {
if (headers instanceof Headers) {
return headers.get(name);
}
const lowerName = name.toLowerCase();
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === lowerName) {
return headers[key] ?? null;
}
}
return null;
};
const parseNumber = (value) => {
if (value === null)
return undefined;
const num = parseInt(value, 10);
return Number.isNaN(num) ? undefined : num;
};
return {
limitPerSecond: parseNumber(getHeader('x-limit-per-second')),
remainingThisSecond: parseNumber(getHeader('x-remaining-this-second')),
limitPerDay: parseNumber(getHeader('x-limit-per-day')),
remainingToday: parseNumber(getHeader('x-remaining-today')),
retryAfter: parseNumber(getHeader('retry-after'))
};
}
checkApproachingLimit() {
if (!this.config.onApproachingLimit)
return;
const limit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
const remaining = this.headerRemainingToday ?? this.getRemainingRequests();
const used = limit - remaining;
const percentageUsed = (used / limit) * 100;
if (percentageUsed >= this.config.qpdWarningThreshold) {
this.config.onApproachingLimit(remaining, limit, percentageUsed);
}
}
async handleRateLimitResponse(headers) {
const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
this.updateFromHeaders(headers);
if (this.headerRemainingToday === 0) {
throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
}
this.currentRetryCount++;
if (this.currentRetryCount > this.config.maxRetries) {
this.currentRetryCount = 0;
throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
}
const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
return { shouldRetry: true, delayMs };
}
calculateBackoffDelay(attempt, retryAfterSeconds) {
const serverSuggestedMs = retryAfterSeconds ? retryAfterSeconds * 1000 : 0;
const exponentialDelay = this.config.baseDelayMs * Math.pow(2, attempt - 1);
let delay = Math.min(exponentialDelay, this.config.maxDelayMs);
delay = Math.max(delay, serverSuggestedMs);
if (this.config.jitter > 0) {
const jitterAmount = delay * this.config.jitter;
const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
delay += randomJitter;
}
return Math.max(0, Math.floor(delay));
}
resetRetryCount() {
this.currentRetryCount = 0;
}
setApproachingLimitCallback(callback) {
this.config.onApproachingLimit = callback;
}
setWarningThreshold(threshold) {
this.config.qpdWarningThreshold = threshold;
}
getEffectiveMinInterval() {
if (this.headerLimitPerSecond !== undefined && this.headerLimitPerSecond > 0) {
return Math.ceil(1000 / this.headerLimitPerSecond);
}
return this.config.minRequestInterval;
}
async waitForRateLimit() {
const now = Date.now();
if (now >= this.dailyReset.getTime()) {
this.requestCount = 0;
this.setNextDailyReset();
}
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
const effectiveRemaining = this.headerRemainingToday ??
(effectiveDailyLimit - this.requestCount);
if (effectiveRemaining <= 0) {
const timeUntilReset = this.dailyReset.getTime() - now;
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
`Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
}
const minInterval = this.getEffectiveMinInterval();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < minInterval) {
const waitTime = minInterval - timeSinceLastRequest;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requestCount++;
this.lastRequestTime = Date.now();
}
getRateLimitStatus() {
const now = Date.now();
if (now >= this.dailyReset.getTime()) {
this.requestCount = 0;
this.setNextDailyReset();
}
const effectiveRemaining = this.headerRemainingToday ??
Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
return {
remainingRequests: effectiveRemaining,
resetTime: this.dailyReset,
canMakeRequest: effectiveRemaining > 0 &&
(now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
isFromHeaders: this.isHeaderBasedLimiting,
limitPerSecond: this.headerLimitPerSecond,
remainingThisSecond: this.headerRemainingThisSecond,
limitPerDay: this.headerLimitPerDay
};
}
getRemainingRequests() {
return this.headerRemainingToday ??
Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
}
reset() {
this.requestCount = 0;
this.lastRequestTime = 0;
this.currentRetryCount = 0;
this.headerLimitPerSecond = undefined;
this.headerRemainingThisSecond = undefined;
this.headerLimitPerDay = undefined;
this.headerRemainingToday = undefined;
this.isHeaderBasedLimiting = false;
this.setNextDailyReset();
}
canMakeRequest() {
const now = Date.now();
if (now >= this.dailyReset.getTime()) {
this.requestCount = 0;
this.setNextDailyReset();
}
const effectiveRemaining = this.headerRemainingToday ??
(this.config.maxRequestsPerDay - this.requestCount);
if (effectiveRemaining <= 0) {
return false;
}
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
}
getTimeUntilNextRequest() {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
const minInterval = this.getEffectiveMinInterval();
if (timeSinceLastRequest >= minInterval) {
return 0;
}
return minInterval - timeSinceLastRequest;
}
getConfig() {
return { ...this.config };
}
}
const defaultRateLimiter = new EtsyRateLimiter();
class BulkOperationManager {
constructor(config = {}) {
this.concurrency = config.concurrency ?? 5;
this.stopOnError = config.stopOnError ?? false;
this.onProgress = config.onProgress;
this.onItemComplete = config.onItemComplete;
this.onItemError = config.onItemError;
}
async executeBulk(items, operation, getId) {
const startTime = Date.now();
const results = [];
const errors = [];
let completed = 0;
let successful = 0;
let failed = 0;
const queue = items.map((item, index) => ({
item,
index,
id: getId ? getId(item, index) : index
}));
const workers = [];
for (let i = 0; i < Math.min(this.concurrency, items.length); i++) {
workers.push(this.worker(queue, operation, results, errors, () => {
completed++;
const lastResult = results[results.length - 1];
if (lastResult?.success) {
successful++;
}
else {
failed++;
}
if (this.onProgress) {
this.onProgress(completed, items.length, lastResult);
}
}));
}
await Promise.all(workers);
const duration = Date.now() - startTime;
return {
total: items.length,
successful,
failed,
results,
errors,
duration
};
}
async worker(queue, operation, results, errors, onComplete) {
while (queue.length > 0) {
const work = queue.shift();
if (!work)
break;
const { item, index, id } = work;
try {
const data = await operation(item, index);
const result = {
success: true,
data,
id,
index
};
results.push(result);
if (this.onItemComplete) {
this.onItemComplete(result);
}
onComplete();
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const result = {
success: false,
error: err,
id,
index
};
results.push(result);
const bulkError = {
id,
index,
error: err
};
errors.push(bulkError);
if (this.onItemError) {
this.onItemError(bulkError);
}
onComplete();
if (this.stopOnError) {
queue.length = 0;
break;
}
}
}
}
setConcurrency(concurrency) {
this.concurrency = Math.max(1, concurrency);
}
getConcurrency() {
return this.concurrency;
}
}
function createBulkOperationManager(config) {
return new BulkOperationManager(config);
}
async function executeBulkOperation(items, operation, config) {
const manager = new BulkOperationManager(config);
return manager.executeBulk(items, operation);
}
class Validator {
constructor() {
this.validators = [];
}
rule(validator) {
this.validators.push(validator);
return this;
}
validate(data) {
const errors = [];
for (const validator of this.validators) {
const error = validator(data);
if (error) {
errors.push(error);
}
}
return {
valid: errors.length === 0,
errors
};
}
}
class FieldValidator {
constructor(field) {
this.field = field;
}
required(message) {
return (data) => {
if (typeof data !== 'object' || data === null)
return null;
const value = data[this.field];
if (value === undefined || value === null || value === '') {
return {
field: this.field,
message: message || `${this.field} is required`,
value
};
}
return null;
};
}
string(options) {
return (data) => {
if (typeof data !== 'object' || data === null)
return null;
const value = data[this.field];
if (value === undefined || value === null)
return null;
if (typeof value !== 'string') {
return {
field: this.field,
message: options.message || `${this.field} must be a string`,
value
};
}
if (options.min !== undefined && value.length < options.min) {
return {
field: this.field,
message: options.message || `${this.field} must be at least ${options.min} characters`,
value
};
}
if (options.max !== undefined && value.length > options.max) {
return {
field: this.field,
message: options.message || `${this.field} must be at most ${options.max} characters`,
value
};
}
if (options.pattern && !options.pattern.test(value)) {
return {
field: this.field,
message: options.message || `${this.field} has invalid format`,
value
};
}
return null;
};
}
number(options) {
return (data) => {
if (typeof data !== 'object' || data === null)
return null;
const value = data[this.field];
if (value === undefined || value === null)
return null;
if (typeof value !== 'number' || Number.isNaN(value)) {
return {
field: this.field,
message: options.message || `${this.field} must be a number`,
value
};
}
if (options.integer && !Number.isInteger(value)) {
return {
field: this.field,
message: options.message || `${this.field} must be an integer`,
value
};
}
if (options.positive && value <= 0) {
return {
field: this.field,
message: options.message || `${this.field} must be positive`,
value
};
}
if (options.min !== undefined && value < options.min) {
return {
field: this.field,
message: options.message || `${this.field} must be at least ${options.min}`,
value
};
}
if (options.max !== undefined && value > options.max) {
return {
field: this.field,
message: options.message || `${this.field} must be at most ${options.max}`,
value
};
}
return null;
};
}
enum(allowedValues, message) {
return (data) => {
if (typeof data !== 'object' || data === null)
return null;
const value = data[this.field];
if (value === undefined || value === null)
return null;
if (!allowedValues.includes(value)) {
return {
field: this.field,
message: message || `${this.field} must be one of: ${allowedValues.join(', ')}`,
value
};
}
return null;
};
}
array(options) {
return (data) => {
if (typeof data !== 'object' || data === null)
return null;
const value = data[this.field];
if (value === undefined || value === null)
return null;
if (!Array.isArray(value)) {
return {
field: this.field,
message: options.message || `${this.field} must be an array`,
value
};
}
if (options.min !== undefined && value.length < options.min) {
return {
field: this.field,
message: options.message || `${this.field} must have at least ${options.min} items`,
value
};
}
if (options.max !== undefined && value.length > options.max) {
return {
field: this.field,
message: options.message || `${this.field} must have at most ${options.max} items`,
value
};
}
if (options.itemValidator) {
for (let i = 0; i < value.length; i++) {
if (!options.itemValidator(value[i])) {
return {
field: this.field,
message: options.message || `${this.field}[${i}] has invalid value`,
value: value[i]
};
}
}
}
return null;
};
}
}
function field(fieldName) {
return new FieldValidator(fieldName);
}
const CreateListingSchema = new Validator()
.rule(field('quantity').required())
.rule(field('quantity').number({ min: 1, integer: true, message: 'quantity must be a positive integer' }))
.rule(field('title').required())
.rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
.rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
.rule(field('price').required())
.rule(field('price').number({ min: 0.2, max: 50000, message: 'price must be between 0.20 and 50000.00' }))
.rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
.rule(field('when_made').enum([
'made_to_order',
'2020_2024',
'2010_2019',
'2005_2009',
'2000_2004',
'1990s',
'1980s',
'1970s',
'1960s',
'1950s',
'1940s',
'1930s',
'1920s',
'1910s',
'1900s',
'1800s',
'1700s',
'before_1700'
]))
.rule(field('taxonomy_id').required())
.rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
const UpdateListingSchema = new Validator()
.rule((data) => {
if (typeof data === 'object' && data !== null && 'title' in data && data.title !== undefined) {
return field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' })(data);
}
return null;
})
.rule((data) => {
if (typeof data === 'object' && data !== null && 'description' in data && data.description !== undefined) {
return field('description').string({ max: 65535, message: 'description must be less than 65535 characters' })(data);
}
return null;
})
.rule((data) => {
if (data.tags !== undefined) {
return field('tags').array({ max: 13, message: 'tags must have at most 13 items' })(data);
}
return null;
})
.rule((data) => {
if (data.materials !== undefined) {
return field('materials').array({ max: 13, message: 'materials must have at most 13 items' })(data);
}
return null;
});
const UpdateShopSchema = new Validator()
.rule((data) => {
if (typeof data === 'object' && data !== null && 'title' in data && data.title !== undefined) {
return field('title').string({ min: 1, max: 55, message: 'shop title must be 1-55 characters' })(data);
}
return null;
})
.rule((data) => {
if (typeof data === 'object' && data !== null && 'announcement' in data && data.announcement !== undefined) {
return field('announcement').string({ max: 5000, message: 'announcement must be less than 5000 characters' })(data);
}
return null;
});
class ValidationException extends Error {
constructor(message, errors) {
super(message);
this.name = 'ValidationException';
this.errors = errors;
}
}
function validateOrThrow(data, schema, errorMessage = 'Validation failed') {
const result = schema.validate(data);
if (!result.valid) {
throw new ValidationException(errorMessage, result.errors);
}
}
function validate(data, schema) {
return schema.validate(data);
}
function createValidator() {
return new Validator();
}
function combineValidators(...validators) {
return {
validate(data) {
const allErrors = [];
for (const validator of validators) {
const result = validator.validate(data);
if (!result.valid) {
allErrors.push(...result.errors);
}
}
return {
valid: allErrors.length === 0,
errors: allErrors
};
}
};
}
class DefaultLogger {
debug(message, ...args) {
let isDevelopment;
{
const host = window.location?.hostname;
isDevelopment = host === 'localhost' || host === '127.0.0.1';
}
if (isDevelopment) {
console.log(`[DEBUG] ${message}`, ...args);
}
}
info(message, ...args) {
console.log(`[INFO] ${message}`, ...args);
}
warn(message, ...args) {
console.warn(`[WARN] ${message}`, ...args);
}
error(message, ...args) {
console.error(`[ERROR] ${message}`, ...args);
}
}
class MemoryCache {
constructor() {
this.cache = new Map();
}
async get(key) {
const entry = this.cache.get(key);
if (!entry)
return null;
if (Date.now() > entry.expires) {
this.cache.delete(key);
return null;
}
return entry.value;
}
async set(key, value, ttl = 3600) {
const expires = Date.now() + (ttl * 1000);
this.cache.set(key, { value, expires });
}
async delete(key) {
this.cache.delete(key);
}
async clear() {
this.cache.clear();
}
}
class EtsyClient {
constructor(config) {
if (!config.sharedSecret) {
throw new EtsyAuthError('sharedSecret is REQUIRED for Etsy API v3 application usage. See: https://github.com/profplum700/etsy-v3-api-client/issues/21');
}
this.tokenManager = new TokenManager(config);
this.baseUrl = config.baseUrl || 'https://api.etsy.com/v3/application';
this.logger = new DefaultLogger();
this.keystring = config.keystring;
this.sharedSecret = config.sharedSecret;
if (config.rateLimiting?.enabled !== false) {
this.rateLimiter = new EtsyRateLimiter({
maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
minRequestInterval: config.rateLimiting?.minRequestInterval ?? ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL,
maxRetries: config.rateLimiting?.maxRetries,
baseDelayMs: config.rateLimiting?.baseDelayMs,
maxDelayMs: config.rateLimiting?.maxDelayMs,
jitter: config.rateLimiting?.jitter,
qpdWarningThreshold: config.rateLimiting?.qpdWarningThreshold,
onApproachingLimit: config.rateLimiting?.onApproachingLimit
});
}
else {
this.rateLimiter = new EtsyRateLimiter(undefined);
}
if (config.caching?.enabled !== false) {
this.cache = config.caching?.storage || new MemoryCache();
this.cacheTtl = config.caching?.ttl || 3600;
}
this.bulkOperationManager = new BulkOperationManager({
concurrency: 5,
stopOnError: false
});
}
async makeRequest(endpoint, options = {}, useCache = true) {
const url = `${this.baseUrl}${endpoint}`;
const requestOptions = {
method: 'GET',
...options
};
const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
if (useCache && this.cache && requestOptions.method === 'GET') {
const cached = await this.cache.get(cacheKey);
if (cached) {
try {
return JSON.parse(cached);
}
catch {
await this.cache.delete(cacheKey);
}
}
}
await this.rateLimiter.waitForRateLimit();
const accessToken = await this.tokenManager.getAccessToken();
const headers = {
'Authorization': `Bearer ${accessToken}`,
'x-api-key': this.getApiKey(),
'Content-Type': 'application/json',
'Accept': 'application/json',
...requestOptions.headers
};
return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
}
async executeWithRetry(url, options, cacheKey, useCache) {
while (true) {
try {
const response = await this.fetch(url, options);
if (response.status === 429) {
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
if (shouldRetry) {
this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
await this.sleep(delayMs);
continue;
}
}
if (!response.ok) {
const errorText = await respons