diff --git a/History/connection-reset-analysis.md b/History/connection-reset-analysis.md new file mode 100644 index 0000000..a4ed087 --- /dev/null +++ b/History/connection-reset-analysis.md @@ -0,0 +1,197 @@ +# Запрос на анализ: случайные обрывы соединения при HTTP POST + +**Дата:** 2026-06-17 +**Проект:** Contracts App (Flask + PostgreSQL на managed-хостинге) +**Версия:** v1.21 + +--- + +## Симптом + +Любой HTTP POST на managed-хостинг pythonk8s.services.ngcloud.ru **случайно** обрывается: + +``` +100KB #1: HTTP:000 +100KB #2: HTTP:000 +100KB #3: HTTP:200 +100KB #4: HTTP:000 +100KB #5: HTTP:000 +``` + +- HTTP 000 — curl не может установить TCP-соединение (Connection reset? Connection refused?) +- Не зависит от: размера (17KB падает, 35KB работает), типа файла, кодировки +- FormData и JSON — одинаково +- Health check (GET /health, без БД) — всегда 200 (5/5) +- Прямой доступ к БД через /test (JSON) — всегда работает + +## Инфраструктура + +``` +Клиент → ddos-guard → nginx/Ingress → gunicorn → Flask (1 worker?) + ↓ + PostgreSQL 17.6 +``` + +- Managed-хостинг: pythonk8s.services.ngcloud.ru +- Деплой: git push master → автосборка +- БД: внутренний кластер postgresqlk8s-master...svc.cluster.local +- Код: Flask (app.run, host=0.0.0.0:5000) + +## Что пробовали + +1. **FormData/multipart** — POST / с файлом → ✗ +2. **Чанки 50KB** (FormData) → только первый чанк доходит +3. **Чанки в PostgreSQL** (таблица chunks) → только первый чанк +4. **Пул соединений psycopg2** (ThreadedConnectionPool min=1 max=3) → ✗ +5. **JSON/base64** (POST /upload, Content-Type: application/json) → ✗ +6. **Прямой INSERT в БД** через /test endpoint (JSON) → ✓ всегда + +## Код + +### Сервер: простое сохранение файла в БД + +```python +# app.py +def _upload_json(self): + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Нет JSON"}), 400 + + filename = data.get("filename", "") + b64 = data.get("data", "") + if not filename or not b64: + return jsonify({"error": "Нет filename или data"}), 400 + + import base64 as b64mod + file_bytes = b64mod.b64decode(b64) + mime = mimeutil.guess_mime(filename) or "application/octet-stream" + cid = data.get("cid") + + conn = None + if cid: + contract_id = cid + else: + conn, err = db.connect() + if err: + return jsonify({"error": f"БД: {err}"}), 500 + cur = conn.cursor() + cur.execute( + "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", + ("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""), + ) + contract_id = cur.fetchone()[0] + conn.commit() + + doc_id = _save_file_to_db(filename, file_bytes, mime, str(contract_id), conn=conn) + if conn: + cur.close() + db.put_conn(conn) + return jsonify({"contract_id": str(contract_id), "doc_id": str(doc_id) if doc_id else None}) +``` + +### Пул соединений (db.py) + +```python +import os +from psycopg2 import pool as _pgpool + +_pool = None + +def _get_pool(): + global _pool + if _pool is None: + _pool = _pgpool.ThreadedConnectionPool( + minconn=1, + maxconn=3, + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT", 5432), + dbname=os.getenv("DB_NAME"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASS"), + sslmode=os.getenv("DB_SSLMODE", "disable"), + ) + return _pool + +def connect(): + try: + return _get_pool().getconn(), None + except Exception as e: + return None, str(e) + +def put_conn(conn): + try: + _get_pool().putconn(conn) + except Exception: + pass + +def execute(sql_text, params=None): + conn, err = connect() + if err: + return None, f"connect: {err}" + try: + cur = conn.cursor() + cur.execute(sql_text, params) + conn.commit() + rowcount = cur.rowcount + cur.close() + return rowcount, None + except Exception as e: + try: conn.rollback() + except: pass + return None, str(e) + finally: + put_conn(conn) +``` + +### Клиент (JavaScript) + +```javascript +function readFileAsBase64(file, onProgress) { + return new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.onload = function() { + var b64 = reader.result.split(',')[1]; + resolve(b64); + }; + reader.onerror = function() { reject(new Error('Read error')); }; + reader.readAsDataURL(file); + }); + } + + function uploadFileJSON(file, cid, onProgress) { + return new Promise(function(resolve, reject) { + readFileAsBase64(file, onProgress).then(function(b64) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.timeout = 120000; + xhr.onload = function() { + if (xhr.status === 200) { + try { + var resp = JSON.parse(xhr.responseText); + if (resp.error) { reject(new Error(resp.error)); return; } + resolve(resp); + } catch(e) { reject(new Error('Bad JSON')); } + } else { + reject(new Error('HTTP ' + xhr.status)); + } + }; + xhr.onerror = function() { reject(new Error('Сеть')); }; + xhr.ontimeout = function() { reject(new Error('Таймаут')); }; + xhr.send(JSON.stringify({ + filename: file.name, + data: b64, + cid: cid || null + })); + }).catch(reject); + }); + } +``` + +## Вопросы + +1. Почему health check (GET /health, просто return "OK") всегда работает, а POST с любым телом — случайно обрывается? +2. Может ли проблема быть в ddos-guard, который считает частые POST за флуд? +3. Может ли gunicorn с 1 worker'ом не справляться с последовательными запросами (keepalive, таймауты)? +4. Почему curl с локалхоста показывает HTTP 000 (нет TCP-соединения), а не HTTP 502/504? +5. Может ли managed PostgreSQL обрывать соединения пула, вызывая каскадный отказ?