Limity i ponawianie
Limity
Każdy klient integracji ma „wiadro tokenów” wspólne dla wszystkich jego danych dostępowych i tokenów:
- 20 żądań na minutę (jeden token co 3 sekundy),
- chwilowo do 30 żądań,
- 5000 żądań na dobę (doba UTC).
Umowa Twojej organizacji może przewidywać wyższe limity; administrator IT może też ustawić klientowi niższe (np. klientowi
testowemu). Punkt wydawania tokenów (POST /api/v1/oauth/token) ma osobne limity: 60 żądań na minutę z jednego adresu
i 10 wydanych tokenów na minutę na klienta.
Nagłówki
Każda odpowiedź dla klienta integracji podaje stan jego limitu:
| Nagłówek | Znaczenie |
|---|---|
RateLimit-Limit |
Pojemność wiadra (maksymalna chwilowa liczba żądań). |
RateLimit-Remaining |
Żądania dostępne teraz (z uwzględnieniem limitu dobowego). |
RateLimit-Reset |
Sekundy do zapełnienia wiadra; po wyczerpaniu limitu dobowego — do północy UTC. |
RateLimit-Policy |
Zasada limitu, np. 20;w=60;burst=30, 5000;w=86400. |
Retry-After |
Tylko przy 429: sekundy do pierwszego dozwolonego żądania. |
Gdy dostaniesz 429
429 RATE_LIMITED oznacza, że żądanie nie zostało wykonane i nie zużyło limitu. Odczekaj Retry-After sekund i
ponów. Nie ponawiaj w ciasnej pętli — powtarzane nieudane uwierzytelnienia (20 na minutę) blokują adres źródłowy na
10 minut (429 TOO_MANY_FAILED_AUTHENTICATIONS).
Bezpieczne ponawianie
- Ponawiaj
429,502,503i504oraz błędy sieci z wykładniczym odstępem i losowym rozrzutem (np. 1 s, 2 s, 4 s …, maksymalnie 60 s), respektującRetry-After, jeśli jest. - Nie ponawiaj innych błędów
4xxbez zmiany żądania. POST /api/v1/usersiPOST /api/v1/enrollmentsodrzucają duplikaty kodem409, więc ponowienie utworzenia po przekroczeniu czasu jest bezpieczne: traktuj409jako „już istnieje” i wyszukaj zasób.
BASE_URL="${BASE_URL:-https://api.elearning.edu.pl}"
# --retry covers 408, 429, 500, 502, 503 and 504 and honours Retry-After; --retry-max-time caps the total wait.
curl --fail-with-body -sS --retry 5 --retry-max-time 120 \
"$BASE_URL/api/v1/users?limit=1" \
-H "X-Tenant-Code: $TENANT_CODE" \
-H "X-API-Key: $API_KEY"import os
import random
import time
import requests
BASE_URL = os.environ.get("BASE_URL", "https://api.elearning.edu.pl")
RETRY_STATUSES = {429, 502, 503, 504}
def request_with_retry(session: requests.Session, method: str, url: str, attempts: int = 6, **kwargs) -> requests.Response:
"""Retries 429/502/503/504 and network errors with exponential backoff and jitter, honouring Retry-After."""
for attempt in range(attempts):
try:
response = session.request(method, url, timeout=30, **kwargs)
except (requests.ConnectionError, requests.Timeout):
response = None
if response is not None and response.status_code not in RETRY_STATUSES:
return response
if attempt == attempts - 1:
break
retry_after = response.headers.get("Retry-After") if response is not None else None
delay = int(retry_after) if retry_after and retry_after.isdigit() else min(60, 2 ** attempt) + random.random()
time.sleep(delay)
if response is None:
raise ConnectionError(f"{method} {url}: no response after {attempts} attempts")
return response
session = requests.Session()
session.headers.update({"X-Tenant-Code": os.environ["TENANT_CODE"], "X-API-Key": os.environ["API_KEY"]})
response = request_with_retry(session, "GET", f"{BASE_URL}/api/v1/users", params={"limit": 1})
response.raise_for_status()
print(response.headers.get("RateLimit-Remaining"), "requests left")<?php
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
const MAX_RETRIES = 5;
// Retries 429/502/503/504 and connection errors with exponential backoff and jitter, honouring Retry-After.
$stack = HandlerStack::create();
$stack->push(Middleware::retry(
function (int $retries, RequestInterface $request, ?ResponseInterface $response = null, ?Throwable $error = null): bool {
if ($retries >= MAX_RETRIES) {
return false;
}
return $error instanceof GuzzleHttp\Exception\ConnectException
|| in_array($response?->getStatusCode(), [429, 502, 503, 504], true);
},
function (int $retries, ?ResponseInterface $response = null): int {
$retryAfter = $response?->getHeaderLine('Retry-After');
if ($retryAfter !== null && ctype_digit($retryAfter)) {
return (int) $retryAfter * 1000;
}
return min(60_000, 1000 * 2 ** ($retries - 1)) + random_int(0, 1000);
},
));
$client = new GuzzleHttp\Client([
'handler' => $stack,
'base_uri' => getenv('BASE_URL') ?: 'https://api.elearning.edu.pl',
'timeout' => 30,
'headers' => [
'X-Tenant-Code' => getenv('TENANT_CODE'),
'X-API-Key' => getenv('API_KEY'),
],
]);
$response = $client->get('/api/v1/users', ['query' => ['limit' => 1]]);
echo $response->getHeaderLine('RateLimit-Remaining'), ' requests left', PHP_EOL;package main
import (
"fmt"
"math/rand/v2"
"net/http"
"os"
"strconv"
"time"
)
// doWithRetry retries 429/502/503/504 and network errors with exponential backoff and jitter, honouring Retry-After.
// newRequest builds a fresh request for every attempt (a request body can be read only once).
func doWithRetry(newRequest func() (*http.Request, error), attempts int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
req, err := newRequest()
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err == nil {
switch res.StatusCode {
case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
lastErr = fmt.Errorf("%s %s: %s", req.Method, req.URL.Path, res.Status)
default:
return res, nil
}
} else {
lastErr = err
}
if attempt == attempts-1 {
break
}
delay := min(60*time.Second, time.Second<<attempt) + time.Duration(rand.Int64N(int64(time.Second)))
if res != nil {
if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
res.Body.Close()
}
time.Sleep(delay)
}
return nil, lastErr
}
func main() {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
baseURL = "https://api.elearning.edu.pl"
}
res, err := doWithRetry(func() (*http.Request, error) {
req, err := http.NewRequest("GET", baseURL+"/api/v1/users?limit=1", nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Tenant-Code", os.Getenv("TENANT_CODE"))
req.Header.Set("X-API-Key", os.Getenv("API_KEY"))
return req, nil
}, 6)
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
fmt.Fprintln(os.Stderr, "request failed:", res.Status)
os.Exit(1)
}
fmt.Println(res.Header.Get("RateLimit-Remaining"), "requests left")
}const BASE_URL = process.env.BASE_URL ?? "https://api.elearning.edu.pl";
const RETRY_STATUSES = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Retries 429/502/503/504 and network errors with exponential backoff and jitter, honouring Retry-After.
async function fetchWithRetry(url, init = {}, attempts = 6) {
for (let attempt = 0; ; attempt++) {
let response;
try {
response = await fetch(url, init);
if (!RETRY_STATUSES.has(response.status)) return response;
} catch (error) {
if (attempt === attempts - 1) throw error;
}
if (attempt === attempts - 1) return response;
const retryAfter = Number(response?.headers.get("Retry-After"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : Math.min(60_000, 1000 * 2 ** attempt) + Math.random() * 1000);
}
}
const response = await fetchWithRetry(`${BASE_URL}/api/v1/users?limit=1`, {
headers: { "X-Tenant-Code": process.env.TENANT_CODE ?? "", "X-API-Key": process.env.API_KEY ?? "" },
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
console.log(response.headers.get("RateLimit-Remaining"), "requests left");using System.Net;
var baseUrl = Environment.GetEnvironmentVariable("BASE_URL") ?? "https://api.elearning.edu.pl";
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
http.DefaultRequestHeaders.Add("X-Tenant-Code", Environment.GetEnvironmentVariable("TENANT_CODE"));
http.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("API_KEY"));
using var response = await SendWithRetry(http, () => new HttpRequestMessage(HttpMethod.Get, "/api/v1/users?limit=1"));
response.EnsureSuccessStatusCode();
var remaining = response.Headers.TryGetValues("RateLimit-Remaining", out var values) ? values.First() : "?";
Console.WriteLine($"{remaining} requests left");
// Retries 429/502/503/504 and network errors with exponential backoff and jitter, honouring Retry-After.
// (With Microsoft.Extensions.Http.Resilience, AddStandardResilienceHandler() gives you the same.)
static async Task<HttpResponseMessage> SendWithRetry(HttpClient http, Func<HttpRequestMessage> newRequest, int attempts = 6)
{
HttpStatusCode[] retry = [HttpStatusCode.TooManyRequests, HttpStatusCode.BadGateway, HttpStatusCode.ServiceUnavailable, HttpStatusCode.GatewayTimeout];
for (var attempt = 0; ; attempt++)
{
HttpResponseMessage? response = null;
try
{
response = await http.SendAsync(newRequest());
if (!retry.Contains(response.StatusCode) || attempt == attempts - 1) return response;
}
catch (HttpRequestException) when (attempt < attempts - 1)
{
}
var delay = response?.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(Math.Min(60, Math.Pow(2, attempt)) + Random.Shared.NextDouble());
response?.Dispose();
await Task.Delay(delay);
}
}import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
public class Example {
static final Set<Integer> RETRY_STATUSES = Set.of(429, 502, 503, 504);
// Retries 429/502/503/504 and network errors with exponential backoff and jitter, honouring Retry-After.
static HttpResponse<String> sendWithRetry(HttpClient http, HttpRequest request, int attempts) throws Exception {
for (int attempt = 0; ; attempt++) {
HttpResponse<String> response = null;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (!RETRY_STATUSES.contains(response.statusCode()) || attempt == attempts - 1) {
return response;
}
} catch (IOException e) {
if (attempt == attempts - 1) {
throw e;
}
}
long delayMs = Math.min(60_000, 1000L << attempt) + ThreadLocalRandom.current().nextLong(1000);
if (response != null) {
String retryAfter = response.headers().firstValue("Retry-After").orElse("");
if (retryAfter.matches("\\d+")) {
delayMs = Long.parseLong(retryAfter) * 1000;
}
}
Thread.sleep(delayMs);
}
}
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv().getOrDefault("BASE_URL", "https://api.elearning.edu.pl");
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/api/v1/users?limit=1"))
.header("X-Tenant-Code", System.getenv("TENANT_CODE"))
.header("X-API-Key", System.getenv("API_KEY"))
.GET()
.build();
HttpResponse<String> response = sendWithRetry(HttpClient.newHttpClient(), request, 6);
if (response.statusCode() != 200) {
throw new IllegalStateException(response.statusCode() + " " + response.body());
}
System.out.println(response.headers().firstValue("RateLimit-Remaining").orElse("?") + " requests left");
}
}Synchronizacja masowa
Przy pierwszym ładowaniu wielu tysięcy osób rozłóż wywołania w czasie albo poproś opiekuna wdrożenia o czasowe podniesienie limitu. Do bieżącego zasilania kont z dostawcy tożsamości zwykle lepiej nadaje się SCIM.