diff --git a/Libs/pollilib/javascript/polliLib/base.js b/Libs/pollilib/javascript/polliLib/base.js index ef6bad6..e3eebbe 100644 --- a/Libs/pollilib/javascript/polliLib/base.js +++ b/Libs/pollilib/javascript/polliLib/base.js @@ -6,7 +6,12 @@ const DEFAULTS = { imagePromptBase: "https://image.pollinations.ai/prompt", textPromptBase: "https://text.pollinations.ai", timeoutMs: 10000, + minRequestIntervalMs: 3000, + retryInitialDelayMs: 500, + retryDelayStepMs: 100, + retryMaxDelayMs: 4000, }; +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); export class BaseClient { constructor(opts = {}) { @@ -18,6 +23,27 @@ export class BaseClient { this.fetch = opts.fetch || (typeof fetch !== "undefined" ? fetch.bind(globalThis) : null); if (!this.fetch) throw new Error("fetch is not available; provide opts.fetch"); this._modelsCache = new Map(); // kind -> list + this.minRequestIntervalMs = Number.isFinite(opts.minRequestIntervalMs) + ? Math.max(0, opts.minRequestIntervalMs) + : DEFAULTS.minRequestIntervalMs; + this.retryInitialDelayMs = Number.isFinite(opts.retryInitialDelayMs) + ? Math.max(0, opts.retryInitialDelayMs) + : DEFAULTS.retryInitialDelayMs; + this.retryDelayStepMs = Number.isFinite(opts.retryDelayStepMs) + ? Math.max(0, opts.retryDelayStepMs) + : DEFAULTS.retryDelayStepMs; + this.retryMaxDelayMs = Number.isFinite(opts.retryMaxDelayMs) + ? Math.max(this.retryInitialDelayMs, opts.retryMaxDelayMs) + : DEFAULTS.retryMaxDelayMs; + const steps = this.retryDelayStepMs > 0 + ? Math.floor(Math.max(0, this.retryMaxDelayMs - this.retryInitialDelayMs) / this.retryDelayStepMs) + : 0; + this._maxRetryAttempts = this.retryMaxDelayMs > 0 ? steps + 1 : 0; + this._sleepFn = typeof opts.sleep === "function" + ? opts.sleep + : (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + this._lastSuccessAt = 0; + this._requestQueue = Promise.resolve(); } async listModels(kind /* 'text' | 'image' */) { @@ -109,5 +135,72 @@ export class BaseClient { _textPromptUrl(prompt) { return `${this.textPromptBase}/${encodeURIComponent(prompt)}`; } + + _retryDelayMs(attempt) { + if (attempt <= 0) return 0; + if (this.retryInitialDelayMs <= 0) return 0; + if (attempt === 1) return this.retryInitialDelayMs; + if (this.retryDelayStepMs <= 0) return Math.min(this.retryInitialDelayMs, this.retryMaxDelayMs); + const delay = this.retryInitialDelayMs + (attempt - 1) * this.retryDelayStepMs; + return Math.min(delay, this.retryMaxDelayMs); + } + + async _sleep(ms) { + if (!(ms > 0)) return; + await this._sleepFn(ms); + } + + _shouldRetryResponse(resp) { + return resp && RETRYABLE_STATUS.has(resp.status); + } + + _isRetryableError(error) { + if (!error) return false; + if (error.retryable === true) return true; + if (typeof error.status === "number" && RETRYABLE_STATUS.has(error.status)) return true; + return false; + } + + async _rateLimitedRequest(executor) { + const run = async () => { + let attempt = 0; + let lastError = null; + for (;;) { + if (attempt === 0) { + const waitMs = Math.max(0, this._lastSuccessAt + this.minRequestIntervalMs - Date.now()); + if (waitMs > 0) await this._sleep(waitMs); + } else { + const delay = this._retryDelayMs(attempt); + if (delay > 0) await this._sleep(delay); + } + try { + const response = await executor(attempt); + if (this._shouldRetryResponse(response)) { + lastError = new Error(`HTTP ${response.status}`); + lastError.status = response.status; + try { response.body?.cancel?.(); } catch {} + attempt += 1; + if (attempt > this._maxRetryAttempts) throw lastError; + continue; + } + if (!response.ok) { + const err = new Error(`HTTP ${response.status}`); + err.status = response.status; + throw err; + } + this._lastSuccessAt = Date.now(); + return response; + } catch (error) { + lastError = error; + if (!this._isRetryableError(error)) throw error; + attempt += 1; + if (attempt > this._maxRetryAttempts) throw lastError; + } + } + }; + const next = this._requestQueue.then(run, run); + this._requestQueue = next.catch(() => {}); + return next; + } } diff --git a/Libs/pollilib/javascript/polliLib/images.js b/Libs/pollilib/javascript/polliLib/images.js index 421f0db..35305cc 100644 --- a/Libs/pollilib/javascript/polliLib/images.js +++ b/Libs/pollilib/javascript/polliLib/images.js @@ -26,20 +26,22 @@ export const ImagesMixin = (Base) => class extends Base { if (token) params.set('token', token); const url = this._imagePromptUrl(String(prompt)); const full = `${url}?${params}`; - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs); - try { - const resp = await this.fetch(full, { method: 'GET', signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - if (outPath) { - await streamToFile(resp, outPath, chunkSize); - return outPath; + const response = await this._rateLimitedRequest(async () => { + const controller = new AbortController(); + const limit = timeoutMs ?? this.timeoutMs; + const t = setTimeout(() => controller.abort(), limit); + try { + return await this.fetch(full, { method: 'GET', signal: controller.signal }); + } finally { + clearTimeout(t); } - const buf = await resp.arrayBuffer(); - return Buffer.from(buf); - } finally { - clearTimeout(t); + }); + if (outPath) { + await streamToFile(response, outPath, chunkSize); + return outPath; } + const buf = await response.arrayBuffer(); + return Buffer.from(buf); } async save_image_timestamped(prompt, { @@ -70,20 +72,22 @@ export const ImagesMixin = (Base) => class extends Base { const u = new URL(imageUrl); if (referrer) u.searchParams.set('referrer', referrer); if (token) u.searchParams.set('token', token); - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs); - try { - const resp = await this.fetch(u, { method: 'GET', signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - if (outPath) { - await streamToFile(resp, outPath, chunkSize); - return outPath; + const response = await this._rateLimitedRequest(async () => { + const controller = new AbortController(); + const limit = timeoutMs ?? this.timeoutMs; + const t = setTimeout(() => controller.abort(), limit); + try { + return await this.fetch(u, { method: 'GET', signal: controller.signal }); + } finally { + clearTimeout(t); } - const buf = await resp.arrayBuffer(); - return Buffer.from(buf); - } finally { - clearTimeout(t); + }); + if (outPath) { + await streamToFile(response, outPath, chunkSize); + return outPath; } + const buf = await response.arrayBuffer(); + return Buffer.from(buf); } }; diff --git a/Libs/pollilib/javascript/polliLib/stt.js b/Libs/pollilib/javascript/polliLib/stt.js index 04f8ff4..d751e94 100644 --- a/Libs/pollilib/javascript/polliLib/stt.js +++ b/Libs/pollilib/javascript/polliLib/stt.js @@ -17,14 +17,23 @@ export const STTMixin = (Base) => class extends Base { if (token) payload.token = token; payload.safe = false; const url = `${this.textPromptBase}/${provider}`; - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs); - try { - const resp = await this.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const json = await resp.json(); - return json?.choices?.[0]?.message?.content; - } finally { clearTimeout(t); } + const response = await this._rateLimitedRequest(async () => { + const controller = new AbortController(); + const limit = timeoutMs ?? this.timeoutMs; + const t = setTimeout(() => controller.abort(), limit); + try { + return await this.fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } finally { + clearTimeout(t); + } + }); + const json = await response.json(); + return json?.choices?.[0]?.message?.content; } }; diff --git a/Libs/pollilib/javascript/polliLib/text.js b/Libs/pollilib/javascript/polliLib/text.js index 954451f..10ef270 100644 --- a/Libs/pollilib/javascript/polliLib/text.js +++ b/Libs/pollilib/javascript/polliLib/text.js @@ -10,18 +10,20 @@ export const TextMixin = (Base) => class extends Base { if (system) url.searchParams.set('system', system); if (referrer) url.searchParams.set('referrer', referrer); if (token) url.searchParams.set('token', token); - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), timeoutMs || this.timeoutMs); - try { - const resp = await this.fetch(url, { method: 'GET', signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - if (asJson) { - const text = await resp.text(); - try { return JSON.parse(text); } catch { return text; } + const response = await this._rateLimitedRequest(async () => { + const controller = new AbortController(); + const limit = timeoutMs ?? this.timeoutMs; + const t = setTimeout(() => controller.abort(), limit); + try { + return await this.fetch(url, { method: 'GET', signal: controller.signal }); + } finally { + clearTimeout(t); } - return await resp.text(); - } finally { - clearTimeout(t); + }); + if (asJson) { + const text = await response.text(); + try { return JSON.parse(text); } catch { return text; } } + return await response.text(); } }; diff --git a/Libs/pollilib/javascript/tests/test_text_chat.js b/Libs/pollilib/javascript/tests/test_text_chat.js index 5049838..6b6f6aa 100644 --- a/Libs/pollilib/javascript/tests/test_text_chat.js +++ b/Libs/pollilib/javascript/tests/test_text_chat.js @@ -66,3 +66,37 @@ test('chat_completion_tools two-step', async () => { assert.equal(secondBody.safe, false); }); +test('generate_text enforces minimum 3s spacing between successes', async () => { + const seq = new SeqFetch([ + new FakeResponse({ text: 'first' }), + new FakeResponse({ text: 'second' }), + ]); + const sleeps = []; + const c = new PolliClient({ + fetch: seq.fetch.bind(seq), + sleep: async (ms) => { sleeps.push(ms); }, + }); + const first = await c.generate_text('hello'); + assert.equal(first, 'first'); + const second = await c.generate_text('world'); + assert.equal(second, 'second'); + assert.ok(sleeps.some((ms) => ms >= 2990), `expected >=2990ms wait, saw ${sleeps}`); +}); + +test('generate_text retries quickly when hitting rate limits', async () => { + const seq = new SeqFetch([ + new FakeResponse({ status: 429, text: 'limit' }), + new FakeResponse({ status: 503, text: 'busy' }), + new FakeResponse({ text: 'ok' }), + ]); + const sleeps = []; + const c = new PolliClient({ + fetch: seq.fetch.bind(seq), + sleep: async (ms) => { sleeps.push(ms); }, + }); + const result = await c.generate_text('retry please'); + assert.equal(result, 'ok'); + const rounded = sleeps.map((ms) => Math.round(ms)); + assert.deepEqual(rounded.slice(0, 2), [500, 600]); +}); + diff --git a/Libs/pollilib/python/polliLib/base.py b/Libs/pollilib/python/polliLib/base.py index 09e6c87..9b01540 100644 --- a/Libs/pollilib/python/polliLib/base.py +++ b/Libs/pollilib/python/polliLib/base.py @@ -1,7 +1,9 @@ from __future__ import annotations from functools import lru_cache -from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict +import threading +import time +from typing import Any, Callable, Dict, Iterable, List, Literal, Optional, TypedDict import requests ModelType = Literal["text", "image"] @@ -33,6 +35,11 @@ def __init__( text_prompt_base: str = "https://text.pollinations.ai", timeout: float = 10.0, session: Optional[requests.Session] = None, + min_request_interval: float = 3.0, + retry_initial_delay: float = 0.5, + retry_delay_step: float = 0.1, + retry_max_delay: float = 4.0, + sleep: Optional[Callable[[float], None]] = None, ) -> None: self.text_url = text_url self.image_url = image_url @@ -40,6 +47,21 @@ def __init__( self.text_prompt_base = text_prompt_base self.timeout = timeout self.session = session or requests.Session() + self.min_request_interval = max(0.0, float(min_request_interval)) + self.retry_initial_delay = max(0.0, float(retry_initial_delay)) + self.retry_delay_step = max(0.0, float(retry_delay_step)) + self.retry_max_delay = max(self.retry_initial_delay, float(retry_max_delay)) + if self.retry_delay_step > 0 and self.retry_max_delay > 0 and self.retry_initial_delay > 0: + steps = int(max(0.0, (self.retry_max_delay - self.retry_initial_delay)) / self.retry_delay_step) + self._max_retry_attempts = steps + 1 + elif self.retry_initial_delay > 0 and self.retry_max_delay > 0: + self._max_retry_attempts = 1 + else: + self._max_retry_attempts = 0 + self._sleep = sleep or time.sleep + self._last_success_ts = 0.0 + self._request_lock = threading.Lock() + self._retryable_statuses = {429, 502, 503, 504} @lru_cache(maxsize=4) def list_models(self, kind: ModelType) -> List[Model]: @@ -134,3 +156,29 @@ def _text_prompt_url(self, prompt: str) -> str: from urllib.parse import quote return f"{self.text_prompt_base}/{quote(prompt)}" + def _retry_delay(self, attempt: int) -> float: + if attempt <= 0 or self.retry_initial_delay <= 0: + return 0.0 + if attempt == 1 or self.retry_delay_step <= 0: + return min(self.retry_initial_delay, self.retry_max_delay) + delay = self.retry_initial_delay + (attempt - 1) * self.retry_delay_step + return min(delay, self.retry_max_delay) + + def _can_retry(self, attempt: int) -> bool: + return attempt <= self._max_retry_attempts + + def _wait_before_attempt(self, attempt: int) -> None: + now = time.monotonic() + if attempt == 0: + wait_for = (self._last_success_ts + self.min_request_interval) - now + else: + wait_for = self._retry_delay(attempt) + if wait_for > 0: + self._sleep(wait_for) + + def _mark_success(self) -> None: + self._last_success_ts = time.monotonic() + + def _should_retry_status(self, status: int) -> bool: + return status in self._retryable_statuses + diff --git a/Libs/pollilib/python/polliLib/images.py b/Libs/pollilib/python/polliLib/images.py index f43b203..d8f9522 100644 --- a/Libs/pollilib/python/polliLib/images.py +++ b/Libs/pollilib/python/polliLib/images.py @@ -45,17 +45,37 @@ def generate_image( url = self._image_prompt_url(prompt) eff_timeout = timeout if timeout is not None else max(self.timeout, 60.0) + attempt = 0 + stream = bool(out_path) + response = None + while True: + with self._request_lock: + self._wait_before_attempt(attempt) + resp = self.session.get(url, params=params, timeout=eff_timeout, stream=stream) + if self._should_retry_status(resp.status_code): + if not self._can_retry(attempt + 1): + resp.raise_for_status() + resp.close() + attempt += 1 + continue + try: + resp.raise_for_status() + except Exception: + resp.close() + raise + self._mark_success() + response = resp + break if out_path: - with self.session.get(url, params=params, timeout=eff_timeout, stream=True) as r: - r.raise_for_status() + with response as r: with open(out_path, "wb") as f: for chunk in r.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) return out_path - resp = self.session.get(url, params=params, timeout=eff_timeout) - resp.raise_for_status() - return resp.content + content = response.content + response.close() + return content def save_image_timestamped( self, @@ -112,15 +132,35 @@ def fetch_image( params["referrer"] = referrer if token: params["token"] = token + attempt = 0 + stream = bool(out_path) + response = None + while True: + with self._request_lock: + self._wait_before_attempt(attempt) + resp = self.session.get(image_url, params=params, timeout=timeout or self.timeout, stream=stream) + if self._should_retry_status(resp.status_code): + if not self._can_retry(attempt + 1): + resp.raise_for_status() + resp.close() + attempt += 1 + continue + try: + resp.raise_for_status() + except Exception: + resp.close() + raise + self._mark_success() + response = resp + break if out_path: - with self.session.get(image_url, params=params, timeout=timeout or self.timeout, stream=True) as r: - r.raise_for_status() + with response as r: with open(out_path, "wb") as f: for chunk in r.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) return out_path - resp = self.session.get(image_url, params=params, timeout=timeout or self.timeout) - resp.raise_for_status() - return resp.content + content = response.content + response.close() + return content diff --git a/Libs/pollilib/python/polliLib/stt.py b/Libs/pollilib/python/polliLib/stt.py index f4945b9..aff01c2 100644 --- a/Libs/pollilib/python/polliLib/stt.py +++ b/Libs/pollilib/python/polliLib/stt.py @@ -42,8 +42,27 @@ def transcribe_audio( payload["safe"] = False url = f"{self.text_prompt_base}/{provider}" headers = {"Content-Type": "application/json"} - resp = self.session.post(url, headers=headers, json=payload, timeout=timeout or self.timeout) - resp.raise_for_status() - data = resp.json() + attempt = 0 + response = None + while True: + with self._request_lock: + self._wait_before_attempt(attempt) + resp = self.session.post(url, headers=headers, json=payload, timeout=timeout or self.timeout) + if self._should_retry_status(resp.status_code): + if not self._can_retry(attempt + 1): + resp.raise_for_status() + resp.close() + attempt += 1 + continue + try: + resp.raise_for_status() + except Exception: + resp.close() + raise + self._mark_success() + response = resp + break + data = response.json() + response.close() return data.get("choices", [{}])[0].get("message", {}).get("content") diff --git a/Libs/pollilib/python/polliLib/text.py b/Libs/pollilib/python/polliLib/text.py index 4c6bfe2..ed54a25 100644 --- a/Libs/pollilib/python/polliLib/text.py +++ b/Libs/pollilib/python/polliLib/text.py @@ -35,15 +35,33 @@ def generate_text( params["token"] = token url = self._text_prompt_url(prompt) eff_timeout = timeout if timeout is not None else max(self.timeout, 10.0) - resp = self.session.get(url, params=params, timeout=eff_timeout) - resp.raise_for_status() + attempt = 0 + response = None + while True: + with self._request_lock: + self._wait_before_attempt(attempt) + resp = self.session.get(url, params=params, timeout=eff_timeout) + if self._should_retry_status(resp.status_code): + if not self._can_retry(attempt + 1): + resp.raise_for_status() + resp.close() + attempt += 1 + continue + try: + resp.raise_for_status() + except Exception: + resp.close() + raise + self._mark_success() + response = resp + break if as_json: import json as _json - txt = resp.text + + txt = response.text try: return _json.loads(txt) except Exception: return txt - else: - return resp.text + return response.text diff --git a/Libs/pollilib/python/tests/conftest.py b/Libs/pollilib/python/tests/conftest.py index 688b2cf..6cf8790 100644 --- a/Libs/pollilib/python/tests/conftest.py +++ b/Libs/pollilib/python/tests/conftest.py @@ -50,6 +50,10 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): self._closed = True + return False + + def close(self): + self._closed = True class FakeSession: diff --git a/Libs/pollilib/python/tests/test_text_chat.py b/Libs/pollilib/python/tests/test_text_chat.py index f22c868..fd282d3 100644 --- a/Libs/pollilib/python/tests/test_text_chat.py +++ b/Libs/pollilib/python/tests/test_text_chat.py @@ -117,3 +117,46 @@ def get_current_weather(location: str, unit: str = "celsius"): assert len(fs.posts) == 2 assert all(post[2].get("safe") is False for post in fs.posts) + +def test_generate_text_spacing_enforced(): + class SeqSession(FakeSession): + def __init__(self): + super().__init__() + self.responses = [ + FakeResponse(text="one"), + FakeResponse(text="two"), + ] + + def get(self, url, **kw): + self.last_get = (url, kw) + return self.responses.pop(0) + + sleeps = [] + c = PolliClient(session=SeqSession(), sleep=lambda seconds: sleeps.append(seconds)) + first = c.generate_text("hello") + second = c.generate_text("world") + assert first == "one" and second == "two" + assert any(delay >= 2.99 for delay in sleeps) + + +def test_generate_text_retry_backoff(): + class SeqSession(FakeSession): + def __init__(self): + super().__init__() + self.responses = [ + FakeResponse(status=429, text="limit"), + FakeResponse(status=503, text="busy"), + FakeResponse(text="done"), + ] + + def get(self, url, **kw): + self.last_get = (url, kw) + return self.responses.pop(0) + + sleeps = [] + c = PolliClient(session=SeqSession(), sleep=lambda seconds: sleeps.append(seconds)) + out = c.generate_text("retry") + assert out == "done" + rounded = [round(delay, 1) for delay in sleeps[:2]] + assert rounded == [0.5, 0.6] + diff --git a/docs/APIDOCS.md b/docs/APIDOCS.md index f2b73ef..ce5a45d 100644 --- a/docs/APIDOCS.md +++ b/docs/APIDOCS.md @@ -19,6 +19,7 @@ Click the links below to see examples in your browser: - [Pollinations.AI API Documentation](#pollinationsai-api-documentation) - [Quickstart](#quickstart) - [Summary / Navigation](#summary--navigation) + - [Request Pacing & Backoff ⏱️](#request-pacing--backoff-️) - [Generate Image API 🖼️](#generate-image-api-️) - [1. Text-To-Image (GET) 🖌️](#1-text-to-image-get-️) - [2. List Available Image Models 📜](#2-list-available-image-models-) @@ -37,6 +38,16 @@ Click the links below to see examples in your browser: - [License 📜](#license-) --- +# Request Pacing & Backoff ⏱️ + +Pollinations enforces a minimum spacing between generation requests, especially on the Flower tier that powers this project. To avoid rate-limit walls: + +- Leave at least **3 seconds between successful calls** to the text, image, or audio generation endpoints. +- When a request is rate-limited (HTTP 429/503) you can retry sooner, starting at **500 ms** and increasing by **100 ms** per attempt up to **4 seconds**. +- PolliLib (our JavaScript and Python clients) now performs this pacing automatically: it queues generation requests, guarantees the 3-second post-success gap, and performs the short incremental backoff on retryable errors. + +If you are calling the HTTP APIs directly, adopt the same pattern to stay within the Flower tier guardrails and to minimize hard failures. + # Generate Image API 🖼️ ### 1. Text-To-Image (GET) 🖌️