Gymterview
senior

Что такое асинхронные API и как их проектировать?

Асинхронные API — подходы к взаимодействию клиента и сервера, при которых обработка запроса не блокирует клиента и результат доставляется позже (через callback, polling или push-уведомление).

Когда нужны

  • Долгие операции — генерация отчёта, обработка видео, массовый импорт.
  • Real-time обновления — чат, биржевые котировки, уведомления.
  • Событийные уведомления — платёж завершён, заказ отправлен.
  • Интеграции — webhook-и от внешних систем (Stripe, GitHub).

Сравнение подходов

Характеристика Polling Webhooks SSE WebSocket
Направление Клиент -> Сервер Сервер -> Клиент Сервер -> Клиент Двунаправленный
Протокол HTTP HTTP HTTP WS (поверх HTTP)
Задержка Высокая (интервал) Низкая Низкая Очень низкая
Нагрузка на сервер Высокая Низкая Средняя Средняя
Масштабирование Простое Простое Сложнее Сложнее
Подходит для Долгие операции Интеграции, события Уведомления, ленты Чат, real-time игры

Паттерн 1: Polling (202 Accepted + Location)

Реализация
// Запуск долгой операции
@PostMapping("/api/reports")
public ResponseEntity<TaskStatus> createReport(
        @Valid @RequestBody ReportRequest request) {
    String taskId = reportService.startGeneration(request);

    URI statusUri = URI.create("/api/reports/tasks/" + taskId);
    TaskStatus status = new TaskStatus(taskId, "PENDING", null);

    return ResponseEntity.accepted()
        .location(statusUri)
        .header("Retry-After", "5")
        .body(status);
}

// Проверка статуса
@GetMapping("/api/reports/tasks/{taskId}")
public ResponseEntity<TaskStatus> getTaskStatus(@PathVariable String taskId) {
    TaskStatus status = reportService.getStatus(taskId);

    if ("COMPLETED".equals(status.status())) {
        return ResponseEntity.status(HttpStatus.SEE_OTHER)
            .location(URI.create(status.resultUrl()))
            .body(status);
    }
    if ("FAILED".equals(status.status())) {
        return ResponseEntity.ok(status);
    }
    return ResponseEntity.ok()
        .header("Retry-After", "5")
        .body(status);
}

public record TaskStatus(
    String taskId,
    String status,       // PENDING, PROCESSING, COMPLETED, FAILED
    String resultUrl,
    Integer progress,
    String errorMessage
) {
    public TaskStatus(String taskId, String status, String resultUrl) {
        this(taskId, status, resultUrl, null, null);
    }
}

Паттерн 2: Webhooks (Callback URL)

Реализация
@PostMapping("/api/webhooks")
public ResponseEntity<WebhookRegistration> registerWebhook(
        @Valid @RequestBody WebhookRequest request) {
    WebhookRegistration registration = webhookService.register(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(registration);
}

public record WebhookRequest(
    @NotBlank String url,           // URL для callback
    @NotEmpty Set<String> events,   // ["order.completed", "payment.failed"]
    String secret                   // секрет для подписи
) {}

@Service
@RequiredArgsConstructor
@Slf4j
public class WebhookSender {

    private final RestClient restClient;
    private final WebhookRepository webhookRepository;

    @Async
    @TransactionalEventListener
    public void onOrderCompleted(OrderCompletedEvent event) {
        List<WebhookRegistration> hooks = webhookRepository
            .findByEvent("order.completed");
        for (WebhookRegistration hook : hooks) {
            sendWebhook(hook, event);
        }
    }

    private void sendWebhook(WebhookRegistration hook, Object payload) {
        String body = objectMapper.writeValueAsString(payload);
        String signature = HmacUtils.hmacSha256Hex(hook.getSecret(), body);

        try {
            restClient.post()
                .uri(hook.getUrl())
                .header("X-Webhook-Signature", "sha256=" + signature)
                .header("X-Webhook-Event", "order.completed")
                .body(body)
                .retrieve()
                .toBodilessEntity();
        } catch (Exception e) {
            log.error("Webhook delivery failed: {}", hook.getUrl(), e);
            webhookRetryService.scheduleRetry(hook, payload);
        }
    }
}

Паттерн 3: SSE (Server-Sent Events)

Сервер отправляет поток событий клиенту через постоянное HTTP-соединение. Однонаправленный.

Реализация
@RestController
@RequestMapping("/api/events")
public class SseController {

    private final SseEmitterService sseService;

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamEvents(@RequestParam Long userId) {
        SseEmitter emitter = new SseEmitter(0L);
        sseService.register(userId, emitter);

        emitter.onCompletion(() -> sseService.unregister(userId));
        emitter.onTimeout(() -> sseService.unregister(userId));

        return emitter;
    }
}

@Service
public class SseEmitterService {

    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();

    public void sendEvent(Long userId, String eventType, Object data) {
        SseEmitter emitter = emitters.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                    .name(eventType)
                    .data(data, MediaType.APPLICATION_JSON)
                    .id(UUID.randomUUID().toString())
                    .reconnectTime(3000));
            } catch (IOException e) {
                emitters.remove(userId);
            }
        }
    }
}

Паттерн 4: WebSocket (двунаправленный)

Реализация
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
            .setAllowedOriginPatterns("*")
            .withSockJS();
    }
}

@Controller
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;

    @MessageMapping("/chat.send")
    public void sendMessage(@Payload ChatMessage message,
                            SimpMessageHeaderAccessor headerAccessor) {
        String userId = headerAccessor.getUser().getName();
        message.setSenderId(userId);
        message.setTimestamp(Instant.now());

        messagingTemplate.convertAndSend(
            "/topic/chat." + message.getRoomId(), message);
    }
}

Частые ошибки

  • Использование WebSocket там, где достаточно SSE.
  • Отсутствие retry-механизма для webhook.
  • Polling без Retry-After заголовка.
  • Хранение состояния SSE/WebSocket в памяти без учёта масштабирования.

Как используется в 2026

  • Spring WebFlux + SSE — реактивный подход для потоковых данных.
  • AsyncAPI 3.0 — стандарт описания асинхронных API.
  • Virtual threads (Java 21+) упрощают обработку большого числа SSE/WebSocket-соединений.
  • Standard Webhooks (standardwebhooks.com) — единый формат подписи и retry.

На собеседовании: нужно знать все четыре паттерна (Polling, Webhooks, SSE, WebSocket) и когда использовать какой. Polling — самый простой для долгих операций, Webhooks — для интеграций, SSE — для уведомлений, WebSocket — только для полнодуплексной связи. Частая ошибка — предлагать WebSocket для всех async-задач.