ZHIWEI666 commited on
Commit
e12a676
·
verified ·
1 Parent(s): 0fe2742

Upload 11 files

Browse files
app.py CHANGED
@@ -4,7 +4,7 @@ install_uvicorn_privacy_filters()
4
 
5
  from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
6
  from rate_limit import shared_limit
7
- from response_privacy import requires_no_store
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import Response, JSONResponse, FileResponse
10
  from sqlalchemy.orm import Session
@@ -74,6 +74,7 @@ from router_messages import (router as messages_router, start_message_io, stop_m
74
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
75
  from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
76
  from proxy_download_work import start_proxy_downloads, stop_proxy_downloads, close_proxy_downloads
 
77
  from router_proxy import router as proxy_router # 🔗 代理下载
78
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
79
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
@@ -170,16 +171,7 @@ app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response
170
  from public_list_etag import PublicListETag
171
  app.add_middleware(PublicListETag)
172
 
173
- @app.middleware("http")
174
- async def private_response_cache_control(request: Request, call_next):
175
- response = await call_next(request)
176
- if requires_no_store(request.url.path, request.headers.get("Authorization")):
177
- response.headers["Cache-Control"] = "no-store"
178
- vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
179
- if not any(name.lower() in {"authorization", "*"} for name in vary):
180
- vary.append("Authorization")
181
- response.headers["Vary"] = ", ".join(vary)
182
- return response
183
 
184
 
185
  @app.exception_handler(RequestValidationError)
@@ -327,6 +319,7 @@ async def on_startup():
327
  start_tasks_io()
328
  start_proxy_access()
329
  start_proxy_downloads()
 
330
  start_comment_reads()
331
  start_admin_scripts()
332
 
@@ -387,6 +380,7 @@ async def _shutdown_resources():
387
  for stage, stop in (
388
  ('stop_wallet_sql', stop_wallet_sql),
389
  ('stop_proxy_downloads', stop_proxy_downloads),
 
390
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
391
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
392
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
@@ -404,6 +398,7 @@ async def _shutdown_resources():
404
  for stage, close, args in (
405
  ('wait_warmup', wait_startup_warmup, (app.state,)),
406
  ('close_proxy_downloads', close_proxy_downloads, ()),
 
407
  ('close_wallet_sql', close_wallet_sql, ()),
408
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
409
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
@@ -465,11 +460,13 @@ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件
465
  # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
466
  # ==========================================
467
  from media_proxy_service import read_public_media
 
468
 
469
  @app.get("/api/image_proxy")
470
- def proxy_hf_image(url: str = None, path: str = None):
471
- return read_public_media(url, path, store=db, download=hf_hub_download,
472
- file_response=FileResponse, error_response=create_error_response)
 
473
 
474
  # ==========================================
475
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
 
4
 
5
  from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
6
  from rate_limit import shared_limit
7
+ from response_privacy import PrivateResponseCacheControl
8
  from fastapi.middleware.cors import CORSMiddleware
9
  from fastapi.responses import Response, JSONResponse, FileResponse
10
  from sqlalchemy.orm import Session
 
74
  from router_wallet import (router as wallet_router, start_wallet_sql, stop_wallet_sql, close_wallet_sql) # 💰 钱包/提现
75
  from proxy_access import start_proxy_access, stop_proxy_access, close_proxy_access
76
  from proxy_download_work import start_proxy_downloads, stop_proxy_downloads, close_proxy_downloads
77
+ from media_proxy_service import start_public_media, stop_public_media, close_public_media, public_media_endpoint
78
  from router_proxy import router as proxy_router # 🔗 代理下载
79
  from router_posts import (router as posts_router, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
80
  from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
 
171
  from public_list_etag import PublicListETag
172
  app.add_middleware(PublicListETag)
173
 
174
+ app.add_middleware(PrivateResponseCacheControl)
 
 
 
 
 
 
 
 
 
175
 
176
 
177
  @app.exception_handler(RequestValidationError)
 
319
  start_tasks_io()
320
  start_proxy_access()
321
  start_proxy_downloads()
322
+ start_public_media()
323
  start_comment_reads()
324
  start_admin_scripts()
325
 
 
380
  for stage, stop in (
381
  ('stop_wallet_sql', stop_wallet_sql),
382
  ('stop_proxy_downloads', stop_proxy_downloads),
383
+ ('stop_public_media', stop_public_media),
384
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
385
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
386
  ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
 
398
  for stage, close, args in (
399
  ('wait_warmup', wait_startup_warmup, (app.state,)),
400
  ('close_proxy_downloads', close_proxy_downloads, ()),
401
+ ('close_public_media', close_public_media, ()),
402
  ('close_wallet_sql', close_wallet_sql, ()),
403
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
404
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
 
460
  # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
461
  # ==========================================
462
  from media_proxy_service import read_public_media
463
+ from media_file_response import OwnedMediaFileResponse
464
 
465
  @app.get("/api/image_proxy")
466
+ @public_media_endpoint
467
+ async def proxy_hf_image(url: str = None, path: str = None):
468
+ return await run_owned_thread(read_public_media, url, path, store=db, download=hf_hub_download,
469
+ file_response=OwnedMediaFileResponse, error_response=create_error_response)
470
 
471
  # ==========================================
472
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
media_file_response.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """媒体缓存文件的有界读取、Range交付与真实线程关闭。"""
2
+ import asyncio
3
+ import os
4
+ from secrets import token_hex
5
+ import stat
6
+
7
+ from fastapi.responses import FileResponse
8
+ from starlette.datastructures import MutableHeaders
9
+ from owned_thread import run_owned_thread
10
+ from workflow_stream_response import OwnedDelivery
11
+
12
+
13
+ class OwnedMediaFileResponse(OwnedDelivery, FileResponse):
14
+ total_timeout = 300
15
+ operation_timeout = 15
16
+ eof_timeout = 5
17
+
18
+ def __init__(self, path, **kwargs):
19
+ snapshot = os.stat(path)
20
+ if not stat.S_ISREG(snapshot.st_mode):
21
+ raise ValueError('invalid media cache file')
22
+ super().__init__(path, stat_result=snapshot, **kwargs)
23
+ self._file = None
24
+ self._init_delivery(self._close_file)
25
+
26
+ async def send_owned(self, scope, receive, send):
27
+ async def bounded_send(message):
28
+ final = message['type'] == 'http.response.body' and not message.get('more_body', False)
29
+ async with asyncio.timeout(self.eof_timeout if final else self.operation_timeout):
30
+ await send(message)
31
+ async with asyncio.timeout(self.total_timeout):
32
+ return await super().send_owned(scope, receive, bounded_send)
33
+
34
+ async def _file_work(self, function, *args):
35
+ async with asyncio.timeout(self.operation_timeout):
36
+ return await run_owned_thread(function, *args)
37
+
38
+ async def _open_file(self):
39
+ def open_file():
40
+ self._file = open(self.path, 'rb')
41
+ self._check_snapshot(os.fstat(self._file.fileno()))
42
+ await self._file_work(open_file)
43
+
44
+ def _check_snapshot(self, current):
45
+ fields = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns')
46
+ if os.name != 'nt':
47
+ fields += ('st_ctime_ns',)
48
+ if (not stat.S_ISREG(current.st_mode) or not current.st_ino or not self.stat_result.st_ino
49
+ or any(getattr(current, key) != getattr(self.stat_result, key) for key in fields)):
50
+ raise RuntimeError('media cache file changed')
51
+
52
+ async def _prepare_file(self, header_only):
53
+ if header_only:
54
+ current = await self._file_work(os.stat, self.path)
55
+ self._check_snapshot(current)
56
+ else:
57
+ await self._open_file()
58
+
59
+ async def _close_file(self):
60
+ if self._file is None:
61
+ return
62
+ def close_file():
63
+ try:
64
+ self._file.close()
65
+ finally:
66
+ self._file = None
67
+ await run_owned_thread(close_file)
68
+
69
+ async def _send_part(self, send, start, end, *, final):
70
+ await self._file_work(self._file.seek, start)
71
+ remaining = end - start
72
+ if remaining == 0:
73
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': not final})
74
+ while remaining:
75
+ chunk = await self._file_work(self._file.read, min(self.chunk_size, remaining))
76
+ if not chunk:
77
+ raise RuntimeError('media cache file truncated')
78
+ remaining -= len(chunk)
79
+ await send({'type': 'http.response.body', 'body': chunk, 'more_body': remaining > 0 or not final})
80
+
81
+ async def _handle_simple(self, send, send_header_only, send_pathsend):
82
+ await self._prepare_file(send_header_only)
83
+ await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.raw_headers})
84
+ if send_header_only:
85
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
86
+ return
87
+ await self._send_part(send, 0, self.stat_result.st_size, final=True)
88
+
89
+ async def _handle_single_range(self, send, start, end, file_size, send_header_only):
90
+ await self._prepare_file(send_header_only)
91
+ headers = MutableHeaders(raw=list(self.raw_headers))
92
+ headers['content-range'] = f'bytes {start}-{end - 1}/{file_size}'
93
+ headers['content-length'] = str(end - start)
94
+ await send({'type': 'http.response.start', 'status': 206, 'headers': headers.raw})
95
+ if send_header_only:
96
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
97
+ return
98
+ await self._send_part(send, start, end, final=True)
99
+
100
+ async def _handle_multiple_ranges(self, send, ranges, file_size, send_header_only):
101
+ await self._prepare_file(send_header_only)
102
+ boundary = token_hex(13)
103
+ length, header = self.generate_multipart(ranges, boundary, file_size, self.headers['content-type'])
104
+ headers = MutableHeaders(raw=list(self.raw_headers))
105
+ headers['content-type'] = f'multipart/byteranges; boundary={boundary}'
106
+ headers['content-length'] = str(length)
107
+ await send({'type': 'http.response.start', 'status': 206, 'headers': headers.raw})
108
+ if send_header_only:
109
+ await send({'type': 'http.response.body', 'body': b'', 'more_body': False})
110
+ return
111
+ for start, end in ranges:
112
+ await send({'type': 'http.response.body', 'body': header(start, end), 'more_body': True})
113
+ await self._send_part(send, start, end, final=False)
114
+ await send({'type': 'http.response.body', 'body': b'\r\n', 'more_body': True})
115
+ await send({'type': 'http.response.body', 'body': f'--{boundary}--'.encode('latin-1'), 'more_body': False})
media_proxy_service.py CHANGED
@@ -7,6 +7,13 @@ import urllib.parse
7
  import uuid
8
  from fastapi import HTTPException
9
  from fastapi.responses import JSONResponse
 
 
 
 
 
 
 
10
 
11
  logger = logging.getLogger("ComfyUI-Ranking")
12
 
 
7
  import uuid
8
  from fastapi import HTTPException
9
  from fastapi.responses import JSONResponse
10
+ from proxy_download_work import ProxyDownloadWork
11
+
12
+ _public_media_work = ProxyDownloadWork(16)
13
+ public_media_endpoint = _public_media_work.endpoint
14
+ start_public_media = _public_media_work.start
15
+ stop_public_media = _public_media_work.stop
16
+ close_public_media = _public_media_work.close
17
 
18
  logger = logging.getLogger("ComfyUI-Ranking")
19
 
proxy_download_work.py CHANGED
@@ -3,7 +3,7 @@ import asyncio
3
  from functools import wraps
4
 
5
  from fastapi import HTTPException
6
- from workflow_stream_response import OwnedProxyResponse
7
 
8
 
9
  def _spawn(function):
@@ -58,7 +58,7 @@ class _Download:
58
 
59
  async def prepare(self, function, args, kwargs):
60
  result = await function(*args, **kwargs)
61
- if isinstance(result, OwnedProxyResponse):
62
  result.bind_download(self)
63
  self.response = result
64
  return result
 
3
  from functools import wraps
4
 
5
  from fastapi import HTTPException
6
+ from workflow_stream_response import OwnedDelivery
7
 
8
 
9
  def _spawn(function):
 
58
 
59
  async def prepare(self, function, args, kwargs):
60
  result = await function(*args, **kwargs)
61
+ if isinstance(result, OwnedDelivery):
62
  result.bind_download(self)
63
  self.response = result
64
  return result
response_privacy.py CHANGED
@@ -1,5 +1,6 @@
1
  """浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。"""
2
  import re
 
3
 
4
  _PRIVATE_PATH = re.compile(
5
  r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)"
@@ -12,3 +13,26 @@ def requires_no_store(path, authorization=None):
12
  # 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。
13
  # 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。
14
  return bool(authorization or _PRIVATE_PATH.search(path))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。"""
2
  import re
3
+ from starlette.datastructures import Headers, MutableHeaders
4
 
5
  _PRIVATE_PATH = re.compile(
6
  r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)"
 
13
  # 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。
14
  # 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。
15
  return bool(authorization or _PRIVATE_PATH.search(path))
16
+
17
+
18
+ class PrivateResponseCacheControl:
19
+ def __init__(self, app):
20
+ self.app = app
21
+
22
+ async def __call__(self, scope, receive, send):
23
+ if (scope['type'] != 'http'
24
+ or not requires_no_store(scope.get('path', ''), Headers(scope=scope).get('authorization'))):
25
+ return await self.app(scope, receive, send)
26
+
27
+ async def private_send(message):
28
+ if message['type'] == 'http.response.start':
29
+ headers = MutableHeaders(raw=list(message.get('headers', ())))
30
+ headers['Cache-Control'] = 'no-store'
31
+ vary = [name.strip() for name in headers.get('Vary', '').split(',') if name.strip()]
32
+ if not any(name.lower() in {'authorization', '*'} for name in vary):
33
+ vary.append('Authorization')
34
+ headers['Vary'] = ', '.join(vary)
35
+ message = {**message, 'headers': headers.raw}
36
+ await send(message)
37
+
38
+ return await self.app(scope, receive, private_send)
router_proxy.py CHANGED
@@ -48,6 +48,8 @@ async def _validate_public_http_target(url: str) -> str:
48
  try:
49
  await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
50
  except PublicResolutionError as error:
 
 
51
  detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
52
  raise HTTPException(status_code=400, detail=detail) from None
53
  return host
 
48
  try:
49
  await _resolve_public_ips(host, port or (443 if parsed.scheme == "https" else 80))
50
  except PublicResolutionError as error:
51
+ if error.reason == 'dns_busy':
52
+ raise HTTPException(status_code=503, detail='service_unavailable') from None
53
  detail = "下载地址无法解析" if error.reason == "dns_unavailable" else "下载地址解析到非公网地址"
54
  raise HTTPException(status_code=400, detail=detail) from None
55
  return host
safe_http_transport.py CHANGED
@@ -8,6 +8,7 @@ import socket
8
  import httpcore
9
  import httpx
10
  from proxy_resources import ProxyResources
 
11
 
12
 
13
  _validated_ip = ContextVar("ranking_validated_ip", default=None)
@@ -18,7 +19,12 @@ _DNS_TIMEOUT = 8
18
  class PublicResolutionError(httpx.ConnectError):
19
  def __init__(self, reason):
20
  self.reason = reason
21
- super().__init__("目标域名无法解析" if reason == "dns_unavailable" else "目标域名解析到非公网地址")
 
 
 
 
 
22
 
23
 
24
  def _public(address):
@@ -113,23 +119,42 @@ async def _resolve_hf_public_ips(host):
113
 
114
 
115
  async def _resolve_public_ips(host: str, port: int) -> tuple[str, ...]:
116
- normalized_host = host.rstrip(".").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  try:
118
  address = ipaddress.ip_address(normalized_host)
119
  addresses = [address]
120
  except ValueError:
121
  try:
122
- records = await asyncio.to_thread(
123
  socket.getaddrinfo, normalized_host, port, type=socket.SOCK_STREAM
124
  )
125
  except OSError as exc:
126
  if normalized_host == "huggingface.co":
127
  return await _resolve_hf_public_ips(normalized_host)
128
  raise PublicResolutionError("dns_unavailable") from exc
129
- addresses = [
130
- ipaddress.ip_address(record[4][0].split("%", 1)[0])
131
- for record in records
132
- ]
 
 
 
 
 
133
 
134
  if not addresses or any(not _public(address) for address in addresses):
135
  if normalized_host == "huggingface.co":
 
8
  import httpcore
9
  import httpx
10
  from proxy_resources import ProxyResources
11
+ from owned_thread import OwnedThreadGroup
12
 
13
 
14
  _validated_ip = ContextVar("ranking_validated_ip", default=None)
 
19
  class PublicResolutionError(httpx.ConnectError):
20
  def __init__(self, reason):
21
  self.reason = reason
22
+ super().__init__("目标域名无法解析" if reason in ('dns_unavailable', 'dns_busy') else "目标域名解析到非公网地址")
23
+
24
+
25
+ _DNS_SYSTEM_CAPACITY = 8
26
+ _DNS_MAX_RECORDS = 32
27
+ _system_dns = OwnedThreadGroup(_DNS_SYSTEM_CAPACITY, lambda: PublicResolutionError('dns_busy'))
28
 
29
 
30
  def _public(address):
 
119
 
120
 
121
  async def _resolve_public_ips(host: str, port: int) -> tuple[str, ...]:
122
+ try:
123
+ normalized_host = host.rstrip('.').lower()
124
+ if (not normalized_host or any(ord(char) <= 32 or ord(char) == 127 for char in normalized_host)
125
+ or len(normalized_host.encode('idna')) > 253):
126
+ raise ValueError()
127
+ except (AttributeError, TypeError, ValueError):
128
+ raise PublicResolutionError('dns_unavailable') from None
129
+ try:
130
+ async with asyncio.timeout(_DNS_TIMEOUT):
131
+ return await _resolve_public_ips_once(normalized_host, port)
132
+ except TimeoutError:
133
+ raise PublicResolutionError('dns_unavailable') from None
134
+
135
+
136
+ async def _resolve_public_ips_once(normalized_host: str, port: int) -> tuple[str, ...]:
137
  try:
138
  address = ipaddress.ip_address(normalized_host)
139
  addresses = [address]
140
  except ValueError:
141
  try:
142
+ records = await _system_dns.run(
143
  socket.getaddrinfo, normalized_host, port, type=socket.SOCK_STREAM
144
  )
145
  except OSError as exc:
146
  if normalized_host == "huggingface.co":
147
  return await _resolve_hf_public_ips(normalized_host)
148
  raise PublicResolutionError("dns_unavailable") from exc
149
+ if not isinstance(records, list) or len(records) > _DNS_MAX_RECORDS:
150
+ raise PublicResolutionError('dns_unavailable')
151
+ try:
152
+ addresses = [
153
+ ipaddress.ip_address(record[4][0].split("%", 1)[0])
154
+ for record in records
155
+ ]
156
+ except (IndexError, AttributeError, TypeError, ValueError):
157
+ raise PublicResolutionError('dns_unavailable') from None
158
 
159
  if not addresses or any(not _public(address) for address in addresses):
160
  if normalized_host == "huggingface.co":
workflow_download_service.py CHANGED
@@ -119,6 +119,7 @@ async def prepare_workflow_download(item, item_id, *, store, source_authorizer,
119
  "下载源重定向无效或次数过多": "invalid_redirect",
120
  "下载源重定向失败": "redirect_failed",
121
  "下载文件超过大小限制": "size_exceeded",
 
122
  }
123
  reason = reasons.get(error.detail, "http_rejected") if type(error.detail) is str else "http_rejected"
124
  status = error.status_code if type(error.status_code) is int else 0
 
119
  "下载源重定向无效或次数过多": "invalid_redirect",
120
  "下载源重定向失败": "redirect_failed",
121
  "下载文件超过大小限制": "size_exceeded",
122
+ "service_unavailable": "dns_busy",
123
  }
124
  reason = reasons.get(error.detail, "http_rejected") if type(error.detail) is str else "http_rejected"
125
  status = error.status_code if type(error.status_code) is int else 0
workflow_stream_response.py CHANGED
@@ -3,9 +3,8 @@ import asyncio
3
  from fastapi.responses import StreamingResponse
4
 
5
 
6
- class OwnedProxyResponse(StreamingResponse):
7
- def __init__(self, content, *, close_callback, **kwargs):
8
- super().__init__(content, **kwargs)
9
  self._close_callback = close_callback
10
  self._download = None
11
  self._close_task = None
@@ -19,7 +18,7 @@ class OwnedProxyResponse(StreamingResponse):
19
  if self._close_task is None:
20
  async def close():
21
  try:
22
- close_iterator = getattr(self.body_iterator, "aclose", None)
23
  if close_iterator is not None:
24
  await close_iterator()
25
  finally:
@@ -36,6 +35,8 @@ class OwnedProxyResponse(StreamingResponse):
36
  raise cancelled
37
 
38
  async def __call__(self, scope, receive, send):
 
 
39
  if self._download is not None:
40
  return await self._download.deliver(self, scope, receive, send)
41
  return await self.send_owned(scope, receive, send)
@@ -46,5 +47,11 @@ class OwnedProxyResponse(StreamingResponse):
46
  finally:
47
  await self.aclose()
48
 
 
 
 
 
 
 
49
  # 保留既有工作流调用方的导出名称。
50
  OwnedWorkflowResponse = OwnedProxyResponse
 
3
  from fastapi.responses import StreamingResponse
4
 
5
 
6
+ class OwnedDelivery:
7
+ def _init_delivery(self, close_callback):
 
8
  self._close_callback = close_callback
9
  self._download = None
10
  self._close_task = None
 
18
  if self._close_task is None:
19
  async def close():
20
  try:
21
+ close_iterator = getattr(getattr(self, 'body_iterator', None), "aclose", None)
22
  if close_iterator is not None:
23
  await close_iterator()
24
  finally:
 
35
  raise cancelled
36
 
37
  async def __call__(self, scope, receive, send):
38
+ if self._close_task is not None:
39
+ raise asyncio.CancelledError()
40
  if self._download is not None:
41
  return await self._download.deliver(self, scope, receive, send)
42
  return await self.send_owned(scope, receive, send)
 
47
  finally:
48
  await self.aclose()
49
 
50
+
51
+ class OwnedProxyResponse(OwnedDelivery, StreamingResponse):
52
+ def __init__(self, content, *, close_callback, **kwargs):
53
+ super().__init__(content, **kwargs)
54
+ self._init_delivery(close_callback)
55
+
56
  # 保留既有工作流调用方的导出名称。
57
  OwnedWorkflowResponse = OwnedProxyResponse