Integracja, która działa miesiącami, trafi na zerwane połączenie, chwilową niedostępność albo limit
żądań. Ten przepis to mały klient API, który odróżnia błędy przejściowe od stałych, ponawia tylko te
pierwsze, a mutacje ponawia z tym samym Idempotency-Key, żeby ponowienie nie wykonało zapisu
drugi raz.
- Sukces to wyłącznie status HTTP 2xx, brak tablicy
errorsi obiektdata. Odpowiedź, która nie jest odpowiedzią GraphQL (strona HTML, pusta treść, krótki JSON{ code, message }), nigdy nie jest sukcesem, nawet ze statusem 200. - Ponawiaj tylko błędy przejściowe: błąd sieci lub przekroczony czas, każdą odpowiedź HTTP 5xx
(z treścią JSON albo bez),
RATE_LIMITED,INTERNALiAPI_IDEMPOTENCY_IN_PROGRESS. Pozostałe błędy zwracaj od razu: ponowienie niczego nie zmieni. - Czekaj coraz dłużej: 1, 2, 4, 8 sekund z losowym rozrzutem, najwyżej 30 sekund i pięć prób. Gdy
odpowiedź ma nagłówek
Retry-After, czekaj dokładnie tyle. - Mutacje z kluczem idempotencji: wygeneruj
Idempotency-Keyraz, zapisz go razem z operacją i wysyłaj ten sam przy każdej próbie. Szczegóły: Zapisy i idempotencja. - Loguj
requestIdz każdego błędu. Po nim znajdziemy żądanie, gdy poprosisz nas o pomoc.
Przykłady pokazują adres zastępczy. Wpisz adres swojej drukarni, żeby go podmienić.
Skrypt wysyła treść żądania z pliku JSON, np. {"query": "...", "variables": {...}}, i wypisuje
odpowiedź po sukcesie. Wymaga curl i jq.
#!/usr/bin/env bash# prynt-call.sh — wysyła żądanie GraphQL z pliku JSON i ponawia błędy przejściowe (curl, jq).# Użycie: IDEMPOTENCY_KEY=<klucz zapisany razem z operacją> ./prynt-call.sh zadanie.json# Przy zapytaniu (odczycie) IDEMPOTENCY_KEY możesz pominąć.set -uo pipefail
endpoint="https://twoja-drukarnia.prynt.pl/api/v1/graphql"response=$(mktemp) headers=$(mktemp)trap 'rm -f "$response" "$headers"' EXITdelay=1
for attempt in 1 2 3 4 5; do : >"$response"; : >"$headers" # nic z poprzedniej próby status=$(curl -sS --max-time 30 -o "$response" -D "$headers" -w '%{http_code}' "$endpoint" \ -H "Authorization: Bearer $PRYNT_API_KEY" \ -H "Content-Type: application/json" \ ${IDEMPOTENCY_KEY:+-H "Idempotency-Key: $IDEMPOTENCY_KEY"} \ --data-binary @"$1") || status=000 # 000: błąd sieci albo przekroczony czas retry_after=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' "$headers")
# Sukces to wyłącznie status 2xx, brak `errors` i obiekt `data`. if [[ $status == 2?? ]] && jq -e '((.errors // []) | length == 0) and (.data | type == "object")' "$response" >/dev/null 2>&1; then cat "$response"; exit 0 fi if jq -e '(.errors // []) | length > 0' "$response" >/dev/null 2>&1; then reason=$(jq -r '.errors[0].extensions | "\(.code)/\(.subCode // "")"' "$response") else # Odpowiedź spoza GraphQL: serwer niedostępny (5xx) albo zły adres. reason="$(jq -r '.code // empty' "$response" 2>/dev/null || true)/HTTP $status" fi if [[ $status != 000 ]] && ((10#$status < 500)); then # 000 (błąd sieci) i 5xx: ponawiaj case "$reason" in RATE_LIMITED/* | INTERNAL/* | CONFLICT/API_IDEMPOTENCY_IN_PROGRESS) ;; *) echo "$reason" >&2; cat "$response" >&2; exit 1 ;; # błąd stały: popraw żądanie esac fi sleep "${retry_after:-$((delay + RANDOM % delay))}" delay=$((delay * 2 > 30 ? 30 : delay * 2))doneecho "Brak sukcesu po 5 próbach ($reason)" >&2exit 1IDEMPOTENCY_KEY=$(uuidgen) ./prynt-call.sh mutacja.json// prynt-client.mjs — Node.js 18+ (wbudowany fetch)const ENDPOINT = 'https://twoja-drukarnia.prynt.pl/api/v1/graphql';const MAX_ATTEMPTS = 5;const MAX_DELAY_MS = 30_000;
/** Błąd API: kody z `extensions`, `requestId` do logów i dane częściowego sukcesu. */export class PryntApiError extends Error { constructor(errors, requestId, data) { super(errors[0]?.message ?? 'Prynt API error'); this.errors = errors; this.code = errors[0]?.extensions?.code; this.subCode = errors[0]?.extensions?.subCode; this.requestId = requestId; this.data = data; }}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
const isTransient = (status, error) => status >= 500 || error.code === 'RATE_LIMITED' || error.code === 'INTERNAL' || error.subCode === 'API_IDEMPOTENCY_IN_PROGRESS';
/** Błąd odpowiedzi, która nie jest odpowiedzią GraphQL (np. HTML albo `{ code, message }`). */const httpError = (status, body, requestId) => new PryntApiError( [{ message: body?.message ?? `HTTP ${status}`, extensions: { code: body?.code } }], requestId, );
const errorsOf = (result) => Array.isArray(result?.errors) && result.errors.length > 0 ? result.errors : null;
const retryAfterMs = (response) => { const seconds = Number(response.headers.get('retry-after')); return seconds > 0 ? seconds * 1000 : undefined;};
/** Odpowiedź API: dane albo decyzja o ponowieniu. Błąd stały rzuca PryntApiError. */async function outcomeOf(response) { const requestId = response.headers.get('x-request-id'); const result = await response.json().catch(() => null); const errors = errorsOf(result); // Sukces to wyłącznie status 2xx, brak `errors` i obiekt `data`. if (response.ok && !errors && isObject(result?.data)) return { data: result.data }; const error = errors ? new PryntApiError(errors, requestId, result.data) : httpError(response.status, result, requestId); if (!isTransient(response.status, error)) throw error; return { retry: true, cause: error, waitMs: retryAfterMs(response) };}
/** Jedna próba: dane albo decyzja o ponowieniu. */async function attempt({ query, variables, idempotencyKey }) { let response; try { response = await fetch(ENDPOINT, { method: 'POST', headers: { Authorization: `Bearer ${process.env.PRYNT_API_KEY}`, 'Content-Type': 'application/json', ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }), }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(30_000), }); } catch (error) { return { retry: true, cause: error }; // błąd sieci albo przekroczony czas } return outcomeOf(response);}
/** * Wysyła operację GraphQL i ponawia błędy przejściowe z rosnącym odstępem. * Mutacji podaj `idempotencyKey` zapisany przed pierwszą próbą. */export async function pryntRequest(operation) { let delay = 1000; for (let tries = 1; ; tries += 1) { const outcome = await attempt(operation); if (!outcome.retry) return outcome.data; if (tries === MAX_ATTEMPTS) throw outcome.cause; await sleep(outcome.waitMs ?? delay + Math.random() * delay); delay = Math.min(delay * 2, MAX_DELAY_MS); }}import { randomUUID } from 'node:crypto';import { readFileSync } from 'node:fs';import { pryntRequest } from './prynt-client.mjs';
const query = readFileSync('mutacja.graphql', 'utf8');const idempotencyKey = randomUUID(); // zapisz go razem z operacją, zanim wyślesz żądanieconst data = await pryntRequest({ query, variables: {}, idempotencyKey });# prynt_client.py — Python 3.9+, biblioteka requestsimport osimport randomimport time
import requests
ENDPOINT = "https://twoja-drukarnia.prynt.pl/api/v1/graphql"MAX_ATTEMPTS = 5MAX_DELAY = 30.0
class PryntApiError(Exception): """Błąd API: kody z `extensions`, `request_id` do logów i dane częściowego sukcesu."""
def __init__(self, errors, request_id=None, data=None): super().__init__(errors[0].get("message", "Prynt API error")) extensions = errors[0].get("extensions", {}) self.errors = errors self.code = extensions.get("code") self.sub_code = extensions.get("subCode") self.request_id = request_id self.data = data
class _Retry(Exception): """Błąd przejściowy: ponów po `after` sekundach (0 = odstęp wykładniczy)."""
def __init__(self, cause, after=0.0): super().__init__(str(cause)) self.cause = cause self.after = after
def _attempt(query, variables, idempotency_key): headers = {"Authorization": f"Bearer {os.environ['PRYNT_API_KEY']}"} if idempotency_key: headers["Idempotency-Key"] = idempotency_key try: response = requests.post( ENDPOINT, headers=headers, json={"query": query, "variables": variables or {}}, timeout=30, ) except (requests.ConnectionError, requests.Timeout) as error: raise _Retry(error) # błąd sieci albo przekroczony czas request_id = response.headers.get("X-Request-ID") try: result = response.json() except ValueError: result = None # odpowiedź bez JSON, np. strona HTML body = result if isinstance(result, dict) else {} errors, data = body.get("errors"), body.get("data") # Sukces to wyłącznie status 2xx, brak `errors` i obiekt `data`. if 200 <= response.status_code < 300 and not errors and isinstance(data, dict): return data if errors: error = PryntApiError(errors, request_id, data) else: # Odpowiedź spoza GraphQL: serwer niedostępny (5xx) albo zły adres. message = body.get("message") or f"HTTP {response.status_code}" error = PryntApiError([{"message": message, "extensions": {"code": body.get("code")}}], request_id) if ( response.status_code >= 500 or error.code in ("RATE_LIMITED", "INTERNAL") or error.sub_code == "API_IDEMPOTENCY_IN_PROGRESS" ): raise _Retry(error, float(response.headers.get("Retry-After") or 0)) raise error
def prynt_request(query, variables=None, idempotency_key=None): """Wysyła operację GraphQL i ponawia błędy przejściowe z rosnącym odstępem. Mutacji podaj idempotency_key zapisany przed pierwszą próbą.""" delay = 1.0 for attempt in range(1, MAX_ATTEMPTS + 1): try: return _attempt(query, variables, idempotency_key) except _Retry as retry: if attempt == MAX_ATTEMPTS: raise retry.cause time.sleep(retry.after or delay + random.uniform(0, delay)) delay = min(delay * 2, MAX_DELAY)import uuidfrom pathlib import Path
from prynt_client import prynt_request
query = Path("mutacja.graphql").read_text()idempotency_key = str(uuid.uuid4()) # zapisz go razem z operacją, zanim wyślesz żądaniedata = prynt_request(query, {}, idempotency_key=idempotency_key)<?php// PryntClient.php — PHP 8.1+, rozszerzenie curl
final class PryntApiError extends RuntimeException{ public function __construct( public readonly array $errors, public readonly ?string $requestId = null, public readonly mixed $data = null, ) { parent::__construct($errors[0]['message'] ?? 'Prynt API error'); }
public function code(): ?string { return $this->errors[0]['extensions']['code'] ?? null; }
public function subCode(): ?string { return $this->errors[0]['extensions']['subCode'] ?? null; }}
final class PryntClient{ private const ENDPOINT = 'https://twoja-drukarnia.prynt.pl/api/v1/graphql'; private const MAX_ATTEMPTS = 5; private const MAX_DELAY = 30.0;
/** Wysyła operację i ponawia błędy przejściowe. Mutacji podaj klucz zapisany przed pierwszą próbą. */ public function request(string $query, array $variables = [], ?string $idempotencyKey = null): mixed { $delay = 1.0; for ($attempt = 1; ; $attempt++) { [$data, $retryAfter, $cause] = $this->attempt($query, $variables, $idempotencyKey); if ($retryAfter === null) { return $data; } if ($attempt === self::MAX_ATTEMPTS) { throw $cause; } $wait = $retryAfter > 0 ? $retryAfter : $delay + mt_rand() / mt_getrandmax() * $delay; usleep((int) ($wait * 1_000_000)); $delay = min($delay * 2, self::MAX_DELAY); } }
/** @return array{0: mixed, 1: ?float, 2: ?Throwable} dane albo sekundy do ponowienia (0 = odstęp wykładniczy) */ private function attempt(string $query, array $variables, ?string $idempotencyKey): array { $headers = []; $ch = curl_init(self::ENDPOINT); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => array_filter([ 'Authorization: Bearer ' . getenv('PRYNT_API_KEY'), 'Content-Type: application/json', $idempotencyKey !== null ? "Idempotency-Key: {$idempotencyKey}" : null, ]), CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => (object) $variables]), CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int { [$name, $value] = array_pad(explode(':', $line, 2), 2, ''); $headers[strtolower(trim($name))] = trim($value); return strlen($line); }, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($body === false) { return [null, 0.0, new RuntimeException(curl_error($ch))]; // błąd sieci albo przekroczony czas } $requestId = $headers['x-request-id'] ?? null; $result = json_decode($body, true); $result = is_array($result) ? $result : []; $errors = $result['errors'] ?? []; $data = $result['data'] ?? null; // Sukces to wyłącznie status 2xx, brak `errors` i obiekt `data`. if ($status >= 200 && $status < 300 && empty($errors) && is_array($data)) { return [$data, null, null]; } $error = !empty($errors) ? new PryntApiError($errors, $requestId, $data) // Odpowiedź spoza GraphQL: serwer niedostępny (5xx) albo zły adres. : new PryntApiError([[ 'message' => $result['message'] ?? "HTTP {$status}", 'extensions' => ['code' => $result['code'] ?? null], ]], $requestId); $transient = $status >= 500 || in_array($error->code(), ['RATE_LIMITED', 'INTERNAL'], true) || $error->subCode() === 'API_IDEMPOTENCY_IN_PROGRESS'; if (!$transient) { throw $error; } return [null, (float) ($headers['retry-after'] ?? 0), $error]; }}<?phprequire __DIR__ . '/PryntClient.php';
$query = file_get_contents(__DIR__ . '/mutacja.graphql');$idempotencyKey = bin2hex(random_bytes(16)); // zapisz go razem z operacją, zanim wyślesz żądanie$data = (new PryntClient())->request($query, [], $idempotencyKey);Co dalej
Dział zatytułowany „Co dalej”- Błąd stały ma kategorię w
codei szczegóły wsubCodeorazparams. Co zrobić z każdym z nich, opisuje strona Błędy. - Częściowy sukces (dane i błędy naraz) klient zwraca jako błąd z danymi w polu
data. Zdecyduj, czy dla Twojej operacji to sukces. Gdy pierwszy błąd ma kodINTERNAL, klient najpierw ponawia żądanie: zIdempotency-Keydostaje wtedy tę samą zapisaną odpowiedź, bez drugiego zapisu. - Integracja, która często dostaje
RATE_LIMITED, wysyła za dużo żądań. Zobacz Limity.