Перейти к основному содержимому

Собеседование в Озон. Golang разработка. Задача на net/http.

· 32 мин. чтения

Сегодня мы разберем процесс решения объемной задачи на живом собеседовании, где кандидат шаг за шагом проектирует и дорабатывает HTTP-проверку хостов, постепенно усложняя архитектуру: от простого последовательного запроса до конкурентной модели с каналами, контекстами, правильной обработкой ошибок и написанием тестов, демонстрируя навыки проектирования и clean code на практике.

Вопрос 1. Как реализовать базовую проверку доступности URL с помощью HTTP-запросов и обработки кодов ответа?

Таймкод: 00:00:49

Ответ собеседника: Правильный. Используется цикл для перебора списка URL. Для каждого адреса выполняется GET-запрос через стандартную библиотеку net/http. Если возникает ошибка сети, выводится статус not OK. Если ошибки нет, проверяется код ответа: при 200 выводится OK, при любом другом — not OK. Это позволяет отличить сетевые сбои от корректных, но неуспешных ответов сервера.

Правильный ответ:

1. Базовая реализация проверки доступности

Для проверки доступности URL необходимо выполнить HTTP-запрос и корректно обработать два типа ошибок: сетевые (transport level) и прикладные (HTTP status codes).

package main

import (
"fmt"
"net/http"
"time"
)

type CheckResult struct {
URL string
Status string
Code int
Latency time.Duration
Err error
}

func checkURL(url string, timeout time.Duration) CheckResult {
start := time.Now()

client := &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}

req, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
url,
nil,
)
if err != nil {
return CheckResult{URL: url, Status: "not OK", Err: err}
}

req.Header.Set("User-Agent", "HealthChecker/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")

resp, err := client.Do(req)
latency := time.Since(start)

if err != nil {
return CheckResult{
URL: url,
Status: "not OK",
Latency: latency,
Err: err,
}
}
defer resp.Body.Close()

status := "not OK"
if resp.StatusCode == http.StatusOK {
status = "OK"
}

return CheckResult{
URL: url,
Status: status,
Code: resp.StatusCode,
Latency: latency,
}
}

2. Улучшенная обработка статусов

Код 200 не всегда означает полную работоспособность сервиса. В production-системах рекомендуется учитывать и другие успешные коды:

func isHealthyStatus(code int) bool {
switch code {
case http.StatusOK, // 200
http.StatusCreated, // 201
http.StatusAccepted, // 202
http.StatusNoContent, // 204
http.StatusPartialContent: // 206
return true
default:
return false
}
}

3. Масштабирование для множества URL

Для проверки множества URL эффективно использовать worker pool pattern:

func checkURLs(urls []string, concurrency int, timeout time.Duration) []CheckResult {
results := make([]CheckResult, len(urls))
jobs := make(chan int, len(urls))
resultChan := make(chan CheckResult, len(urls))

// Запуск воркеров
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range jobs {
resultChan <- checkURL(urls[idx], timeout)
}
}()
}

// Распределение задач
for i := range urls {
jobs <- i
}
close(jobs)

// Сбор результатов
go func() {
wg.Wait()
close(resultChan)
}()

// Сбор в слайс
i := 0
for result := range resultChan {
results[i] = result
i++
}

return results
}

4. Важные аспекты production-реализации

Таймауты и контексты: Использование context.WithTimeout позволяет не только ограничить общее время проверки, но и корректно отменять запросы при завершении программы или таймауте.

Пул соединений: Для множественных проверок одного хоста эффективно использовать кастомный http.Transport с настроенным пулом соединений:

transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}

Обработка редиректов: Автоматические редиректы могут скрывать реальную проблему. Иногда целесообразно ограничивать их количество или полностью отключать для точной диагностики.

Метрики и логирование: Помимо простого статуса, важно собирать метрики: время отклика (latency), размер ответа, заголовки. Это позволяет выявлять деградацию производительности до полного отказа.

SQL пример для хранения истории проверок:

CREATE TABLE url_health_checks (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL,
status VARCHAR(10) NOT NULL,
http_code INTEGER,
latency_ms INTEGER,
error_message TEXT,
checked_at TIMESTAMP DEFAULT NOW(),
INDEX idx_url_time (url, checked_at)
);

-- Запрос для получения доступности по последним проверкам
SELECT
url,
status,
COUNT(*) as total_checks,
AVG(latency_ms) as avg_latency,
MAX(checked_at) as last_check
FROM url_health_checks
WHERE checked_at > NOW() - INTERVAL '5 minutes'
GROUP BY url, status;

5. Расширенные сценарии

Для критически важных сервисов базовой проверки недостаточно. Необходимо учитывать:

  • Проверку SSL/TLS сертификатов
  • Валидацию содержимого ответа (наличие ключевых строк)
  • Проверку времени ответа (SLA)
  • Circuit breaker pattern для предотвращения эффекта "avalanche"
  • Retry с экспоненциальной задержкой для transient errors

Такой подход обеспечивает надежную проверку доступности с корректным разделением сетевых и прикладных ошибок, что критически важно для систем мониторинга и health checks в распределенных системах.

Вопрос 2. Как модифицировать программу для использования горутин и каналов с возвратом структуры вместо булева значения?

Таймкод: 00:03:10

Ответ собеседника: Правильный. Создается функция checkURL, выполняющая запрос и возвращающая через канал структуру с полями URL и статуса. Вместо булева типа выбрана структура, чтобы передавать и адрес, и результат проверки. В основной программе для каждого URL запускается горутина с этой функцией, а основной поток считывает результаты из канала и выводит их. Это обеспечивает параллельную обработку запросов без блокировки основного потока.

Правильный ответ:

1. Определение структуры результата

Для передачи комплексной информации через канал вместо примитивного булева значения используется структура, которая может содержать не только статус, но и метаданные проверки:

type HealthCheck struct {
URL string
Status string
Code int
Latency time.Duration
Error error
CheckedAt time.Time
}

2. Функция проверки с возвратом через канал

Функция принимает URL и канал для возврата результата, выполняет асинхронную проверку:

func checkURLAsync(url string, resultChan chan<- HealthCheck) {
result := HealthCheck{
URL: url,
CheckedAt: time.Now(),
}

start := time.Now()

client := &http.Client{
Timeout: 10 * time.Second,
}

resp, err := client.Get(url)
result.Latency = time.Since(start)

if err != nil {
result.Status = "error"
result.Error = err
resultChan <- result
return
}
defer resp.Body.Close()

result.Code = resp.StatusCode
if resp.StatusCode == http.StatusOK {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}

resultChan <- result
}

3. Использование горутин и каналов в основной программе

func main() {
urls := []string{
"https://google.com",
"https://github.com",
"https://example.com",
}

// Буферизованный канал для предотвращения блокировки горутин
results := make(chan HealthCheck, len(urls))

// Запуск горутин для каждого URL
for _, url := range urls {
go checkURLAsync(url, results)
}

// Сбор результатов
for i := 0; i < len(urls); i++ {
result := <-results
fmt.Printf("URL: %s, Status: %s, Code: %d, Latency: %v\n",
result.URL, result.Status, result.Code, result.Latency)
}

close(results)
}

4. Улучшенная версия с worker pool и context

Для production-систем рекомендуется использовать worker pool для ограничения количества одновременных запросов:

func worker(urls <-chan string, results chan<- HealthCheck, ctx context.Context) {
client := &http.Client{Timeout: 10 * time.Second}

for {
select {
case <-ctx.Done():
return
case url, ok := <-urls:
if !ok {
return
}

result := HealthCheck{URL: url, CheckedAt: time.Now()}
start := time.Now()

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
result.Error = err
result.Status = "error"
results <- result
continue
}

resp, err := client.Do(req)
result.Latency = time.Since(start)

if err != nil {
result.Error = err
result.Status = "error"
} else {
result.Code = resp.StatusCode
if resp.StatusCode == http.StatusOK {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}
resp.Body.Close()
}

results <- result
}
}
}

func checkURLsConcurrent(urls []string, maxWorkers int) []HealthCheck {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

urlChan := make(chan string, len(urls))
resultChan := make(chan HealthCheck, len(urls))

// Запуск воркеров
for i := 0; i < maxWorkers; i++ {
go worker(urlChan, resultChan, ctx)
}

// Отправка задач
go func() {
for _, url := range urls {
urlChan <- url
}
close(urlChan)
}()

// Сбор результатов
var results []HealthCheck
for i := 0; i < len(urls); i++ {
results = append(results, <-resultChan)
}

return results
}

5. Обработка race conditions и синхронизация

При работе с горутинами важно учитывать возможность race conditions. Если результаты нужно агрегировать в общую структуру данных:

type SafeResults struct {
mu sync.RWMutex
checks []HealthCheck
}

func (sr *SafeResults) Add(check HealthCheck) {
sr.mu.Lock()
defer sr.mu.Unlock()
sr.checks = append(sr.checks, check)
}

func (sr *SafeResults) GetAll() []HealthCheck {
sr.mu.RLock()
defer sr.mu.RUnlock()

// Возвращаем копию для предотвращения race conditions
result := make([]HealthCheck, len(sr.checks))
copy(result, sr.checks)
return result
}

6. SQL схема для хранения истории проверок

Для сохранения результатов асинхронных проверок:

CREATE TABLE health_checks (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('healthy', 'unhealthy', 'error')),
http_code INTEGER,
latency_ms INTEGER NOT NULL,
error_message TEXT,
checked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
response_size INTEGER
);

CREATE INDEX idx_health_checks_url_time
ON health_checks(url, checked_at DESC);

CREATE INDEX idx_health_checks_status
ON health_checks(status, checked_at);

-- Запрос для получения статистики по последним проверкам
SELECT
url,
status,
COUNT(*) as total_checks,
AVG(latency_ms)::INTEGER as avg_latency_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) as p95_latency_ms,
MAX(checked_at) as last_check
FROM health_checks
WHERE checked_at > NOW() - INTERVAL '1 hour'
GROUP BY url, status
ORDER BY url, status;

7. Паттерн pipeline для сложной обработки

Для более сложных сценариев можно использовать паттерн pipeline с несколькими стадиями обработки:

func checkStage(urls <-chan string, results chan<- HealthCheck) {
for url := range urls {
// Выполняем проверку
result := performCheck(url)
results <- result
}
}

func filterStage(results <-chan HealthCheck, filtered chan<- HealthCheck) {
for result := range results {
if result.Status == "error" || result.Latency > 5*time.Second {
filtered <- result
}
}
}

func main() {
urls := make(chan string, 100)
results := make(chan HealthCheck, 100)
filtered := make(chan HealthCheck, 100)

// Стадии pipeline
go checkStage(urls, results)
go filterStage(results, filtered)

// Отправка данных
go func() {
for _, url := range urlList {
urls <- url
}
close(urls)
}()

// Чтение отфильтрованных результатов
for result := range filtered {
fmt.Printf("Alert: %s - %s\n", result.URL, result.Status)
}
}

8. Важные аспекты асинхронной обработки

Буферизация каналов: Использование буферизованных каналов предотвращает блокировку горутин при отправке результатов, особенно когда производители и потребители работают с разной скоростью.

Context для отмены: Всегда используйте context для возможности отмены длительных операций и корректного завершения горутин.

Ограничение конкурентности: Неограниченное создание горутин может привести к исчерпанию ресурсов. Worker pool pattern позволяет контролировать максимальное количество одновременных проверок.

Тайм-ауты: Установка тайм-аутов на уровне клиента HTTP и context предотвращает утечки горутин при зависших запросах.

Этот подход обеспечивает масштабируемую, надежную и эффективную асинхронную проверку доступности URL с полной информацией о результатах проверки, готовую для использования в production-системах.

Вопрос 3. Как предотвратить панику при обращении к ответу и корректно завершить работу с каналами и горутинами?

Таймкод: 00:07:16

Ответ собеседника: Правильный. Добавлена проверка на nil перед доступом к телу ответа, чтобы избежать паники при сетевых ошибках. Для синхронизации используется WaitGroup: счетчик увеличивается при запуске каждой горутины и уменьшается по ее завершении. После ожидания всех горутин канал закрывается, что позволяет корректно завершить range по каналу и завершить работу программы без блокировок.

Правильный ответ:

1. Защита от nil-указателей при работе с HTTP-ответом

Паника при обращении к resp.Body возникает, если сетевой запрос завершился ошибкой до получения ответа. В стандартной библиотеке net/http при ошибке транспортного уровня указатель на ответ всегда равен nil:

resp, err := http.Get(url)
if err != nil {
// resp гарантированно nil
// Любое обращение к resp вызовет панику
return fmt.Errorf("request failed: %w", err)
}
// Только здесь resp гарантированно не nil
defer resp.Body.Close()

Однако в конкурентной среде с использованием горутин и каналов требуется более строгая защита:

func safeCheckURL(url string, resultChan chan<- HealthCheck) {
defer func() {
if r := recover(); r != nil {
// Защита от паник в горутинах
resultChan <- HealthCheck{
URL: url,
Status: "panic",
Error: fmt.Errorf("recovered from panic: %v", r),
}
}
}()

result := HealthCheck{URL: url, CheckedAt: time.Now()}

resp, err := http.Get(url)
if err != nil {
result.Error = err
result.Status = "network_error"
resultChan <- result
return
}

// Двойная проверка для абсолютной безопасности
if resp == nil {
result.Error = errors.New("nil response without error")
result.Status = "error"
resultChan <- result
return
}

defer func() {
// Защита от паники при закрытии тела
if r := recover(); r != nil {
log.Printf("panic closing body for %s: %v", url, r)
}
}()

defer resp.Body.Close()

// Работа с телом ответа
body, err := io.ReadAll(resp.Body)
if err != nil {
result.Error = err
result.Status = "read_error"
} else {
result.Code = resp.StatusCode
result.Latency = time.Since(result.CheckedAt)
if resp.StatusCode == http.StatusOK {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}
result.ResponseSize = len(body)
}

resultChan <- result
}

2. Корректная синхронизация с WaitGroup и закрытие каналов

Закрытие канала до завершения всех горутин приведет к панике при попытке отправки. Использование sync.WaitGroup обеспечивает гарантированное завершение:

func checkAllURLs(urls []string, concurrency int) []HealthCheck {
var wg sync.WaitGroup
urlChan := make(chan string, concurrency)
resultChan := make(chan HealthCheck, len(urls))

// Запуск воркеров
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()

for url := range urlChan {
safeCheckURL(url, resultChan)
}
}(i)
}

// Отправка задач (в отдельной горутине)
go func() {
for _, url := range urls {
urlChan <- url
}
close(urlChan) // Закрываем после отправки всех задач
}()

// Ожидание завершения всех воркеров
go func() {
wg.Wait()
close(resultChan) // Безопасное закрытие после Wait
}()

// Сбор результатов
var results []HealthCheck
for result := range resultChan {
results = append(results, result)
}

return results
}

3. Улучшенная версия с context и graceful shutdown

Для production-систем необходимо учитывать возможность прерывания работы:

func checkWithGracefulShutdown(ctx context.Context, urls []string) ([]HealthCheck, error) {
var wg sync.WaitGroup
urlChan := make(chan string, 10)
resultChan := make(chan HealthCheck, len(urls))
errChan := make(chan error, 1)

// Создаем context с отменой для внутренних горутин
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Запуск воркеров
const numWorkers = 5
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()

for {
select {
case <-ctx.Done():
// Получен сигнал отмены
return
case url, ok := <-urlChan:
if !ok {
// Канал закрыт
return
}

result := performCheckWithContext(ctx, url)

select {
case resultChan <- result:
case <-ctx.Done():
return
}
}
}
}(i)
}

// Отправка задач с обработкой отмены
go func() {
defer close(urlChan)

for _, url := range urls {
select {
case <-ctx.Done():
errChan <- ctx.Err()
return
case urlChan <- url:
}
}
}()

// Ожидание завершения в отдельной горутине
go func() {
wg.Wait()
close(resultChan)
}()

// Сбор результатов
var results []HealthCheck
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errChan:
return nil, err
case result, ok := <-resultChan:
if !ok {
// Канал закрыт, все результаты собраны
return results, nil
}
results = append(results, result)
}
}
}

func performCheckWithContext(ctx context.Context, url string) HealthCheck {
result := HealthCheck{URL: url, CheckedAt: time.Now()}

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
result.Error = err
result.Status = "error"
return result
}

client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)

if err != nil {
// Проверка на отмену контекста
if ctx.Err() != nil {
result.Error = ctx.Err()
result.Status = "cancelled"
} else {
result.Error = err
result.Status = "network_error"
}
return result
}
defer func() {
if resp.Body != nil {
resp.Body.Close()
}
}()

result.Code = resp.StatusCode
result.Latency = time.Since(result.CheckedAt)

if resp.StatusCode == http.StatusOK {
result.Status = "healthy"
} else {
result.Status = "unhealthy"
}

return result
}

4. SQL для отслеживания ошибок и паник

Для анализа сбоев в системе проверки:

CREATE TABLE health_check_errors (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL,
error_type VARCHAR(50) NOT NULL,
error_message TEXT NOT NULL,
stack_trace TEXT,
occurred_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
worker_id INTEGER,
is_recovered BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_errors_time
ON health_check_errors(occurred_at DESC);

CREATE INDEX idx_errors_type
ON health_check_errors(error_type, occurred_at);

-- Запрос для анализа частоты паник и ошибок
SELECT
error_type,
COUNT(*) as total_occurrences,
COUNT(*) FILTER (WHERE is_recovered) as recovered_count,
COUNT(*) FILTER (WHERE NOT is_recovered) as unrecovered_count,
MAX(occurred_at) as last_occurrence
FROM health_check_errors
WHERE occurred_at > NOW() - INTERVAL '24 hours'
GROUP BY error_type
ORDER BY total_occurrences DESC;

-- Запрос для выявления проблемных URL
SELECT
url,
error_type,
COUNT(*) as error_count,
MAX(occurred_at) as last_error
FROM health_check_errors
WHERE occurred_at > NOW() - INTERVAL '1 hour'
GROUP BY url, error_type
HAVING COUNT(*) > 5
ORDER BY error_count DESC;

5. Паттерн circuit breaker для предотвращения каскадных сбоев

Для защиты от повторяющихся паник и ошибок:

type CircuitBreaker struct {
mu sync.RWMutex
failures int
lastFailure time.Time
state string // "closed", "open", "half-open"
threshold int
timeout time.Duration
}

func (cb *CircuitBreaker) Execute(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()

if cb.state == "open" {
if time.Since(cb.lastFailure) > cb.timeout {
cb.state = "half-open"
} else {
return fmt.Errorf("circuit breaker is open")
}
}

err := fn()

if err != nil {
cb.failures++
cb.lastFailure = time.Now()

if cb.failures >= cb.threshold {
cb.state = "open"
}
return err
}

// Успешное выполнение
cb.failures = 0
cb.state = "closed"
return nil
}

6. Критические аспекты конкурентной обработки

Гарантированное закрытие каналов: Канал должен закрываться только тем, кто в него пишет, и только после гарантированного завершения всех записей. Использование WaitGroup обеспечивает эту гарантию.

Защита от утечек горутин: Каждая горутина должна иметь гарантированный путь завершения, даже при ошибках. Использование defer wg.Done() и recover() предотвращает утечки.

Разделение ответственности: Отправка данных в канал и закрытие канала должны быть в разных горутинах или гарантированно синхронизированы.

Контекст для отмены: Все долгоживущие операции должны поддерживать отмену через context для корректного завершения при остановке сервиса.

Этот подход обеспечивает абсолютную безопасность при конкурентной обработке HTTP-запросов, предотвращает паники и гарантирует корректное завершение всех горутин и закрытие каналов, что критически важно для стабильности production-систем.

Вопрос 4. Как реализовать динамическое получение URL из канала и корректно завершить работу при отсутствии фиксированного списка?

Таймкод: 00:09:51

Ответ собеседника: Правильный. Вместо жестко заданного слайса URL используется канал, из которого горутины читают адреса по мере их поступления. Для имитации внешнего источника данные пихаются в канал отдельной горутиной. Завершение работы синхронизируется с помощью закрытия канала URL (сигнал об окончании данных) и WaitGroup (ожидание завершения всех горутин). После закрытия канала URL range завершается, ожидается окончание работы всех горутин, затем закрывается канал результатов и программа завершается.

Правильный ответ:

1. Базовая реализация динамического конвейера

При работе с потоковыми данными необходимо разделить этапы производства (генерация URL) и потребления (проверка). Ключевым моментом является корректная сигнализация о завершении данных через закрытие канала:

type URLChecker struct {
workers int
timeout time.Duration
maxRetries int
}

func NewURLChecker(workers int, timeout time.Duration) *URLChecker {
return &URLChecker{
workers: workers,
timeout: timeout,
maxRetries: 3,
}
}

func (uc *URLChecker) Run(ctx context.Context, urlSource <-chan string) <-chan HealthCheck {
results := make(chan HealthCheck, 100)
var wg sync.WaitGroup

// Запуск пула воркеров
for i := 0; i < uc.workers; i++ {
wg.Add(1)
go uc.worker(ctx, i, urlSource, results, &wg)
}

// Горутина для корректного закрытия результатов
go func() {
wg.Wait()
close(results)
}()

return results
}

func (uc *URLChecker) worker(
ctx context.Context,
id int,
urls <-chan string,
results chan<- HealthCheck,
wg *sync.WaitGroup,
) {
defer wg.Done()

for {
select {
case <-ctx.Done():
// Получен сигнал отмены
log.Printf("worker %d: shutting down", id)
return
case url, ok := <-urls:
if !ok {
// Канал закрыт, нет больше данных
log.Printf("worker %d: url channel closed", id)
return
}

result := uc.checkWithRetry(ctx, url)

select {
case results <- result:
case <-ctx.Done():
return
}
}
}
}

2. Продвинутая реализация с backpressure и rate limiting

Для защиты от перегрузки системы при высоком потоке входящих URL:

type RateLimitedChecker struct {
sem chan struct{} // Семафор для ограничения конкурентности
rateLimiter *time.Ticker
client *http.Client
}

func NewRateLimitedChecker(maxConcurrent int, requestsPerSecond float64) *RateLimitedChecker {
return &RateLimitedChecker{
sem: make(chan struct{}, maxConcurrent),
rateLimiter: time.NewTicker(time.Second / time.Duration(requestsPerSecond)),
client: &http.Client{Timeout: 10 * time.Second},
}
}

func (rlc *RateLimitedChecker) ProcessStream(ctx context.Context, input <-chan string) <-chan HealthCheck {
output := make(chan HealthCheck, 100)

go func() {
defer close(output)

for {
select {
case <-ctx.Done():
return
case url, ok := <-input:
if !ok {
return
}

// Ожидание токена rate limiter
select {
case <-ctx.Done():
return
case <-rlc.rateLimiter.C:
}

// Захват семафора
select {
case <-ctx.Done():
return
case rlc.sem <- struct{}{}:
}

go func(u string) {
defer func() { <-rlc.sem }()

result := rlc.checkURL(ctx, u)

select {
case output <- result:
case <-ctx.Done():
}
}(url)
}
}
}()

return output
}

3. Многоступенчатый конвейер обработки

Для сложных сценариев с фильтрацией, трансформацией и агрегацией:

func URLPipeline(ctx context.Context, source <-chan string) <-chan HealthCheck {
// Этап 1: Валидация URL
validated := make(chan string, 100)
go func() {
defer close(validated)
for url := range source {
if _, err := urlpkg.ParseRequestURI(url); err == nil {
select {
case validated <- url:
case <-ctx.Done():
return
}
}
}
}()

// Этап 2: Дедупликация
deduped := make(chan string, 100)
go func() {
defer close(deduped)
seen := make(map[string]struct{})
for url := range validated {
if _, exists := seen[url]; !exists {
seen[url] = struct{}{}
select {
case deduped <- url:
case <-ctx.Done():
return
}
}
}
}()

// Этап 3: Проверка с ограничением скорости
checked := make(chan HealthCheck, 100)
go func() {
defer close(checked)
sem := make(chan struct{}, 10) // 10 одновременных проверок

var wg sync.WaitGroup
for url := range deduped {
select {
case <-ctx.Done():
wg.Wait()
return
case sem <- struct{}{}:
wg.Add(1)
go func(u string) {
defer func() {
<-sem
wg.Done()
}()

result := performCheck(ctx, u)
select {
case checked <- result:
case <-ctx.Done():
}
}(url)
}
}
wg.Wait()
}()

// Этап 4: Агрегация и фильтрация
final := make(chan HealthCheck, 100)
go func() {
defer close(final)
for result := range checked {
if result.Status != "skip" { // Пропускаем определенные результаты
select {
case final <- result:
case <-ctx.Done():
return
}
}
}
}()

return final
}

4. Управление жизненным циклом и graceful shutdown

Корректное завершение при получении сигнала от ОС:

func RunHealthCheckService(ctx context.Context, config ServiceConfig) error {
// Создаем context с отменой
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Канал для входящих URL (может быть из Kafka, RabbitMQ и т.д.)
urlChannel := make(chan string, config.BufferSize)

// Запуск процессора
checker := NewURLChecker(config.Workers, config.Timeout)
results := checker.Run(ctx, urlChannel)

// Горутина для чтения результатов
var resultWg sync.WaitGroup
resultWg.Add(1)
go func() {
defer resultWg.Done()
for result := range results {
handleResult(result)
}
}()

// Горутина для подачи данных (имитация внешнего источника)
var feedWg sync.WaitGroup
feedWg.Add(1)
go func() {
defer feedWg.Done()
defer close(urlChannel) // Важно: закрываем после окончания подачи

ticker := time.NewTicker(config.RefreshInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
urls := fetchURLsFromSource() // Получение из БД, API и т.д.
for _, url := range urls {
select {
case urlChannel <- url:
case <-ctx.Done():
return
}
}
}
}
}()

// Ожидание сигнала завершения
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

select {
case <-sigChan:
log.Println("Received shutdown signal")
cancel() // Инициируем graceful shutdown
case <-ctx.Done():
}

// Ожидание завершения всех горутин
done := make(chan struct{})
go func() {
feedWg.Wait() // Ждем окончания подачи данных
resultWg.Wait() // Ждем обработки всех результатов
close(done)
}()

// Таймаут на graceful shutdown
select {
case <-done:
log.Println("Graceful shutdown completed")
case <-time.After(config.ShutdownTimeout):
log.Println("Forced shutdown due to timeout")
}

return nil
}

5. SQL для динамических источников данных

Для хранения и управления очередями URL:

CREATE TABLE url_queue (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL,
priority INTEGER DEFAULT 0,
source VARCHAR(100),
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT unique_active_url UNIQUE (url, status)
DEFERRABLE INITIALLY IMMEDIATE
);

CREATE INDEX idx_queue_pending
ON url_queue(status, scheduled_at, priority)
WHERE status = 'pending';

-- Получение следующего URL для обработки
CREATE OR REPLACE FUNCTION dequeue_urls(batch_size INTEGER)
RETURNS TABLE (id INTEGER, url VARCHAR) AS $$
BEGIN
RETURN QUERY
UPDATE url_queue
SET status = 'processing',
updated_at = NOW()
WHERE id IN (
SELECT id
FROM url_queue
WHERE status = 'pending'
AND scheduled_at <= NOW()
AND attempts < max_attempts
ORDER BY priority DESC, scheduled_at ASC
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
RETURNING id, url;
END;
$$ LANGUAGE plpgsql;

-- Обновление статуса после обработки
CREATE OR REPLACE FUNCTION update_url_status(
p_id INTEGER,
p_status VARCHAR,
p_next_attempt INTERVAL DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
UPDATE url_queue
SET status = p_status,
attempts = attempts + 1,
scheduled_at = COALESCE(NOW() + p_next_attempt, scheduled_at),
updated_at = NOW()
WHERE id = p_id;
END;
$$ LANGUAGE plpgsql;

6. Важные аспекты динамической обработки

Семафорный паттерн: Использование буферизованного канала как семафора позволяет ограничить максимальное количество одновременно обрабатываемых URL, предотвращая исчерпание ресурсов системы.

Backpressure механизм: Сигнализация отправителю о невозможности принять новые данные через блокирующую отправку в канал защищает систему от перегрузки.

Graceful degradation: При получении сигнала отмены система должна завершить обработку текущих запросов, не принимая новые, обеспечивая корректное завершение работы.

Idempotentность: При повторной обработке URL из-за сбоев необходимо учитывать возможность дублирования результатов.

Мониторинг очередей: Постоянный мониторинг длины входящей очереди позволяет вовремя обнаруживать проблемы с производительностью или масштабированием.

Этот подход обеспечивает гибкую, масштабируемую и надежную обработку динамических потоков данных с корректным управлением жизненным циклом горутин и каналов, готовую для использования в высоконагруженных production-системах.

Вопрос 5. Как реализовать функцию проверки URL с поддержкой контекста, тайм-аутов и кастомного HTTP-клиента для тестирования?

Таймкод: 00:13:10

Ответ собеседника: Правильный. Создается функция checkURL, принимающая контекст, канал результатов, URL и интерфейс HTTP-клиента. Внутри строится запрос с передачей контекста (для возможности отмены) и выполняется через кастомного клиента с настроенным тайм-аутом. Результат (URL, статус, ошибка) упаковывается в структуру и отправляется в канал. Для тестирования HTTP-клиент выносится в интерфейс, что позволяет легко подменять реальную реализацию на моки или фейковые объекты, возвращающие заранее заданные ответы и ошибки для проверки логики функции.

Правильный ответ:

1. Определение контракта и структур данных

Для обеспечения тестируемости и гибкости вводится интерфейс для HTTP-клиента, который абстрагирует от конкретной реализации:

package healthcheck

import (
"context"
"io"
"net/http"
"time"
)

// HTTPClient определяет контракт для выполнения HTTP-запросов
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}

// CheckResult представляет результат проверки URL
type CheckResult struct {
URL string
Status string // healthy, unhealthy, error, timeout, cancelled
StatusCode int
Latency time.Duration
Error error
CheckedAt time.Time
RetryCount int
ResponseSize int64
}

// Config содержит параметры проверки
type Config struct {
Timeout time.Duration
MaxRetries int
RetryDelay time.Duration
ExpectedStatus []int
FollowRedirects bool
}

2. Реализация ядра проверки с поддержкой контекста

Функция принимает контекст для управления жизненным циклом и отмены операций:

type Checker struct {
client HTTPClient
cfg Config
}

func NewChecker(client HTTPClient, cfg Config) *Checker {
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Second
}
if cfg.MaxRetries == 0 {
cfg.MaxRetries = 2
}
if len(cfg.ExpectedStatus) == 0 {
cfg.ExpectedStatus = []int{http.StatusOK}
}
return &Checker{client: client, cfg: cfg}
}

func (c *Checker) Check(ctx context.Context, url string) CheckResult {
result := CheckResult{
URL: url,
CheckedAt: time.Now(),
}

var lastErr error

for attempt := 0; attempt <= c.cfg.MaxRetries; attempt++ {
result.RetryCount = attempt

// Создаем запрос с контекстом
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
result.Status = "error"
result.Error = fmt.Errorf("failed to create request: %w", err)
return result
}

// Установка заголовков
req.Header.Set("User-Agent", "HealthChecker/1.0")
req.Header.Set("Accept", "*/*")

// Выполнение запроса с таймингом
start := time.Now()
resp, err := c.client.Do(req)
result.Latency = time.Since(start)

if err != nil {
lastErr = err

// Проверка на отмену контекста
if ctx.Err() != nil {
result.Status = "cancelled"
result.Error = ctx.Err()
return result
}

// Проверка на тайм-аут
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
result.Status = "timeout"
} else {
result.Status = "network_error"
}

if attempt < c.cfg.MaxRetries {
select {
case <-time.After(c.cfg.RetryDelay):
continue
case <-ctx.Done():
result.Status = "cancelled"
result.Error = ctx.Err()
return result
}
}

result.Error = fmt.Errorf("all retries exhausted: %w", lastErr)
return result
}

// Обработка успешного ответа
result.StatusCode = resp.StatusCode
result.ResponseSize = resp.ContentLength

// Проверка статуса
expected := false
for _, code := range c.cfg.ExpectedStatus {
if resp.StatusCode == code {
expected = true
break
}
}

// Чтение тела для освобождения соединения
if resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}

if expected {
result.Status = "healthy"
result.Error = nil
return result
}

// Неожиданный статус
result.Status = "unhealthy"
result.Error = fmt.Errorf("unexpected status code: %d", resp.StatusCode)

if attempt < c.cfg.MaxRetries {
select {
case <-time.After(c.cfg.RetryDelay):
continue
case <-ctx.Done():
result.Status = "cancelled"
result.Error = ctx.Err()
return result
}
}

return result
}

return result
}

// CheckAsync выполняет проверку асинхронно
func (c *Checker) CheckAsync(ctx context.Context, url string, resultChan chan<- CheckResult) {
result := c.Check(ctx, url)

select {
case resultChan <- result:
case <-ctx.Done():
// Контекст отменен, результат не отправляем
}
}

3. Продвинутый HTTP-клиент с кастомным транспортом

Реализация клиента с тонкой настройкой для production:

type ProductionClient struct {
client *http.Client
}

func NewProductionClient(timeout time.Duration) *ProductionClient {
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
}

return &ProductionClient{
client: &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Не следовать редиректам автоматически
},
},
}
}

func (p *ProductionClient) Do(req *http.Request) (*http.Response, error) {
return p.client.Do(req)
}

// CircuitBreakerClient оборачивает клиент с защитой от каскадных сбоев
type CircuitBreakerClient struct {
client HTTPClient
breaker *CircuitBreaker
}

func (c *CircuitBreakerClient) Do(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error

cbErr := c.breaker.Execute(func() error {
resp, err = c.client.Do(req)
return err
})

if cbErr != nil {
return nil, cbErr
}

return resp, err
}

4. Мок-реализации для тестирования

// MockHTTPClient для юнит-тестов
type MockHTTPClient struct {
Response *http.Response
Err error
DoFunc func(req *http.Request) (*http.Response, error)
}

func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.DoFunc != nil {
return m.DoFunc(req)
}
return m.Response, m.Err
}

// FakeRoundTripper для более гибкого тестирования
type FakeRoundTripper struct {
RoundTripFunc func(req *http.Request) (*http.Response, error)
}

func (f *FakeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if f.RoundTripFunc != nil {
return f.RoundTripFunc(req)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}, nil
}

func NewTestClient(roundTripper http.RoundTripper) *http.Client {
return &http.Client{
Transport: roundTripper,
Timeout: 1 * time.Second,
}
}

5. Примеры юнит-тестов

package healthcheck

import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
)

func TestChecker_Check_Success(t *testing.T) {
mockClient := &MockHTTPClient{
Response: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
},
}

cfg := Config{
Timeout: 5 * time.Second,
ExpectedStatus: []int{http.StatusOK},
}

checker := NewChecker(mockClient, cfg)
ctx := context.Background()

result := checker.Check(ctx, "http://example.com")

if result.Status != "healthy" {
t.Errorf("Expected healthy status, got %s", result.Status)
}

if result.StatusCode != http.StatusOK {
t.Errorf("Expected status code 200, got %d", result.StatusCode)
}
}

func TestChecker_Check_Timeout(t *testing.T) {
mockClient := &MockHTTPClient{
Err: context.DeadlineExceeded,
}

cfg := Config{Timeout: 1 * time.Millisecond}
checker := NewChecker(mockClient, cfg)
ctx := context.Background()

result := checker.Check(ctx, "http://example.com")

if result.Status != "timeout" {
t.Errorf("Expected timeout status, got %s", result.Status)
}
}

func TestChecker_Check_ContextCancellation(t *testing.T) {
mockClient := &MockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}

cfg := Config{Timeout: 10 * time.Second}
checker := NewChecker(mockClient, cfg)

ctx, cancel := context.WithCancel(context.Background())
cancel() // Немедленная отмена

result := checker.Check(ctx, "http://example.com")

if result.Status != "cancelled" {
t.Errorf("Expected cancelled status, got %s", result.Status)
}
}

func TestChecker_Check_RetryLogic(t *testing.T) {
attemptCount := 0
mockClient := &MockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
attemptCount++
if attemptCount < 3 {
return nil, errors.New("temporary error")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}, nil
},
}

cfg := Config{
Timeout: 5 * time.Second,
MaxRetries: 3,
RetryDelay: 1 * time.Millisecond,
}

checker := NewChecker(mockClient, cfg)
ctx := context.Background()

result := checker.Check(ctx, "http://example.com")

if result.Status != "healthy" {
t.Errorf("Expected healthy after retries, got %s", result.Status)
}

if attemptCount != 3 {
t.Errorf("Expected 3 attempts, got %d", attemptCount)
}

if result.RetryCount != 2 {
t.Errorf("Expected retry count 2, got %d", result.RetryCount)
}
}

func TestChecker_CheckAsync(t *testing.T) {
mockClient := &MockHTTPClient{
Response: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
},
}

cfg := Config{Timeout: 5 * time.Second}
checker := NewChecker(mockClient, cfg)

ctx := context.Background()
resultChan := make(chan CheckResult, 1)

go checker.CheckAsync(ctx, "http://example.com", resultChan)

select {
case result := <-resultChan:
if result.Status != "healthy" {
t.Errorf("Expected healthy status, got %s", result.Status)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for async result")
}
}

6. SQL для хранения результатов проверок

CREATE TABLE health_check_results (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL,
status VARCHAR(50) NOT NULL,
http_status_code INTEGER,
latency_ms INTEGER,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
response_size BIGINT,
checked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
execution_time_ms INTEGER GENERATED ALWAYS AS (
EXTRACT(EPOCH FROM (NOW() - checked_at)) * 1000
) STORED
);

CREATE INDEX idx_results_url_time
ON health_check_results(url, checked_at DESC);

CREATE INDEX idx_results_status
ON health_check_results(status, checked_at);

-- Материализованное представление для дашборда
CREATE MATERIALIZED VIEW health_check_summary AS
SELECT
url,
COUNT(*) as total_checks,
COUNT(*) FILTER (WHERE status = 'healthy') as healthy_checks,
COUNT(*) FILTER (WHERE status = 'unhealthy') as unhealthy_checks,
COUNT(*) FILTER (WHERE status = 'error') as error_checks,
COUNT(*) FILTER (WHERE status = 'timeout') as timeout_checks,
ROUND(AVG(latency_ms)::numeric, 2) as avg_latency_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) as p95_latency_ms,
MAX(checked_at) as last_check,
CASE
WHEN COUNT(*) FILTER (WHERE status = 'healthy')::float / COUNT(*) > 0.95
THEN 'healthy'
ELSE 'degraded'
END as overall_status
FROM health_check_results
WHERE checked_at > NOW() - INTERVAL '24 hours'
GROUP BY url;

-- Запрос для получения последних результатов по каждому URL
CREATE OR REPLACE VIEW latest_health_checks AS
SELECT DISTINCT ON (url)
url,
status,
http_status_code,
latency_ms,
error_message,
checked_at
FROM health_check_results
ORDER BY url, checked_at DESC;

7. Важные архитектурные аспекты

Dependency Injection: Использование интерфейса HTTPClient позволяет легко подменять реализации для тестирования без изменения бизнес-логики. Это стандартный паттерн в Go для обеспечения тестируемости.

Context propagation: Контекст передается через все уровни приложения, позволяя корректно обрабатывать отмену операций, дедлайны и передавать метаданные запросов.

Retry с экспоненциальной задержкой: В production-системах рекомендуется использовать экспоненциальную задержку между попытками для предотвращения эффекта "avalanche" при временных сбоях.

Circuit breaker pattern: Защита от каскадных сбоев при длительных проблемах с внешними сервисами. Клиент должен уметь временно отклонять запросы к проблемным хостам.

Resource cleanup: Всегда необходимо освобождать ресурсы (закрывать тело ответа) даже при ошибках, чтобы избежать утечек соединений.

Metrics collection: Сбор метрик (latency, error rates) критически важен для мониторинга здоровья системы и обнаружения проблем на ранних стадиях.

Этот подход обеспечивает полную тестируемость, гибкость и надежность системы проверки URL, готовую для использования в production-средах с высокими требованиями к стабильности и производительности.

Вопрос 6. Как написать и запустить тесты для функции checkURL с использованием фейкового HTTP-клиента и проверки ожидаемых результатов?

Таймкод: 00:23:46

Ответ собеседника: Правильный. Создаются структуры для тест-кейсов, содержащие ожидаемые ответы (статус, ошибка) от фейкового клиента и результаты, которые должна вернуть функция. Фейковый клиент реализует интерфейс HTTP-клиента, возвращая заранее заданные значения из полей структуры. Для каждого теста настраиваются входные данные, вызывается checkURL в горутине, результат считывается из канала и сравнивается с ожидаемыми значениями. Если результат не совпадает, тест падает с выводом подробной информации о расхождении. Запуск тестов выполняется стандартной командой go test.

Правильный ответ:

1. Архитектура тестовых кейсов

Для системного покрытия всех сценариев используется параметризованный подход с тест-кейсами:

package healthcheck

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

// testCase описывает один сценарий тестирования
type testCase struct {
name string
url string
mockResponse *http.Response
mockError error
expectedStatus string
expectedCode int
expectError bool
timeout time.Duration
}

// fakeHTTPClient реализует интерфейс HTTPClient для тестов
type fakeHTTPClient struct {
response *http.Response
err error
doFunc func(*http.Request) (*http.Response, error)
}

func (f *fakeHTTPClient) Do(req *http.Request) (*http.Response, error) {
if f.doFunc != nil {
return f.doFunc(req)
}
return f.response, f.err
}

2. Параметризованные тесты

Использование подтестов (subtests) для изоляции сценариев:

func TestCheckURL_Parameterized(t *testing.T) {
tests := []testCase{
{
name: "successful_200_response",
url: "http://example.com",
mockResponse: &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("OK"))},
expectedStatus: "healthy",
expectedCode: http.StatusOK,
expectError: false,
timeout: 5 * time.Second,
},
{
name: "server_error_500",
url: "http://example.com",
mockResponse: &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("Error"))},
expectedStatus: "unhealthy",
expectedCode: http.StatusInternalServerError,
expectError: true,
timeout: 5 * time.Second,
},
{
name: "network_timeout",
url: "http://example.com",
mockError: fmt.Errorf("net/http: request canceled while waiting for connection"),
expectedStatus: "network_error",
expectError: true,
timeout: 1 * time.Millisecond, // Короткий тайм-аут для теста
},
{
name: "context_cancellation",
url: "http://example.com",
mockError: context.Canceled,
expectedStatus: "cancelled",
expectError: true,
timeout: 5 * time.Second,
},
{
name: "redirect_loop",
url: "http://example.com",
doFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{req.URL.String()}},
Body: io.NopCloser(strings.NewReader("")),
}, nil
},
expectedStatus: "unhealthy",
expectError: true,
timeout: 5 * time.Second,
},
{
name: "slow_response_timeout",
url: "http://example.com",
doFunc: func(req *http.Request) (*http.Response, error) {
time.Sleep(100 * time.Millisecond)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}, nil
},
expectedStatus: "timeout",
expectError: true,
timeout: 50 * time.Millisecond,
},
{
name: "non_200_success",
url: "http://example.com",
mockResponse: &http.Response{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(strings.NewReader("")),
},
expectedStatus: "unhealthy",
expectedCode: http.StatusNoContent,
expectError: true,
timeout: 5 * time.Second,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Создаем фейк-клиент
client := &fakeHTTPClient{
response: tt.mockResponse,
err: tt.mockError,
doFunc: tt.doFunc,
}

cfg := Config{
Timeout: tt.timeout,
MaxRetries: 0,
ExpectedStatus: []int{http.StatusOK},
}

checker := NewChecker(client, cfg)
ctx := context.Background()

result := checker.Check(ctx, tt.url)

// Проверки
if result.Status != tt.expectedStatus {
t.Errorf("Expected status %q, got %q", tt.expectedStatus, result.Status)
}

if tt.expectedCode > 0 && result.StatusCode != tt.expectedCode {
t.Errorf("Expected status code %d, got %d", tt.expectedCode, result.StatusCode)
}

if tt.expectError && result.Error == nil {
t.Error("Expected error, got nil")
}

if !tt.expectError && result.Error != nil {
t.Errorf("Expected no error, got %v", result.Error)
}

if result.URL != tt.url {
t.Errorf("Expected URL %q, got %q", tt.url, result.URL)
}
})
}
}

3. Тестирование асинхронной версии

func TestCheckURLAsync_ChannelCommunication(t *testing.T) {
mockResponse := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}

client := &fakeHTTPClient{response: mockResponse}
cfg := Config{Timeout: 5 * time.Second}
checker := NewChecker(client, cfg)

ctx := context.Background()
resultChan := make(chan CheckResult, 1)

go checker.CheckAsync(ctx, "http://example.com", resultChan)

select {
case result := <-resultChan:
if result.Status != "healthy" {
t.Errorf("Expected healthy status, got %s", result.Status)
}
if result.StatusCode != http.StatusOK {
t.Errorf("Expected status code 200, got %d", result.StatusCode)
}
case <-time.After(2 * time.Second):
t.Fatal("Timeout waiting for async result")
}
}

func TestCheckURLAsync_ContextCancellation(t *testing.T) {
// Симуляция медленного ответа
client := &fakeHTTPClient{
doFunc: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}

cfg := Config{Timeout: 10 * time.Second}
checker := NewChecker(client, cfg)

ctx, cancel := context.WithCancel(context.Background())
cancel() // Немедленная отмена

resultChan := make(chan CheckResult, 1)
go checker.CheckAsync(ctx, "http://example.com", resultChan)

select {
case result := <-resultChan:
// При отмене контекста результат может не отправиться
// Это ожидаемое поведение
t.Logf("Result received: %+v", result)
case <-time.After(100 * time.Millisecond):
// Ожидаем, что результат не будет отправлен
t.Log("No result sent (expected for cancelled context)")
}
}

4. Интеграционное тестирование с реальным сервером

func TestCheckURL_RealServer(t *testing.T) {
// Создаем тестовый сервер
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthy":
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
case "/slow":
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusOK)
case "/error":
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer testServer.Close()

client := NewProductionClient(5 * time.Second)
cfg := Config{
Timeout: 200 * time.Millisecond,
ExpectedStatus: []int{http.StatusOK},
}

checker := NewChecker(client, cfg)
ctx := context.Background()

tests := []struct {
path string
expected string
}{
{"/healthy", "healthy"},
{"/error", "unhealthy"},
{"/slow", "timeout"}, // Превышает тайм-аут
}

for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := checker.Check(ctx, testServer.URL+tt.path)
if result.Status != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result.Status)
}
})
}
}

5. Тестирование retry-логики

func TestCheckURL_RetryLogic(t *testing.T) {
attemptCount := 0
maxAttempts := 3

client := &fakeHTTPClient{
doFunc: func(req *http.Request) (*http.Response, error) {
attemptCount++
if attemptCount < maxAttempts {
return nil, fmt.Errorf("temporary error %d", attemptCount)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}, nil
},
}

cfg := Config{
Timeout: 5 * time.Second,
MaxRetries: maxAttempts - 1,
RetryDelay: 1 * time.Millisecond,
}

checker := NewChecker(client, cfg)
ctx := context.Background()

result := checker.Check(ctx, "http://example.com")

if result.Status != "healthy" {
t.Errorf("Expected healthy after retries, got %s", result.Status)
}

if attemptCount != maxAttempts {
t.Errorf("Expected %d attempts, got %d", maxAttempts, attemptCount)
}

if result.RetryCount != maxAttempts-1 {
t.Errorf("Expected retry count %d, got %d", maxAttempts-1, result.RetryCount)
}
}

func TestCheckURL_RetryExhaustion(t *testing.T) {
client := &fakeHTTPClient{
err: fmt.Errorf("permanent error"),
}

cfg := Config{
Timeout: 5 * time.Second,
MaxRetries: 2,
RetryDelay: 1 * time.Millisecond,
}

checker := NewChecker(client, cfg)
ctx := context.Background()

result := checker.Check(ctx, "http://example.com")

if result.Status != "network_error" {
t.Errorf("Expected network_error, got %s", result.Status)
}

if result.RetryCount != cfg.MaxRetries {
t.Errorf("Expected retry count %d, got %d", cfg.MaxRetries, result.RetryCount)
}

if !strings.Contains(result.Error.Error(), "all retries exhausted") {
t.Errorf("Expected 'all retries exhausted' error, got %v", result.Error)
}
}

6. Тестирование конкурентного доступа

func TestCheckURL_ConcurrentSafety(t *testing.T) {
var requestCount int32
client := &fakeHTTPClient{
doFunc: func(req *http.Request) (*http.Response, error) {
atomic.AddInt32(&requestCount, 1)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("OK")),
}, nil
},
}

cfg := Config{Timeout: 5 * time.Second}
checker := NewChecker(client, cfg)

const goroutines = 100
var wg sync.WaitGroup
results := make(chan CheckResult, goroutines)

ctx := context.Background()

for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
url := fmt.Sprintf("http://example.com/%d", id)
result := checker.Check(ctx, url)
results <- result
}(i)
}

wg.Wait()
close(results)

successCount := 0
for result := range results {
if result.Status == "healthy" {
successCount++
}
}

if successCount != goroutines {
t.Errorf("Expected %d successful checks, got %d", goroutines, successCount)
}

if atomic.LoadInt32(&requestCount) != goroutines {
t.Errorf("Expected %d total requests, got %d", goroutines, requestCount)
}
}

7. SQL для тестовых данных

-- Таблица для хранения ожидаемых результатов тестов
CREATE TABLE test_scenarios (
id SERIAL PRIMARY KEY,
scenario_name VARCHAR(100) NOT NULL UNIQUE,
url VARCHAR(500) NOT NULL,
mock_response_code INTEGER,
mock_response_body TEXT,
mock_error_type VARCHAR(50),
expected_status VARCHAR(50) NOT NULL,
expected_error_pattern TEXT,
timeout_ms INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);

-- Пример данных для тестов
INSERT INTO test_scenarios (
scenario_name, url, mock_response_code,
expected_status, timeout_ms
) VALUES
('success_200', 'http://test.com/ok', 200, 'healthy', 5000),
('server_error', 'http://test.com/error', 500, 'unhealthy', 5000),
('timeout', 'http://test.com/slow', NULL, 'timeout', 100);

-- Запрос для генерации тест-кейсов из БД
-- (может использоваться для data-driven тестов)
SELECT
scenario_name,
url,
mock_response_code,
expected_status,
timeout_ms
FROM test_scenarios
WHERE created_at > NOW() - INTERVAL '30 days';

8. Скрипты запуска тестов

# Запуск всех тестов
go test -v ./...

# Запуск конкретного теста
go test -v -run TestCheckURL_Parameterized

# Запуск с покрытием
go test -v -coverprofile=coverage.out ./healthcheck
go tool cover -html=coverage.out

# Бенчмарки
go test -bench=. -benchmem

# Тесты с race detector
go test -race -v ./healthcheck

# Тесты с тайм-аутом
go test -timeout 30s -v ./healthcheck

9. Важные аспекты тестирования

Изоляция тестов: Каждый тест должен быть независимым и не зависеть от состояния других тестов. Использование фейк-клиентов и подтестов обеспечивает эту изоляцию.

Покрытие граничных условий: Важно тестировать не только успешные сценарии, но и тайм-ауты, отмены контекста, сетевые ошибки и исчерпание попыток.

Детерминированность: Все тесты должны быть детерминированными — одинаковые входные данные всегда должны давать одинаковые результаты.

Производительность тестов: Использование моков вместо реальных сетевых вызовов делает тесты быстрыми и надежными.

Data-driven подход: Параметризованные тесты позволяют легко добавлять новые сценарии без дублирования кода.

Этот подход обеспечивает полное покрытие всех сценариев работы функции проверки URL, гарантирует корректность работы в различных условиях и позволяет легко добавлять новые тест-кейсы по мере развития требований.