2022年1月9日 星期日

Heroku的30秒Request Timeout限制 & workaround

好久沒寫純技術文了, 這次會寫這篇文章的原因, 主要是想新做一個web server, 可是我Azure目前的cost一個月花費已經快超過NT 1000, 就決定把這次新做的server, 放到申請後一直放著生草的Heroku上。

關於Heroku的介紹, 最大的優點就是他會送你每個月550小時的實例(一台server), 如果有綁信用卡就會再送450小時, 合計1000小時>一個月(720小時), 等於你可以完全擁有一台免費的server可用, 如果有佈署多台server則是平均分攤, 網路上的介紹很多, 這邊就不多提及了。


這次要做的新的server github如下:

https://github.com/zmcx16/Norn-Finance-API-Server


主要用途為抓取股價以及選擇權合約資料, 並且跑選擇權定價模型, 計算目前選擇權的價格以及估價模型的價格差異, 從中找出獲利機會, 不過實際上放到Heroku上跑, 會發生只要是合約過多的個股選擇權, 會因為估值模型跑太久(Black-Scholes,  Monte Carlo, Binomial Tree), 觸發Heroku router的30秒Request Timeout問題, 一般來說自己架的server的request timeout是可調的, 可是像Heroku這種PaaS的代管服務可能會因為架構設計問題使得彈性受限(在共用router的情況下, 不能允許各個客戶設定自己的router timeout好像也挺合理的...)。 

關於這個問題Heroku官網上文件也有關於Request Timeout的說明:

https://devcenter.heroku.com/articles/request-timeout

文件提供的建議是, 如果有超過30秒的request task, 請設計async job的request形式, 把long-time job移到background job去做, 最常見的做法就是把API request拆成兩個以上, 第一個request會請server開始計算工作, 並拿回一個task id, 之後再用這個task id去polling問server, 直到server做完在回覆結果。 這個做法的好處是可以讓web server專注在響應客戶端的請求, 後台再用其他service去處理CPU bound或是long-time job類型的工作, 對使用者體驗以及伺服器穩定度都會更好。

不過以我個人來說, 我只是想做個簡單的小server用, 而且我也不想花錢去架其他service跑background job, 為了省錢, 只能想想怎麼workaround了, 幸好要避免Heroku router timeout還是有辦法的:

https://devcenter.heroku.com/articles/http-routing#timeouts

Timeouts

After a dyno connection has been established, HTTP requests have an initial 30 second window in which the web process must return response data (either the completed response or some amount of response data to indicate that the process is active). Processes that do not send response data within the initial 30-second window will see an H12 error in their logs.

After the initial response, each byte sent (either from the client or from your app process) resets a rolling 55 second window. If no data is sent during this 55 second window then the connection is terminated and a H15 or H28 error is logged.

Additional details can be found in the Request Timeout article.

簡單說明的話, 就是雖然會有30秒的Request timeout, 可是只要這中間有傳遞資料(不論是client端還是server端), 就會再重置一次55秒的timeout, 所以如果想避免Request timeout, 只要維持connection不要斷, 並且每幾十秒ping一下就沒問題了。 大概下面幾種方法都可以實現:

  1. SSE (Server-Sent Events)
  2. Stream Response
  3. Websocket
SSE基本上就是connection建立後不斷開, 只靠server主動通知client事件(只允許server單向跟client溝通), 只要在server端工作時不時的ping一下client就能解決; Stream Response則是server在回覆資料給client時不一口氣傳完, 在不超過55秒的情況一次一次write直到最後發送end才讓整個request結束; 至於Websocket則是全雙工, 建立連線後client跟server可以互相通訊, 一樣只要時不時的ping一下(client or server都可以)就能解決。

考慮了一下上述幾個作法, 最後決定選用Websocket, 原因是以我的需求來說, 如果用Stream Response, 會變成需要拆解整個Request body分成heartbeat區以及data區, 太過複雜所以不允考慮; 而SSE跟Websocket以我的需求來說, 只差在server去heartbeat client或client heartbeat server, 以server的loading考慮的話, 最終決定選用Websocket, 做起來也最單純。

至於websocket client - server溝通的interface, 一般作法是建一個萬用的websocket連線, 之後再根據event的內容決定要做什麼, 不過基本上我開的API本來就是以restful為準, 非常不想讓websocket跟原本的API差異過大, 所以就決定把websocket連線當成一次性用途, query parameter則是跟原始的restful API一模一樣, 只要處理完工作就關閉連線, 這樣也乾淨許多(畢竟這個API本來就是restful就夠用了, 根本不需要websocket, 一切都是為了workaround...)。

實作好的具體demo如下, 首先看一下原本會timeout的restful API:



Restful API error:



Heroku log:


這邊說明一下, 會是H13 connection close error主要是因為uvicorn --ws-ping-interval & --ws-ping-timeout預設值是20秒, 所以在Heroku H12 timeout error之前uvicorn就自己timeout了, 如果今天我有設定上述的--ws-ping-interval & --ws-ping-timeout參數讓他大於30秒, 就會變成觸發的是Heroku H12 timeout error, 因為反正我都要打heartbeat了, 我就索性不加參數了, 因為我的uvicorn是用gunicorn管理的, 要改預設參數會很麻煩, 反正問題能解決就不用管細部參數了。


Websocket (手動測試, heartbeat on client):



用websocket做restful API, 這也真夠奢侈的了~。 而且因為是開websocket, OpenAPI 自動產生document文件的方式也不適用websocket, 不過反正這個API也只有我在用, 其實也沒差就是了, 反正query parameter跟restful API一模一樣, 也不用特別去記他~。


這次分享大概就醬, 最後記錄一個踩到的雷, 原本我的backend code, 在處理heartbeat的部分是寫multi-thread去接heartbeat message, 這在local端實測有效, 可是不知道為什麼放到Heroku上client端有打heartbeat server端也有收到, 可是還是會觸發Heroku Request timeout, 最後沒辦法只能接收heartbeat還是用main thread, 抓資料&計算估值在用mulit-thread處理, 完全搞不懂為什麼會這樣...。

沒有留言:

張貼留言