Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
- REST — архитектурный стиль (диссертация Роя Филдинга, 2000), а не протокол. Ключевые ограничения: client-server, stateless, cacheable, layered system, uniform interface, опционально code-on-demand.
- Ресурс — абстрактная сущность, у неё есть представления (JSON/XML/…). URI идентифицирует ресурс, метод выражает действие. Не глаголы в URI (
/users/42, а не/getUser?id=42). - Уровни зрелости Ричардсона (RMM): L0 — один эндпоинт (RPC поверх HTTP), L1 — ресурсы, L2 — HTTP-методы + статусы (де-факто отраслевой стандарт), L3 — HATEOAS (гиперссылки).
- Семантика методов: GET/HEAD/OPTIONS — safe; GET/HEAD/PUT/DELETE/OPTIONS — idempotent; POST/PATCH — нет. Кэшируются по умолчанию GET/HEAD.
- Идемпотентность = N одинаковых запросов дают тот же эффект на сервере, что и один. PUT идемпотентен по дизайну, POST — нет → для платежей нужен Idempotency-Key.
- Версионирование: URL path (
/v1/) — просто и видно; media-type черезAccept— «правильно» по REST, но сложнее в эксплуатации; query param — компромисс. - Пагинация: offset/limit прост, но дрейфует и медленный на больших offset; cursor/keyset — стабилен и быстр (
WHERE (created_at, id) < (?, ?)). - Ошибки: единый формат RFC 7807/9457 Problem Details (
application/problem+json), стабильные машинные коды, никаких stack trace и SQL наружу.
Теория#
1. Принципы REST (ограничения архитектуры)#
REST задаётся набором архитектурных ограничений (constraints). Соблюдение даёт масштабируемость, кэшируемость и слабую связанность.
| Ограничение | Суть | Практический вывод |
|---|---|---|
| Client–Server | Разделение UI и хранения данных | Независимая эволюция фронта и бэка |
| Stateless | Каждый запрос самодостаточен; сервер не хранит сессионное состояние между запросами | Аутентификация в каждом запросе (токен), любой инстанс обработает запрос → горизонтальное масштабирование |
| Cacheable | Ответы помечают себя как кэшируемые/нет | Cache-Control, ETag, Expires |
| Uniform Interface | Единообразный интерфейс: идентификация ресурсов, манипуляция через представления, self-descriptive messages, HATEOAS | Предсказуемость, переиспользование инфраструктуры (прокси, CDN) |
| Layered System | Клиент не знает, говорит ли он с сервером напрямую или через прокси/LB | Можно ставить gateway, кэш, балансировщик прозрачно |
| Code-on-Demand (опц.) | Сервер может прислать код (JS) | Редко применимо к API |
Stateless на практике: «состояние» бизнес-данных живёт в БД и идентифицируется URI; «состояние приложения» (где в навигации находится клиент) держит клиент. Серверные HTTP-сессии в памяти нарушают stateless и ломают масштабирование — отсюда переход на JWT/токены.
Ресурс vs представление: ресурс GET /users/42 может отдаваться как JSON или CSV в зависимости от Accept (content negotiation). Один ресурс — несколько представлений.
Уровни зрелости Ричардсона (Richardson Maturity Model)#
| Уровень | Название | Описание | Пример |
|---|---|---|---|
| 0 | The Swamp of POX | Один URI, один метод (обычно POST), RPC поверх HTTP | POST /api с телом {"method":"getUser"} (SOAP, многие JSON-RPC) |
| 1 | Resources | Много URI-ресурсов, но всё ещё один метод | POST /users/42, POST /orders/7 |
| 2 | HTTP Verbs | Используются методы и статус-коды по семантике | GET /users/42 → 200, DELETE /users/42 → 204 |
| 3 | Hypermedia (HATEOAS) | Ответы содержат ссылки на возможные переходы | в ответе _links: {cancel: ...} |
На практике 99% «REST» API живут на уровне 2 и это считается достаточным.
2. HTTP-методы и семантика#
| Метод | Safe (без side-effect) | Idempotent | Cacheable | Тело запроса | Типичный код успеха |
|---|---|---|---|---|---|
| GET | ✅ | ✅ | ✅ | нет | 200 |
| HEAD | ✅ | ✅ | ✅ | нет | 200 (без тела) |
| OPTIONS | ✅ | ✅ | ❌ | нет | 200/204 |
| POST | ❌ | ❌ | ⚠️ только с Cache-Control/Expires | да | 201/200/202 |
| PUT | ❌ | ✅ | ❌ | да | 200/204 |
| PATCH | ❌ | ❌ (не гарантируется) | ❌ | да | 200/204 |
| DELETE | ❌ | ✅ | ❌ | опц. | 204/200 |
- Safe — не меняет состояние ресурса (можно префетчить, кэшировать, ретраить без последствий).
- Idempotent — повтор даёт тот же серверный эффект. DELETE: первый удаляет (204), повтор → 404, но состояние одинаково (ресурса нет) → идемпотентен.
- PUT vs POST: PUT — «положи ресурс по этому URI» (клиент знает URI, полная замена). POST — «создай подчинённый ресурс / выполни действие», сервер назначает URI (
Locationв ответе). - PATCH — частичное обновление. Идемпотентность зависит от семантики патча: JSON Merge Patch (RFC 7386) с абсолютными значениями обычно идемпотентен; JSON Patch (RFC 6902) с операцией
addв массив — нет.
PUT /users/42 HTTP/1.1
Content-Type: application/json
{"name": "Alice", "email": "alice@example.com"}PUT с этим телом можно повторить 100 раз — результат один.
POST /users HTTP/1.1
Content-Type: application/json
{"name": "Alice"}
HTTP/1.1 201 Created
Location: /users/433. Идемпотентность и Idempotency-Key#
POST неидемпотентен → при сетевом таймауте клиент не знает, прошёл ли платёж, и ретрай создаст дубль списания. Решение — клиент генерирует уникальный ключ и шлёт его в заголовке; сервер дедуплицирует.
POST /payments HTTP/1.1
Idempotency-Key: 9f1c2b7e-4a3d-4c8e-9f0a-1b2c3d4e5f60
Content-Type: application/json
{"amount": 1000, "currency": "USD", "account": "acc_1"}Принципы реализации (как у Stripe):
- Ключ уникален для логической операции (UUID v4), хранится с TTL (например 24ч).
- Атомарно «застолбить» ключ (insert с unique-индексом /
SETNXв Redis). - На повтор с тем же ключом — вернуть сохранённый ответ, не выполняя операцию заново.
- Желательно проверять, что тело запроса совпадает с первым (иначе 422 — конфликт ключа).
- Обрабатывать гонку: два параллельных запроса с одним ключом → один выполняет, второй ждёт/получает 409.
func (s *Server) CreatePayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
writeProblem(w, http.StatusBadRequest, "missing-idempotency-key",
"Idempotency-Key header is required")
return
}
body, _ := io.ReadAll(r.Body)
reqHash := sha256.Sum256(body)
// Атомарно пытаемся застолбить ключ. Уникальный индекс на idempotency_key.
rec, err := s.store.AcquireKey(r.Context(), key, reqHash[:])
switch {
case errors.Is(err, ErrKeyInProgress):
w.WriteHeader(http.StatusConflict) // запрос с тем же ключом ещё выполняется
return
case rec != nil && rec.Completed:
if !bytes.Equal(rec.RequestHash, reqHash[:]) {
writeProblem(w, http.StatusUnprocessableEntity, "idempotency-key-reuse",
"key already used with a different request body")
return
}
w.WriteHeader(rec.StatusCode) // воспроизводим сохранённый ответ
w.Write(rec.ResponseBody)
return
}
// Первый запрос с этим ключом — выполняем операцию в транзакции
resp, status := s.processPayment(r.Context(), body)
s.store.CompleteKey(r.Context(), key, status, resp)
w.WriteHeader(status)
w.Write(resp)
}Ключевая идея: идемпотентность достигается хранением результата по ключу, а не «магией» HTTP. Сама операция списания должна быть в транзакции вместе с записью статуса ключа, иначе при падении между ними получите неконсистентность.
4. HTTP статус-коды#
| Код | Имя | Когда использовать |
|---|---|---|
| 200 | OK | Успех с телом (GET, обновление с возвратом) |
| 201 | Created | Ресурс создан; вернуть Location |
| 202 | Accepted | Принято в асинхронную обработку (очередь), результата ещё нет |
| 204 | No Content | Успех без тела (DELETE, PUT без возврата) |
| 301 | Moved Permanently | Ресурс переехал навсегда (меняйте URL) |
| 304 | Not Modified | Conditional GET: ресурс не изменился (If-None-Match/ETag) — экономит трафик |
| 400 | Bad Request | Синтаксически некорректный запрос (битый JSON, неверный тип) |
| 401 | Unauthorized | Нет/невалидная аутентификация (по факту «не аутентифицирован») |
| 403 | Forbidden | Аутентифицирован, но нет прав на ресурс |
| 404 | Not Found | Ресурс не найден (или скрываем существование вместо 403) |
| 409 | Conflict | Конфликт состояния: дубликат, нарушение версии (optimistic lock), параллельный idempotency-key |
| 422 | Unprocessable Entity | Синтаксис верный, но семантическая/валидационная ошибка (бизнес-правила) |
| 429 | Too Many Requests | Rate limit; вернуть Retry-After |
| 500 | Internal Server Error | Необработанная ошибка сервера |
| 502 | Bad Gateway | Апстрим вернул невалидный ответ (прокси/gateway) |
| 503 | Service Unavailable | Сервис недоступен/перегружен/maintenance; Retry-After |
| 504 | Gateway Timeout | Апстрим не ответил вовремя |
400 vs 422: 400 — клиент прислал то, что сервер не может разобрать (битый JSON, отсутствует обязательное поле на уровне формата). 422 — разобрали, но не прошла валидация бизнес-правил (age = -5, email уже занят). Граница условна; главное — последовательность внутри API.
401 vs 403: 401 — «кто ты?» (нет/протух токен → надо переаутентифицироваться). 403 — «знаю кто ты, но нельзя».
5. Версионирование API#
Версия нужна для breaking changes: удаление/переименование полей, изменение типа, изменение семантики, ужесточение валидации. Неразрушающие (добавление опционального поля, нового эндпоинта) версии не требуют — клиенты должны игнорировать неизвестные поля (правило толерантного ридера).
| Подход | Пример | Плюсы | Минусы |
|---|---|---|---|
| URL path | GET /v1/users | Просто, видно, легко роутить/кэшировать, удобно в браузере | «Не по REST» (URI должен идентифицировать ресурс, а не версию), дублирование роутов |
| Header / media-type | Accept: application/vnd.api.v2+json | Чисто по REST, URI стабилен | Сложнее тестировать/кэшировать, невидимо, ошибки легко не заметить |
| Query param | GET /users?version=2 | Просто добавить | Засоряет кэш-ключи, легко забыть, неоднозначно |
| Custom header | X-API-Version: 2 | URI стабилен | Нестандартно, та же невидимость |
На практике для публичных API чаще берут URL path (GitHub, Stripe — Stripe ещё привязывает версию к аккаунту/дате). Для строгого REST — media-type versioning.
Дополнительно:
- Политика deprecation: заголовки
Deprecation,Sunset, ссылка на доку; срок поддержки старой версии. - Минимизируйте число живых версий (поддержка дорогая). Иногда лучше аддитивные изменения без новой версии.
6. Пагинация#
Offset / Limit#
GET /orders?limit=20&offset=40- ✅ Просто, позволяет прыгать на произвольную страницу.
- ❌ Дрейф данных: вставка/удаление между запросами сдвигает окно → дубли или пропуски.
- ❌ Производительность:
OFFSET 100000заставляет БД отсчитать и выбросить 100k строк (O(offset)).
Cursor / Keyset pagination#
Курсор кодирует «откуда продолжить» (значения сортируемых колонок последней строки), а не номер строки.
GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0wMVQxMDowMDowMFoiLCJpZCI6MTAwfQКурсор обычно — base64 от {created_at, id}. SQL (keyset):
SELECT * FROM orders
WHERE (created_at, id) < ($1, $2) -- строгая сортировка по уникальному ключу
ORDER BY created_at DESC, id DESC
LIMIT 20;- ✅ Стабильно при вставках/удалениях (нет дрейфа окна).
- ✅ Быстро: индекс по
(created_at, id)→O(log n), не зависит от глубины. - ❌ Нельзя прыгнуть на «страницу 500», только next/prev.
- ❌ Сортировка должна включать уникальный тай-брейкер (
id), иначе пропуски при равныхcreated_at.
Ответ с навигацией (стиль ссылок next):
{
"data": [ ... ],
"pagination": {
"next_cursor": "eyJjcmVhdGVkX2F0Ijoi...",
"has_more": true
}
}Link: <https://api.example.com/orders?cursor=...&limit=20>; rel="next"Рекомендация для больших/часто меняющихся коллекций — cursor/keyset. Offset оставляйте для небольших админ-таблиц с произвольным переходом по страницам.
7. Ошибки API: RFC 7807 / 9457 (Problem Details)#
Единый машиночитаемый формат. Content-Type — application/problem+json.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"detail": "The 'email' field must be a valid address",
"instance": "/users",
"code": "VALIDATION_ERROR",
"errors": [
{"field": "email", "message": "invalid format"}
],
"trace_id": "abc-123"
}Поля: type (URI-идентификатор класса ошибки, стабилен), title (человекочитаемое, неизменное для типа), status, detail (про конкретный случай), instance (про конкретный ресурс/запрос). Расширения (code, errors, trace_id) допускаются.
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Code string `json:"code,omitempty"`
Errors []FieldError `json:"errors,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, code, detail string) {
p := Problem{
Type: "https://api.example.com/errors/" + code,
Title: http.StatusText(status),
Status: status,
Code: code,
Detail: detail,
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(p)
}Правила:
- Стабильные машинные
code— клиенты ветвятся по ним, а не по тексту/HTTP-коду. - Не светить internals: никаких stack trace, SQL, путей файлов, имён таблиц в ответе. Внутреннюю причину логируйте под
trace_id, наружу — общийdetail. - Консистентность: один формат на все эндпоинты.
8. HATEOAS (кратко)#
HATEOAS (Hypermedia as the Engine of Application State) — уровень 3 RMM: ответ несёт ссылки на доступные дальнейшие действия, клиент не хардкодит URI.
{
"id": 7, "status": "pending", "total": 100,
"_links": {
"self": {"href": "/orders/7"},
"cancel": {"href": "/orders/7/cancel", "method": "POST"},
"pay": {"href": "/orders/7/payment", "method": "POST"}
}
}Идея: сервер управляет переходами состояний (ссылки появляются/исчезают по статусу), клиент слабо связан с URL-схемой.
Почему редко применяют: дополнительная сложность и объём ответов; большинство клиентов всё равно хардкодят URI; нет общепринятого медиа-типа (HAL, JSON:API, Siren конкурируют); нет инструментов/выгоды для типичного SPA/мобайла; цена > выгоды для внутренних API. Хорошо живёт в строго гипермедийных доменах (некоторые публичные API, AtomPub-подобные).
9. Content negotiation и conditional requests#
Content negotiation — клиент сообщает желаемое представление, сервер выбирает:
GET /report/7 HTTP/1.1
Accept: application/json, text/csv;q=0.5
Accept-Language: ru-RU
Accept-Encoding: gzipСервер отвечает Content-Type, при невозможности — 406 Not Acceptable. Указывайте Vary: Accept для корректного кэширования.
ETag и conditional requests — валидаторы для кэша и оптимистичной блокировки.
GET /users/42
→ HTTP/1.1 200 OK
ETag: "a1b2c3"
# повторный GET с валидатором
GET /users/42
If-None-Match: "a1b2c3"
→ HTTP/1.1 304 Not Modified # тело не передаётся
# безопасное обновление (optimistic concurrency)
PUT /users/42
If-Match: "a1b2c3"
→ 412 Precondition Failed # если ресурс уже изменилиIf-None-Match(GET) → 304, экономия трафика.If-Match(PUT/PATCH/DELETE) → защита от потери обновлений: правим только если версия совпала, иначе 412.- ETag: strong (
"abc") — побайтовое равенство; weak (W/"abc") — семантическое. - Также
Last-Modified+If-Modified-Since/If-Unmodified-Since(гранулярность — секунды, слабее ETag).
Подводные камни / gotchas#
- 200 на ошибку. Возврат
200 OKс телом{"error": ...}ломает обработку у клиентов и прокси. Используйте корректный статус. - PATCH считают идемпотентным. Зависит от семантики патча; JSON Patch с
add/copyв массив — нет. - DELETE повторно → 404 ≠ нарушение идемпотентности. Идемпотентность про состояние сервера, а не про одинаковый статус-ответ.
- POST для всего (уровень 0/1 под видом REST). Теряете кэш, ретраи, семантику; «getUser» через POST.
- Offset-пагинация на больших данных: и дрейф, и
O(offset)по производительности. Под нагрузкой деградирует. - Курсор без уникального тай-брейкера: при равных
created_atстроки пропадают/дублируются — всегда добавляйтеidв ключ сортировки. - Утечка internals в ошибках: stack trace/SQL в ответе = и UX-проблема, и security-риск (раскрытие схемы).
- Idempotency без проверки тела: один ключ с разным body должен давать 422/409, а не молча отдавать старый ответ.
- Stateful через session affinity: липкие сессии в памяти ломают масштабирование и отказоустойчивость.
- Версия только в коде клиента: без
Sunset/Deprecationстарые клиенты внезапно ломаются — нужна политика устаревания. - Кэш без
Vary: при content negotiation прокси может отдать JSON клиенту, попросившему CSV. Retry-Afterзабыт на 429/503 — клиенты ретраят агрессивно и усугубляют перегрузку.- PUT для создания со server-generated ID — путаница; для серверного ID используйте POST +
Location.
Вопросы на собеседовании#
В: Что значит «stateless» в REST и какие практические следствия? О: Сервер не хранит состояние клиента между запросами; каждый запрос самодостаточен (включая аутентификацию). Следствия: любой инстанс обрабатывает любой запрос → горизонтальное масштабирование и отказоустойчивость; нет серверных сессий в памяти (отсюда JWT/токены); состояние данных — в БД (по URI), состояние приложения — у клиента. Цена: каждый запрос тащит аутентификацию/контекст, больше трафика.
В: Чем идемпотентность отличается от безопасности (safety) метода? Идемпотентен ли DELETE, PATCH?
О: Safe — метод не меняет состояние (GET, HEAD). Idempotent — N повторов = эффект как от одного (на состоянии сервера), но метод может менять состояние (PUT, DELETE). DELETE идемпотентен: первый удаляет, повтор → 404, но ресурса всё равно нет — состояние одинаково. PATCH не гарантированно идемпотентен: зависит от семантики (Merge Patch с абсолютными значениями — да; JSON Patch add в массив — нет).
В: Зачем нужен Idempotency-Key и как его реализовать для платежей? О: POST неидемпотентен, при таймауте клиент не знает исход и ретрай создаёт дубль списания. Клиент шлёт уникальный ключ; сервер атомарно столбит его (unique-индекс/SETNX), при первом запросе выполняет операцию в транзакции вместе с сохранением ответа, при повторе — возвращает сохранённый ответ без повторного выполнения. Проверяем совпадение тела (иначе 422), обрабатываем гонку параллельных запросов (409/ожидание), ставим TTL.
В: 400 vs 422 и 401 vs 403?
О: 400 — запрос не разобрать (битый JSON, неверный формат); 422 — разобрали, но не прошла семантическая/бизнес-валидация (age=-5). 401 — не аутентифицирован (нет/протух токен, нужно переаутентифицироваться); 403 — аутентифицирован, но нет прав. Иногда вместо 403 отдают 404, чтобы не раскрывать существование ресурса.
В: Сравните способы версионирования API. Что выбрать?
О: URL path (/v1/) — просто, видно, легко роутить/кэшировать, но «не по REST» и дублирует роуты. Media-type (Accept: ...+json;v=2) — чисто по REST, URI стабилен, но сложнее тестировать/кэшировать и невидимо. Query/custom header — компромиссы с минусами кэширования/видимости. Для публичных API чаще URL path; для строгого REST — media-type. Главное: версионировать только breaking changes, минимизировать число живых версий, иметь политику Deprecation/Sunset.
В: Чем cursor/keyset пагинация лучше offset на больших данных?
О: Offset дрейфует при вставках/удалениях (дубли/пропуски) и медленный: OFFSET N отсчитывает и выбрасывает N строк (O(N)). Keyset кодирует позицию через значения сортируемых колонок последней строки (WHERE (created_at, id) < (?, ?)), использует индекс → O(log n) независимо от глубины и стабилен при изменениях. Минус: нет прыжков на произвольную страницу; нужен уникальный тай-брейкер (id) в сортировке.
В: Как спроектировать формат ошибок API?
О: Единый формат (RFC 7807/9457 Problem Details, application/problem+json): type, title, status, detail, instance + расширения (code, errors[], trace_id). Стабильные машинные code для ветвления клиентов (не по тексту/HTTP). Не светить internals (stack trace, SQL, схему) — их в лог под trace_id, наружу общее сообщение. Консистентность на всех эндпоинтах.
В: Что такое HATEOAS и почему его редко используют?
О: Уровень 3 RMM: ответ несёт гиперссылки на доступные действия/переходы (_links), клиент не хардкодит URI, сервер управляет состоянием приложения. Редко применяют из-за сложности и объёма, отсутствия единого медиа-типа (HAL/JSON:API/Siren), того что клиенты всё равно хардкодят URL, и недостаточной выгоды для типичных SPA/мобайла — цена выше пользы.
В: Как работают ETag и условные запросы?
О: Сервер отдаёт ETag (версия представления). GET с If-None-Match → 304 Not Modified без тела (экономия трафика, валидация кэша). PUT/PATCH/DELETE с If-Match → если версия изменилась, 412 Precondition Failed — это оптимистичная блокировка против потери обновлений. ETag бывают strong (побайтово) и weak (W/, семантически). Аналог по времени — Last-Modified + If-Modified-Since, но гранулярность в секундах.
В: Когда отдавать 202 Accepted?
О: Когда запрос принят, но обработка асинхронная (поставлен в очередь) и результата ещё нет. Возвращают ссылку на ресурс статуса/результата (Location на job), клиент опрашивает или получает через вебхук/SSE. Полезно для долгих операций, чтобы не держать соединение.
На что копают на senior+#
- Идемпотентность под капотом: атомарность «застолбить ключ + выполнить операцию + сохранить ответ» в одной транзакции; что будет при падении между шагами; гонки параллельных запросов с одним ключом; TTL и хранилище ключей (Redis vs БД); согласованность с outbox при отправке событий.
- Семантика vs реализация: почему DELETE идемпотентен, хотя статусы разные; различие «идемпотентность HTTP-метода» и «бизнес-идемпотентность операции».
- Пагинация и консистентность: read-skew при offset, snapshot-пагинация, стабильная сортировка, шифрование/подпись курсора (чтобы не утекали внутренние поля и нельзя было подделать), пагинация при сортировке по неуникальному/изменяемому полю.
- Кэширование:
Cache-Control(public/private, max-age, s-maxage, stale-while-revalidate),Vary, ETag-генерация (не хешировать весь ответ дорого), инвалидация, согласование с CDN. - Эволюция API без версий: толерантный ридер, аддитивные изменения, feature flags, contract testing (Pact), обратная/прямая совместимость.
- Ошибки и безопасность: единый формат + не раскрывать схему/существование ресурсов, маппинг доменных ошибок на HTTP, трассировка (
trace_id) сквозь сервисы. - Когда REST не подходит: gRPC для внутренних высоконагруженных вызовов, GraphQL для гибких выборок, async/event-driven для интеграций; trade-off’ы.
- Конкурентность и оптимистичные блокировки: ETag/
If-Matchvs version-колонка, 409 vs 412, разрешение конфликтов. - Rate limiting: 429,
Retry-After, алгоритмы (token/leaky bucket), заголовкиX-RateLimit-*, поведение клиента (экспоненциальный backoff + jitter).