tokolaku-sdk 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/index.cjs +9 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,11 +90,13 @@ The SDK retries automatically (`maxRetries`, default `2`) using exponential back
|
|
|
90
90
|
| `5xx` server error | Retried | **Not** retried |
|
|
91
91
|
| Timeout (`code: "timeout"`) | **Not** retried | **Not** retried |
|
|
92
92
|
| `2xx` with malformed JSON body (`code: "invalid_response"`) | **Not** retried | **Not** retried |
|
|
93
|
+
| `2xx` where the body stream fails mid-read (`code: "response_read_error"`) | **Not** retried | **Not** retried |
|
|
93
94
|
|
|
94
95
|
- `botReply` has no side effect if it fails, so it retries on `429`, any `5xx`, and network errors.
|
|
95
|
-
- **`messages.send` TIDAK di-retry pada timeout/5xx karena pesan mungkin sudah terkirim** — the message may already have been sent and charged even though the client never saw a successful response, and the API does not yet expose an idempotency key. It only retries on `429` and network errors (no
|
|
96
|
+
- **`messages.send` TIDAK di-retry pada timeout/5xx karena pesan mungkin sudah terkirim** — the message may already have been sent and charged even though the client never saw a successful response, and the API does not yet expose an idempotency key. It only retries on `429` and network errors — a network retry only applies when `fetch` itself rejected before any response headers arrived (no response headers were ever received, so nothing could have been sent). Once response headers have arrived, a failure reading the body is a `response_read_error`, not a network error, and is never retried.
|
|
96
97
|
- A timeout (`code: "timeout"`) is never retried on either endpoint, since it's ambiguous whether the server received/processed the request.
|
|
97
|
-
- A `2xx` response with a body that fails to parse as JSON (`code: "invalid_response"
|
|
98
|
+
- A `2xx` response with a body that fails to parse as JSON (`code: "invalid_response"`) carries the actual 2xx status the server returned (usually `200`) and is never retried on either endpoint — the request already reached the server and had its side effect (reply generated / message sent and charged); retrying would risk a double-send or burning AI quota for nothing.
|
|
99
|
+
- A `2xx` response whose body stream errors mid-read (`code: "response_read_error"`, e.g. the connection resets after headers arrive) is likewise never retried, for the same reason: response headers arriving means the request already reached the server and may have had its side effect, even though the body was never fully read.
|
|
98
100
|
|
|
99
101
|
## Webhooks
|
|
100
102
|
|
package/dist/index.cjs
CHANGED
|
@@ -162,7 +162,15 @@ var Tokolaku = class {
|
|
|
162
162
|
body: JSON.stringify(body),
|
|
163
163
|
signal: controller.signal
|
|
164
164
|
});
|
|
165
|
-
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = await res.text();
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
170
|
+
throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: "timeout" });
|
|
171
|
+
}
|
|
172
|
+
throw new TokolakuAPIError("Gagal membaca body respons", { status: res.status, code: "response_read_error" });
|
|
173
|
+
}
|
|
166
174
|
if (!res.ok) {
|
|
167
175
|
const ra = res.headers.get("retry-after");
|
|
168
176
|
onRetryAfter(ra != null && /^\d+$/.test(ra) ? Number(ra) : null);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/retry.ts","../src/client.ts"],"sourcesContent":["// src/index.ts\nexport { Tokolaku } from \"./client.js\";\nexport { Tokolaku as default } from \"./client.js\";\nexport {\n TokolakuAPIError,\n TokolakuAuthenticationError,\n TokolakuInsufficientBalanceError,\n TokolakuPermissionError,\n TokolakuRateLimitError,\n TokolakuValidationError,\n TokolakuWebhookSignatureError,\n} from \"./errors.js\";\nexport type {\n TokolakuOptions,\n BotReplyParams, BotReplyResponse,\n SendTextParams, SendTemplateParams, SendMessageParams, SendMessageResponse,\n} from \"./types.js\";\n","/** Base error semua kegagalan API. `status` null = kegagalan sebelum ada\n * respons HTTP (network/timeout). `code` = kode envelope BE, mis.\n * \"insufficient_balance\"; null bila body bukan JSON envelope. */\nexport class TokolakuAPIError extends Error {\n readonly status: number | null;\n readonly code: string | null;\n constructor(message: string, opts: { status: number | null; code: string | null }) {\n super(message);\n this.name = new.target.name;\n this.status = opts.status;\n this.code = opts.code;\n }\n}\n\nexport class TokolakuAuthenticationError extends TokolakuAPIError {} // 401\nexport class TokolakuInsufficientBalanceError extends TokolakuAPIError {} // 402\nexport class TokolakuPermissionError extends TokolakuAPIError {} // 403\nexport class TokolakuRateLimitError extends TokolakuAPIError {} // 429\nexport class TokolakuValidationError extends TokolakuAPIError {} // 400/422 + validasi klien\n\nexport class TokolakuWebhookSignatureError extends Error {\n constructor(message = \"Signature webhook tidak valid\") {\n super(message);\n this.name = \"TokolakuWebhookSignatureError\";\n }\n}\n\nconst STATUS_CLASS: Record<number, new (m: string, o: { status: number | null; code: string | null }) => TokolakuAPIError> = {\n 400: TokolakuValidationError,\n 401: TokolakuAuthenticationError,\n 402: TokolakuInsufficientBalanceError,\n 403: TokolakuPermissionError,\n 422: TokolakuValidationError,\n 429: TokolakuRateLimitError,\n};\n\n/** Terjemahkan respons non-2xx jadi error class. Envelope BE:\n * `{ error: { code, message } }`. Body non-JSON dipotong 500 char. */\nexport function mapResponseError(status: number, bodyText: string): TokolakuAPIError {\n let code: string | null = null;\n let message = bodyText ? bodyText.slice(0, 500) : `HTTP ${status}`;\n try {\n const parsed = JSON.parse(bodyText) as { error?: { code?: string; message?: string } };\n if (parsed?.error) {\n code = parsed.error.code ?? null;\n message = parsed.error.message ?? message;\n }\n } catch {\n /* non-JSON — pakai default */\n }\n const Cls = STATUS_CLASS[status] ?? TokolakuAPIError;\n return new Cls(message, { status, code });\n}\n","import { TokolakuAPIError } from \"./errors.js\";\n\nexport type RetryPolicy = \"botReply\" | \"messages\";\n\n/** Retry uang-sadar:\n * - botReply: 429, 5xx, network error (tanpa efek samping bila gagal).\n * - messages: HANYA 429 + network TypeError (pesan mungkin sudah terkirim\n * & tercharge pada timeout/5xx — API belum punya idempotency key).\n * - timeout (code \"timeout\") TIDAK pernah di-retry. */\nexport function shouldRetry(policy: RetryPolicy, error: TokolakuAPIError): boolean {\n if (error.code === \"timeout\") return false;\n if (error.status === 429) return true;\n // \"invalid_response\" (200 OK tapi body JSON rusak) SENGAJA tidak match rule apa pun di\n // bawah ini — efek samping server sudah terjadi, jadi non-retryable untuk kedua policy.\n if (error.code === \"network_error\") return true;\n if (policy === \"botReply\" && error.status !== null && error.status >= 500) return true;\n return false;\n}\n\n/** Exponential backoff + full jitter, base 250ms cap 1s; Retry-After menang. */\nexport function retryDelayMs(attempt: number, retryAfterSec: number | null): number {\n if (retryAfterSec != null && Number.isFinite(retryAfterSec)) return Math.max(0, retryAfterSec * 1000);\n const cap = Math.min(1000, 250 * 2 ** attempt);\n return Math.round(cap * (0.5 + Math.random() * 0.5));\n}\n","// src/client.ts\nimport { mapResponseError, TokolakuAPIError, TokolakuValidationError } from \"./errors.js\";\nimport { shouldRetry, retryDelayMs, type RetryPolicy } from \"./retry.js\";\nimport type {\n BotReplyParams, BotReplyResponse, SendMessageParams, SendMessageResponse, TokolakuOptions,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.tokolaku.id\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 2;\n\nexport class Tokolaku {\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #timeoutMs: number;\n readonly #maxRetries: number;\n readonly #fetch: typeof fetch;\n\n constructor(apiKeyOrOptions: string | TokolakuOptions) {\n const o: TokolakuOptions =\n typeof apiKeyOrOptions === \"string\" ? { apiKey: apiKeyOrOptions } : apiKeyOrOptions;\n if (!o.apiKey) throw new TokolakuValidationError(\"apiKey wajib diisi\", { status: null, code: \"missing_api_key\" });\n this.#apiKey = o.apiKey;\n this.#baseUrl = (o.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.#maxRetries = o.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#fetch = o.fetchImpl ?? globalThis.fetch;\n }\n\n /** Balasan AI bot tenant untuk satu pesan pelanggan (POST /api/v1/bot/reply). */\n async botReply(params: BotReplyParams): Promise<BotReplyResponse> {\n return this.#request<BotReplyResponse>(\"/api/v1/bot/reply\", params, \"botReply\");\n }\n\n readonly messages = {\n /** Kirim pesan text/template via channel resmi (POST /api/v1/messages).\n * `type` diinferensi: field `text` → \"text\", field `template` → \"template\". */\n send: async (params: SendMessageParams): Promise<SendMessageResponse> => {\n const hasText = \"text\" in params && params.text != null;\n const hasTemplate = \"template\" in params && (params as { template?: unknown }).template != null;\n if (hasText === hasTemplate) {\n throw new TokolakuValidationError(\n \"Isi tepat satu: `text` (pesan sesi) ATAU `template` (business-initiated)\",\n { status: null, code: \"invalid_params\" },\n );\n }\n const body = { ...params, type: hasText ? \"text\" : \"template\" };\n return this.#request<SendMessageResponse>(\"/api/v1/messages\", body, \"messages\");\n },\n };\n\n async #request<T>(path: string, body: unknown, policy: RetryPolicy): Promise<T> {\n let lastError: TokolakuAPIError | null = null;\n for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {\n let retryAfterSec: number | null = null;\n try {\n return await this.#once<T>(path, body, (v) => { retryAfterSec = v; });\n } catch (e) {\n // defensif: #once selalu melempar TokolakuAPIError; cabang else = jaring pengaman\n const err = e instanceof TokolakuAPIError\n ? e\n : new TokolakuAPIError(String(e), { status: null, code: \"network_error\" });\n lastError = err;\n if (attempt >= this.#maxRetries || !shouldRetry(policy, err)) throw err;\n await new Promise((r) => setTimeout(r, retryDelayMs(attempt, retryAfterSec)));\n }\n }\n throw lastError!;\n }\n\n async #once<T>(path: string, body: unknown, onRetryAfter: (sec: number | null) => void): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeoutMs);\n try {\n const res = await this.#fetch(`${this.#baseUrl}${path}`, {\n method: \"POST\",\n headers: { \"authorization\": `Bearer ${this.#apiKey}`, \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n const text = await res.text();\n if (!res.ok) {\n const ra = res.headers.get(\"retry-after\");\n onRetryAfter(ra != null && /^\\d+$/.test(ra) ? Number(ra) : null);\n throw mapResponseError(res.status, text);\n }\n try {\n return JSON.parse(text) as T;\n } catch {\n // Respons HTTP sudah diterima (efek samping server, mis. pesan terkirim &\n // tercharge, SUDAH terjadi) — parse gagal BUKAN network error & TIDAK boleh di-retry.\n throw new TokolakuAPIError(\"Respons server bukan JSON valid\", { status: res.status, code: \"invalid_response\" });\n }\n } catch (e) {\n if (e instanceof TokolakuAPIError) throw e;\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n const msg = e instanceof Error ? e.message : String(e);\n throw new TokolakuAPIError(`Network error: ${msg}`, { status: null, code: \"network_error\" });\n } finally {\n clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/retry.ts","../src/client.ts"],"sourcesContent":["// src/index.ts\nexport { Tokolaku } from \"./client.js\";\nexport { Tokolaku as default } from \"./client.js\";\nexport {\n TokolakuAPIError,\n TokolakuAuthenticationError,\n TokolakuInsufficientBalanceError,\n TokolakuPermissionError,\n TokolakuRateLimitError,\n TokolakuValidationError,\n TokolakuWebhookSignatureError,\n} from \"./errors.js\";\nexport type {\n TokolakuOptions,\n BotReplyParams, BotReplyResponse,\n SendTextParams, SendTemplateParams, SendMessageParams, SendMessageResponse,\n} from \"./types.js\";\n","/** Base error semua kegagalan API. `status` null = kegagalan sebelum ada\n * respons HTTP (network/timeout). `code` = kode envelope BE, mis.\n * \"insufficient_balance\"; null bila body bukan JSON envelope. */\nexport class TokolakuAPIError extends Error {\n readonly status: number | null;\n readonly code: string | null;\n constructor(message: string, opts: { status: number | null; code: string | null }) {\n super(message);\n this.name = new.target.name;\n this.status = opts.status;\n this.code = opts.code;\n }\n}\n\nexport class TokolakuAuthenticationError extends TokolakuAPIError {} // 401\nexport class TokolakuInsufficientBalanceError extends TokolakuAPIError {} // 402\nexport class TokolakuPermissionError extends TokolakuAPIError {} // 403\nexport class TokolakuRateLimitError extends TokolakuAPIError {} // 429\nexport class TokolakuValidationError extends TokolakuAPIError {} // 400/422 + validasi klien\n\nexport class TokolakuWebhookSignatureError extends Error {\n constructor(message = \"Signature webhook tidak valid\") {\n super(message);\n this.name = \"TokolakuWebhookSignatureError\";\n }\n}\n\nconst STATUS_CLASS: Record<number, new (m: string, o: { status: number | null; code: string | null }) => TokolakuAPIError> = {\n 400: TokolakuValidationError,\n 401: TokolakuAuthenticationError,\n 402: TokolakuInsufficientBalanceError,\n 403: TokolakuPermissionError,\n 422: TokolakuValidationError,\n 429: TokolakuRateLimitError,\n};\n\n/** Terjemahkan respons non-2xx jadi error class. Envelope BE:\n * `{ error: { code, message } }`. Body non-JSON dipotong 500 char. */\nexport function mapResponseError(status: number, bodyText: string): TokolakuAPIError {\n let code: string | null = null;\n let message = bodyText ? bodyText.slice(0, 500) : `HTTP ${status}`;\n try {\n const parsed = JSON.parse(bodyText) as { error?: { code?: string; message?: string } };\n if (parsed?.error) {\n code = parsed.error.code ?? null;\n message = parsed.error.message ?? message;\n }\n } catch {\n /* non-JSON — pakai default */\n }\n const Cls = STATUS_CLASS[status] ?? TokolakuAPIError;\n return new Cls(message, { status, code });\n}\n","import { TokolakuAPIError } from \"./errors.js\";\n\nexport type RetryPolicy = \"botReply\" | \"messages\";\n\n/** Retry uang-sadar:\n * - botReply: 429, 5xx, network error (tanpa efek samping bila gagal).\n * - messages: HANYA 429 + network TypeError (pesan mungkin sudah terkirim\n * & tercharge pada timeout/5xx — API belum punya idempotency key).\n * - timeout (code \"timeout\") TIDAK pernah di-retry. */\nexport function shouldRetry(policy: RetryPolicy, error: TokolakuAPIError): boolean {\n if (error.code === \"timeout\") return false;\n if (error.status === 429) return true;\n // \"invalid_response\" (200 OK tapi body JSON rusak) dan \"response_read_error\" (header\n // respons sudah diterima tapi baca body gagal) SENGAJA tidak match rule apa pun di\n // bawah ini — efek samping server sudah terjadi, jadi non-retryable untuk kedua policy.\n if (error.code === \"network_error\") return true;\n if (policy === \"botReply\" && error.status !== null && error.status >= 500) return true;\n return false;\n}\n\n/** Exponential backoff + full jitter, base 250ms cap 1s; Retry-After menang. */\nexport function retryDelayMs(attempt: number, retryAfterSec: number | null): number {\n if (retryAfterSec != null && Number.isFinite(retryAfterSec)) return Math.max(0, retryAfterSec * 1000);\n const cap = Math.min(1000, 250 * 2 ** attempt);\n return Math.round(cap * (0.5 + Math.random() * 0.5));\n}\n","// src/client.ts\nimport { mapResponseError, TokolakuAPIError, TokolakuValidationError } from \"./errors.js\";\nimport { shouldRetry, retryDelayMs, type RetryPolicy } from \"./retry.js\";\nimport type {\n BotReplyParams, BotReplyResponse, SendMessageParams, SendMessageResponse, TokolakuOptions,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.tokolaku.id\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 2;\n\nexport class Tokolaku {\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #timeoutMs: number;\n readonly #maxRetries: number;\n readonly #fetch: typeof fetch;\n\n constructor(apiKeyOrOptions: string | TokolakuOptions) {\n const o: TokolakuOptions =\n typeof apiKeyOrOptions === \"string\" ? { apiKey: apiKeyOrOptions } : apiKeyOrOptions;\n if (!o.apiKey) throw new TokolakuValidationError(\"apiKey wajib diisi\", { status: null, code: \"missing_api_key\" });\n this.#apiKey = o.apiKey;\n this.#baseUrl = (o.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.#maxRetries = o.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#fetch = o.fetchImpl ?? globalThis.fetch;\n }\n\n /** Balasan AI bot tenant untuk satu pesan pelanggan (POST /api/v1/bot/reply). */\n async botReply(params: BotReplyParams): Promise<BotReplyResponse> {\n return this.#request<BotReplyResponse>(\"/api/v1/bot/reply\", params, \"botReply\");\n }\n\n readonly messages = {\n /** Kirim pesan text/template via channel resmi (POST /api/v1/messages).\n * `type` diinferensi: field `text` → \"text\", field `template` → \"template\". */\n send: async (params: SendMessageParams): Promise<SendMessageResponse> => {\n const hasText = \"text\" in params && params.text != null;\n const hasTemplate = \"template\" in params && (params as { template?: unknown }).template != null;\n if (hasText === hasTemplate) {\n throw new TokolakuValidationError(\n \"Isi tepat satu: `text` (pesan sesi) ATAU `template` (business-initiated)\",\n { status: null, code: \"invalid_params\" },\n );\n }\n const body = { ...params, type: hasText ? \"text\" : \"template\" };\n return this.#request<SendMessageResponse>(\"/api/v1/messages\", body, \"messages\");\n },\n };\n\n async #request<T>(path: string, body: unknown, policy: RetryPolicy): Promise<T> {\n let lastError: TokolakuAPIError | null = null;\n for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {\n let retryAfterSec: number | null = null;\n try {\n return await this.#once<T>(path, body, (v) => { retryAfterSec = v; });\n } catch (e) {\n // defensif: #once selalu melempar TokolakuAPIError; cabang else = jaring pengaman\n const err = e instanceof TokolakuAPIError\n ? e\n : new TokolakuAPIError(String(e), { status: null, code: \"network_error\" });\n lastError = err;\n if (attempt >= this.#maxRetries || !shouldRetry(policy, err)) throw err;\n await new Promise((r) => setTimeout(r, retryDelayMs(attempt, retryAfterSec)));\n }\n }\n throw lastError!;\n }\n\n async #once<T>(path: string, body: unknown, onRetryAfter: (sec: number | null) => void): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeoutMs);\n try {\n const res = await this.#fetch(`${this.#baseUrl}${path}`, {\n method: \"POST\",\n headers: { \"authorization\": `Bearer ${this.#apiKey}`, \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n let text: string;\n try {\n text = await res.text();\n } catch (e) {\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n // Header respons SUDAH diterima (request sampai server, efek samping —\n // mis. pesan terkirim & tercharge, reply AI dihasilkan — mungkin sudah\n // terjadi) tapi koneksi putus saat membaca body. BUKAN network error &\n // TIDAK boleh di-retry (lihat shouldRetry: sejajar dengan invalid_response).\n throw new TokolakuAPIError(\"Gagal membaca body respons\", { status: res.status, code: \"response_read_error\" });\n }\n if (!res.ok) {\n const ra = res.headers.get(\"retry-after\");\n onRetryAfter(ra != null && /^\\d+$/.test(ra) ? Number(ra) : null);\n throw mapResponseError(res.status, text);\n }\n try {\n return JSON.parse(text) as T;\n } catch {\n // Respons HTTP sudah diterima (efek samping server, mis. pesan terkirim &\n // tercharge, SUDAH terjadi) — parse gagal BUKAN network error & TIDAK boleh di-retry.\n throw new TokolakuAPIError(\"Respons server bukan JSON valid\", { status: res.status, code: \"invalid_response\" });\n }\n } catch (e) {\n if (e instanceof TokolakuAPIError) throw e;\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n const msg = e instanceof Error ? e.message : String(e);\n throw new TokolakuAPIError(`Network error: ${msg}`, { status: null, code: \"network_error\" });\n } finally {\n clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -89,7 +89,15 @@ var Tokolaku = class {
|
|
|
89
89
|
body: JSON.stringify(body),
|
|
90
90
|
signal: controller.signal
|
|
91
91
|
});
|
|
92
|
-
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = await res.text();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
97
|
+
throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: "timeout" });
|
|
98
|
+
}
|
|
99
|
+
throw new TokolakuAPIError("Gagal membaca body respons", { status: res.status, code: "response_read_error" });
|
|
100
|
+
}
|
|
93
101
|
if (!res.ok) {
|
|
94
102
|
const ra = res.headers.get("retry-after");
|
|
95
103
|
onRetryAfter(ra != null && /^\d+$/.test(ra) ? Number(ra) : null);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/retry.ts","../src/client.ts"],"sourcesContent":["import { TokolakuAPIError } from \"./errors.js\";\n\nexport type RetryPolicy = \"botReply\" | \"messages\";\n\n/** Retry uang-sadar:\n * - botReply: 429, 5xx, network error (tanpa efek samping bila gagal).\n * - messages: HANYA 429 + network TypeError (pesan mungkin sudah terkirim\n * & tercharge pada timeout/5xx — API belum punya idempotency key).\n * - timeout (code \"timeout\") TIDAK pernah di-retry. */\nexport function shouldRetry(policy: RetryPolicy, error: TokolakuAPIError): boolean {\n if (error.code === \"timeout\") return false;\n if (error.status === 429) return true;\n // \"invalid_response\" (200 OK tapi body JSON rusak) SENGAJA tidak match rule apa pun di\n // bawah ini — efek samping server sudah terjadi, jadi non-retryable untuk kedua policy.\n if (error.code === \"network_error\") return true;\n if (policy === \"botReply\" && error.status !== null && error.status >= 500) return true;\n return false;\n}\n\n/** Exponential backoff + full jitter, base 250ms cap 1s; Retry-After menang. */\nexport function retryDelayMs(attempt: number, retryAfterSec: number | null): number {\n if (retryAfterSec != null && Number.isFinite(retryAfterSec)) return Math.max(0, retryAfterSec * 1000);\n const cap = Math.min(1000, 250 * 2 ** attempt);\n return Math.round(cap * (0.5 + Math.random() * 0.5));\n}\n","// src/client.ts\nimport { mapResponseError, TokolakuAPIError, TokolakuValidationError } from \"./errors.js\";\nimport { shouldRetry, retryDelayMs, type RetryPolicy } from \"./retry.js\";\nimport type {\n BotReplyParams, BotReplyResponse, SendMessageParams, SendMessageResponse, TokolakuOptions,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.tokolaku.id\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 2;\n\nexport class Tokolaku {\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #timeoutMs: number;\n readonly #maxRetries: number;\n readonly #fetch: typeof fetch;\n\n constructor(apiKeyOrOptions: string | TokolakuOptions) {\n const o: TokolakuOptions =\n typeof apiKeyOrOptions === \"string\" ? { apiKey: apiKeyOrOptions } : apiKeyOrOptions;\n if (!o.apiKey) throw new TokolakuValidationError(\"apiKey wajib diisi\", { status: null, code: \"missing_api_key\" });\n this.#apiKey = o.apiKey;\n this.#baseUrl = (o.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.#maxRetries = o.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#fetch = o.fetchImpl ?? globalThis.fetch;\n }\n\n /** Balasan AI bot tenant untuk satu pesan pelanggan (POST /api/v1/bot/reply). */\n async botReply(params: BotReplyParams): Promise<BotReplyResponse> {\n return this.#request<BotReplyResponse>(\"/api/v1/bot/reply\", params, \"botReply\");\n }\n\n readonly messages = {\n /** Kirim pesan text/template via channel resmi (POST /api/v1/messages).\n * `type` diinferensi: field `text` → \"text\", field `template` → \"template\". */\n send: async (params: SendMessageParams): Promise<SendMessageResponse> => {\n const hasText = \"text\" in params && params.text != null;\n const hasTemplate = \"template\" in params && (params as { template?: unknown }).template != null;\n if (hasText === hasTemplate) {\n throw new TokolakuValidationError(\n \"Isi tepat satu: `text` (pesan sesi) ATAU `template` (business-initiated)\",\n { status: null, code: \"invalid_params\" },\n );\n }\n const body = { ...params, type: hasText ? \"text\" : \"template\" };\n return this.#request<SendMessageResponse>(\"/api/v1/messages\", body, \"messages\");\n },\n };\n\n async #request<T>(path: string, body: unknown, policy: RetryPolicy): Promise<T> {\n let lastError: TokolakuAPIError | null = null;\n for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {\n let retryAfterSec: number | null = null;\n try {\n return await this.#once<T>(path, body, (v) => { retryAfterSec = v; });\n } catch (e) {\n // defensif: #once selalu melempar TokolakuAPIError; cabang else = jaring pengaman\n const err = e instanceof TokolakuAPIError\n ? e\n : new TokolakuAPIError(String(e), { status: null, code: \"network_error\" });\n lastError = err;\n if (attempt >= this.#maxRetries || !shouldRetry(policy, err)) throw err;\n await new Promise((r) => setTimeout(r, retryDelayMs(attempt, retryAfterSec)));\n }\n }\n throw lastError!;\n }\n\n async #once<T>(path: string, body: unknown, onRetryAfter: (sec: number | null) => void): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeoutMs);\n try {\n const res = await this.#fetch(`${this.#baseUrl}${path}`, {\n method: \"POST\",\n headers: { \"authorization\": `Bearer ${this.#apiKey}`, \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n const text = await res.text();\n if (!res.ok) {\n const ra = res.headers.get(\"retry-after\");\n onRetryAfter(ra != null && /^\\d+$/.test(ra) ? Number(ra) : null);\n throw mapResponseError(res.status, text);\n }\n try {\n return JSON.parse(text) as T;\n } catch {\n // Respons HTTP sudah diterima (efek samping server, mis. pesan terkirim &\n // tercharge, SUDAH terjadi) — parse gagal BUKAN network error & TIDAK boleh di-retry.\n throw new TokolakuAPIError(\"Respons server bukan JSON valid\", { status: res.status, code: \"invalid_response\" });\n }\n } catch (e) {\n if (e instanceof TokolakuAPIError) throw e;\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n const msg = e instanceof Error ? e.message : String(e);\n throw new TokolakuAPIError(`Network error: ${msg}`, { status: null, code: \"network_error\" });\n } finally {\n clearTimeout(timer);\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;AASO,SAAS,YAAY,QAAqB,OAAkC;AACjF,MAAI,MAAM,SAAS,UAAW,QAAO;AACrC,MAAI,MAAM,WAAW,IAAK,QAAO;AAGjC,MAAI,MAAM,SAAS,gBAAiB,QAAO;AAC3C,MAAI,WAAW,cAAc,MAAM,WAAW,QAAQ,MAAM,UAAU,IAAK,QAAO;AAClF,SAAO;AACT;AAGO,SAAS,aAAa,SAAiB,eAAsC;AAClF,MAAI,iBAAiB,QAAQ,OAAO,SAAS,aAAa,EAAG,QAAO,KAAK,IAAI,GAAG,gBAAgB,GAAI;AACpG,QAAM,MAAM,KAAK,IAAI,KAAM,MAAM,KAAK,OAAO;AAC7C,SAAO,KAAK,MAAM,OAAO,MAAM,KAAK,OAAO,IAAI,IAAI;AACrD;;;ACjBA,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAC3B,IAAM,sBAAsB;AAErB,IAAM,WAAN,MAAe;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,iBAA2C;AACrD,UAAM,IACJ,OAAO,oBAAoB,WAAW,EAAE,QAAQ,gBAAgB,IAAI;AACtE,QAAI,CAAC,EAAE,OAAQ,OAAM,IAAI,wBAAwB,sBAAsB,EAAE,QAAQ,MAAM,MAAM,kBAAkB,CAAC;AAChH,SAAK,UAAU,EAAE;AACjB,SAAK,YAAY,EAAE,WAAW,kBAAkB,QAAQ,QAAQ,EAAE;AAClE,SAAK,aAAa,EAAE,aAAa;AACjC,SAAK,cAAc,EAAE,cAAc;AACnC,SAAK,SAAS,EAAE,aAAa,WAAW;AAAA,EAC1C;AAAA;AAAA,EAGA,MAAM,SAAS,QAAmD;AAChE,WAAO,KAAK,SAA2B,qBAAqB,QAAQ,UAAU;AAAA,EAChF;AAAA,EAES,WAAW;AAAA;AAAA;AAAA,IAGlB,MAAM,OAAO,WAA4D;AACvE,YAAM,UAAU,UAAU,UAAU,OAAO,QAAQ;AACnD,YAAM,cAAc,cAAc,UAAW,OAAkC,YAAY;AAC3F,UAAI,YAAY,aAAa;AAC3B,cAAM,IAAI;AAAA,UACR;AAAA,UACA,EAAE,QAAQ,MAAM,MAAM,iBAAiB;AAAA,QACzC;AAAA,MACF;AACA,YAAM,OAAO,EAAE,GAAG,QAAQ,MAAM,UAAU,SAAS,WAAW;AAC9D,aAAO,KAAK,SAA8B,oBAAoB,MAAM,UAAU;AAAA,IAChF;AAAA,EACF;AAAA,EAEA,MAAM,SAAY,MAAc,MAAe,QAAiC;AAC9E,QAAI,YAAqC;AACzC,aAAS,UAAU,GAAG,WAAW,KAAK,aAAa,WAAW;AAC5D,UAAI,gBAA+B;AACnC,UAAI;AACF,eAAO,MAAM,KAAK,MAAS,MAAM,MAAM,CAAC,MAAM;AAAE,0BAAgB;AAAA,QAAG,CAAC;AAAA,MACtE,SAAS,GAAG;AAEV,cAAM,MAAM,aAAa,mBACrB,IACA,IAAI,iBAAiB,OAAO,CAAC,GAAG,EAAE,QAAQ,MAAM,MAAM,gBAAgB,CAAC;AAC3E,oBAAY;AACZ,YAAI,WAAW,KAAK,eAAe,CAAC,YAAY,QAAQ,GAAG,EAAG,OAAM;AACpE,cAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,aAAa,SAAS,aAAa,CAAC,CAAC;AAAA,MAC9E;AAAA,IACF;AACA,UAAM;AAAA,EACR;AAAA,EAEA,MAAM,MAAS,MAAc,MAAe,cAAwD;AAClG,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,KAAK,UAAU;AAClE,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,OAAO,GAAG,KAAK,QAAQ,GAAG,IAAI,IAAI;AAAA,QACvD,QAAQ;AAAA,QACR,SAAS,EAAE,iBAAiB,UAAU,KAAK,OAAO,IAAI,gBAAgB,mBAAmB;AAAA,QACzF,MAAM,KAAK,UAAU,IAAI;AAAA,QACzB,QAAQ,WAAW;AAAA,MACrB,CAAC;AACD,YAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,KAAK,IAAI,QAAQ,IAAI,aAAa;AACxC,qBAAa,MAAM,QAAQ,QAAQ,KAAK,EAAE,IAAI,OAAO,EAAE,IAAI,IAAI;AAC/D,cAAM,iBAAiB,IAAI,QAAQ,IAAI;AAAA,MACzC;AACA,UAAI;AACF,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB,QAAQ;AAGN,cAAM,IAAI,iBAAiB,mCAAmC,EAAE,QAAQ,IAAI,QAAQ,MAAM,mBAAmB,CAAC;AAAA,MAChH;AAAA,IACF,SAAS,GAAG;AACV,UAAI,aAAa,iBAAkB,OAAM;AACzC,UAAI,aAAa,SAAS,EAAE,SAAS,cAAc;AACjD,cAAM,IAAI,iBAAiB,mBAAmB,KAAK,UAAU,MAAM,EAAE,QAAQ,MAAM,MAAM,UAAU,CAAC;AAAA,MACtG;AACA,YAAM,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AACrD,YAAM,IAAI,iBAAiB,kBAAkB,GAAG,IAAI,EAAE,QAAQ,MAAM,MAAM,gBAAgB,CAAC;AAAA,IAC7F,UAAE;AACA,mBAAa,KAAK;AAAA,IACpB;AAAA,EACF;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/retry.ts","../src/client.ts"],"sourcesContent":["import { TokolakuAPIError } from \"./errors.js\";\n\nexport type RetryPolicy = \"botReply\" | \"messages\";\n\n/** Retry uang-sadar:\n * - botReply: 429, 5xx, network error (tanpa efek samping bila gagal).\n * - messages: HANYA 429 + network TypeError (pesan mungkin sudah terkirim\n * & tercharge pada timeout/5xx — API belum punya idempotency key).\n * - timeout (code \"timeout\") TIDAK pernah di-retry. */\nexport function shouldRetry(policy: RetryPolicy, error: TokolakuAPIError): boolean {\n if (error.code === \"timeout\") return false;\n if (error.status === 429) return true;\n // \"invalid_response\" (200 OK tapi body JSON rusak) dan \"response_read_error\" (header\n // respons sudah diterima tapi baca body gagal) SENGAJA tidak match rule apa pun di\n // bawah ini — efek samping server sudah terjadi, jadi non-retryable untuk kedua policy.\n if (error.code === \"network_error\") return true;\n if (policy === \"botReply\" && error.status !== null && error.status >= 500) return true;\n return false;\n}\n\n/** Exponential backoff + full jitter, base 250ms cap 1s; Retry-After menang. */\nexport function retryDelayMs(attempt: number, retryAfterSec: number | null): number {\n if (retryAfterSec != null && Number.isFinite(retryAfterSec)) return Math.max(0, retryAfterSec * 1000);\n const cap = Math.min(1000, 250 * 2 ** attempt);\n return Math.round(cap * (0.5 + Math.random() * 0.5));\n}\n","// src/client.ts\nimport { mapResponseError, TokolakuAPIError, TokolakuValidationError } from \"./errors.js\";\nimport { shouldRetry, retryDelayMs, type RetryPolicy } from \"./retry.js\";\nimport type {\n BotReplyParams, BotReplyResponse, SendMessageParams, SendMessageResponse, TokolakuOptions,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.tokolaku.id\";\nconst DEFAULT_TIMEOUT_MS = 30_000;\nconst DEFAULT_MAX_RETRIES = 2;\n\nexport class Tokolaku {\n readonly #apiKey: string;\n readonly #baseUrl: string;\n readonly #timeoutMs: number;\n readonly #maxRetries: number;\n readonly #fetch: typeof fetch;\n\n constructor(apiKeyOrOptions: string | TokolakuOptions) {\n const o: TokolakuOptions =\n typeof apiKeyOrOptions === \"string\" ? { apiKey: apiKeyOrOptions } : apiKeyOrOptions;\n if (!o.apiKey) throw new TokolakuValidationError(\"apiKey wajib diisi\", { status: null, code: \"missing_api_key\" });\n this.#apiKey = o.apiKey;\n this.#baseUrl = (o.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.#timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.#maxRetries = o.maxRetries ?? DEFAULT_MAX_RETRIES;\n this.#fetch = o.fetchImpl ?? globalThis.fetch;\n }\n\n /** Balasan AI bot tenant untuk satu pesan pelanggan (POST /api/v1/bot/reply). */\n async botReply(params: BotReplyParams): Promise<BotReplyResponse> {\n return this.#request<BotReplyResponse>(\"/api/v1/bot/reply\", params, \"botReply\");\n }\n\n readonly messages = {\n /** Kirim pesan text/template via channel resmi (POST /api/v1/messages).\n * `type` diinferensi: field `text` → \"text\", field `template` → \"template\". */\n send: async (params: SendMessageParams): Promise<SendMessageResponse> => {\n const hasText = \"text\" in params && params.text != null;\n const hasTemplate = \"template\" in params && (params as { template?: unknown }).template != null;\n if (hasText === hasTemplate) {\n throw new TokolakuValidationError(\n \"Isi tepat satu: `text` (pesan sesi) ATAU `template` (business-initiated)\",\n { status: null, code: \"invalid_params\" },\n );\n }\n const body = { ...params, type: hasText ? \"text\" : \"template\" };\n return this.#request<SendMessageResponse>(\"/api/v1/messages\", body, \"messages\");\n },\n };\n\n async #request<T>(path: string, body: unknown, policy: RetryPolicy): Promise<T> {\n let lastError: TokolakuAPIError | null = null;\n for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {\n let retryAfterSec: number | null = null;\n try {\n return await this.#once<T>(path, body, (v) => { retryAfterSec = v; });\n } catch (e) {\n // defensif: #once selalu melempar TokolakuAPIError; cabang else = jaring pengaman\n const err = e instanceof TokolakuAPIError\n ? e\n : new TokolakuAPIError(String(e), { status: null, code: \"network_error\" });\n lastError = err;\n if (attempt >= this.#maxRetries || !shouldRetry(policy, err)) throw err;\n await new Promise((r) => setTimeout(r, retryDelayMs(attempt, retryAfterSec)));\n }\n }\n throw lastError!;\n }\n\n async #once<T>(path: string, body: unknown, onRetryAfter: (sec: number | null) => void): Promise<T> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.#timeoutMs);\n try {\n const res = await this.#fetch(`${this.#baseUrl}${path}`, {\n method: \"POST\",\n headers: { \"authorization\": `Bearer ${this.#apiKey}`, \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n let text: string;\n try {\n text = await res.text();\n } catch (e) {\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n // Header respons SUDAH diterima (request sampai server, efek samping —\n // mis. pesan terkirim & tercharge, reply AI dihasilkan — mungkin sudah\n // terjadi) tapi koneksi putus saat membaca body. BUKAN network error &\n // TIDAK boleh di-retry (lihat shouldRetry: sejajar dengan invalid_response).\n throw new TokolakuAPIError(\"Gagal membaca body respons\", { status: res.status, code: \"response_read_error\" });\n }\n if (!res.ok) {\n const ra = res.headers.get(\"retry-after\");\n onRetryAfter(ra != null && /^\\d+$/.test(ra) ? Number(ra) : null);\n throw mapResponseError(res.status, text);\n }\n try {\n return JSON.parse(text) as T;\n } catch {\n // Respons HTTP sudah diterima (efek samping server, mis. pesan terkirim &\n // tercharge, SUDAH terjadi) — parse gagal BUKAN network error & TIDAK boleh di-retry.\n throw new TokolakuAPIError(\"Respons server bukan JSON valid\", { status: res.status, code: \"invalid_response\" });\n }\n } catch (e) {\n if (e instanceof TokolakuAPIError) throw e;\n if (e instanceof Error && e.name === \"AbortError\") {\n throw new TokolakuAPIError(`Timeout setelah ${this.#timeoutMs}ms`, { status: null, code: \"timeout\" });\n }\n const msg = e instanceof Error ? e.message : String(e);\n throw new TokolakuAPIError(`Network error: ${msg}`, { status: null, code: \"network_error\" });\n } finally {\n clearTimeout(timer);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokolaku-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Official TypeScript SDK for the Tokolaku Engine API — AI bot replies, omnichannel messaging (WhatsApp/Instagram/Messenger), and webhook verification.",
|
|
5
5
|
"keywords": ["tokolaku", "whatsapp", "chatbot", "sdk", "api", "messaging"],
|
|
6
6
|
"license": "MIT",
|