🪢

AsanaAPIでプロキシを通過できずにハマった話

2024/09/23に公開

※まえがき:タイトル通りですが、実は単純なurllib3のお話だったりするかも

最近AWS認定試験にお熱の大吉です。

先週、プロキシサーバを経由しなければ問題なくプログラムは動作するが、プロキシサーバを経由させた途端に全く動かなくなる(どう動かなくなるのかは後ほど)…という現象に苛まされておりました。

さて突然ですが、私の勤務先ではAsanaというタスク管理ツールを利用しています。
RedmineやJIRA、Backlogみたいな類のやつです。
大学を卒業してから3年と少しの期間 お世話になった会社を退職すると同時に、ビ◯◯ーチを通じてご縁のあった会社に採用して頂いてもう3ヶ月が経ちました。
その3ヶ月がAsanaの使用歴であり、まだ使いこなせているわけではありませんが、いかにもモダンな感じで便利です。

そんなAsanaの恩恵をより与るべく、世間に公開されているAsanaAPIを使ってちょっと便利なツールを社内で作っちゃおうというお話。…でズッコケた話を、この記念すべき1本目の記事に書こうと思います。

まず、Asanaはこんな感じで開発者向けの充実したドキュメントが存在しています。
様々な言語に対応したテンプレートがたくさんあって嬉しいですね。
で、私はその中からPythonを選びました(実は最初にGoを選んだのですが、不慣れ過ぎて本業の手が回らなくなると判断したので断念しました)。
とりあえずPythonのサンプルコードを見て頂くとわかるように、asanaライブラリにはv5とv3の2種があるんですね。

新しいのはv5の方なのですが、私がハマったのがこのv5でした。(asana5.0.10を使用中)

両者で一体何が違うのかというと、v3系はrequestsライブラリを使用するように作られていて、そのrequestsライブラリはというと、環境変数をよしなに参照してくれるんですよね。(asana3.2.3では動作することを確認済み)
なので、プロキシサーバに対する認証情報を環境変数に設定していれば躓くこともないv3系の傍らで、v5系ではurllib3を使用するように作られているため、環境変数を適切に設定して はい終わり という訳にはいきませんでした。
(urllib、urllib2からのusllib3で何でそうなった?)

ここで、「urllib3でプロキシ認証の情報を渡すメソッドとか無いのか?」とはならずに、「あーなるほどね!理解理解!Asanaが独自にプロキシ使うためのメソッド用意してるパターンね はい余裕~」とAsanaAPIに完全フォーカスしてしまったのが沼への入口でした。
探せど探せど「社内プロキシに通らなくて困っているのですがどなたか知恵のある人いませんか(回答数0)」みたいな海外のスレッドこそヒットするものの、手がかりは何も掴めず。探すの下手すぎか。

そもそも、最初は407エラー以前に app.asana.com への名前解決に失敗していました。
原因はコード内に「configuration.proxy」を定義していなかったことのようです。
以下にエラー文をそのまま貼り付けます(一部の情報にはXXXでマスクを施しています)。

Failed to resolve 'app.asana.com'
2024-09-24 10:56:40,427 WARNING Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x000001E65017B850>: Failed to resolve 'app.asana.com' ([Errno 11002] getaddrinfo failed)")': /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T10%3A56%3A31.030Z&limit=100&opt_fields=actual_time_minutes
2024-09-24 10:56:49,501 WARNING Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x000001E65017AC50>: Failed to resolve 'app.asana.com' ([Errno 11002] getaddrinfo failed)")': /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T10%3A56%3A31.030Z&limit=100&opt_fields=actual_time_minutes
2024-09-24 10:56:58,572 WARNING Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x000001E650188790>: Failed to resolve 'app.asana.com' ([Errno 11002] getaddrinfo failed)")': /api/1.0/projects/12XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T10%3A56%3A31.030Z&limit=100&opt_fields=actual_time_minutes
ーーー省略ーーー
urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='app.asana.com', port=443): Max retries exceeded with url: /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T10%3A56%3A31.030Z&limit=100&opt_fields=actual_time_minutes (Caused by NameResolutionError("<urllib3.connection.HTTPSConnection object at 0x000001E650188E10>: Failed to resolve 'app.asana.com' ([Errno 11002] getaddrinfo failed)"))

上記エラーだけで15分ほど悩まされましたが、コードに
configuration.proxy = os.environ.get("ASANA_PROXY")
の一文を追加してあげると app.asana.com の名前解決に関するエラーは解消され

代わりに407エラーが返却されるようになりました……。
具体的には以下のようなエラー文です(一部の情報にはXXXでマスクを施しています)。

407 Proxy Authentication Required
2024-09-24 11:12:06,687 WARNING Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'OSError('Tunnel connection failed: 407 Proxy Authentication Required')': /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T11%3A12%3A06.631Z&limit=100&opt_fields=actual_time_minutes       
2024-09-24 11:12:06,706 WARNING Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'OSError('Tunnel connection failed: 407 Proxy Authentication Required')': /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T11%3A12%3A06.631Z&limit=100&opt_fields=actual_time_minutes       
2024-09-24 11:12:06,728 WARNING Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'OSError('Tunnel connection failed: 407 Proxy Authentication Required')': /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T11%3A12%3A06.631Z&limit=100&opt_fields=actual_time_minutes
ーーー省略ーーー
urllib3.exceptions.MaxRetryError: HTTPSConnectionPool(host='app.asana.com', port=443): Max retries exceeded with url: /api/1.0/projects/XXXXXXXXXXXXXXXX/tasks?completed_since=2024-09-24T11%3A12%3A06.631Z&limit=100&opt_fields=actual_time_minutes (Caused by ProxyError('Unable to connect to proxy', OSError('Tunnel connection failed: 407 Proxy Authentication Required')))

はてどうしたものか、どうしたらよいものかと途方に暮れつつ、GitHubでAsanaAPIのソースコードを読むとinit処理でユーザ名やらパスワードを空にしていて結合したユーザ名とパスワード(Basic)がどこに行くのか、どう受け取ればいいのかトレースするのは骨が折れるな、というところで参っていたのですが、空き時間を見つけては チマチマとコレダメ、コレモダメ…を繰り返していたら†正解†に辿り着きました。(最適解とは程遠い嘘解答みたいなものでしょう)
コードは下記の通りです。お納めください。

Asana_v5
import urllib3, os, json
from base64 import b64encode

# AsanaプロジェクトGID、プロキシ、ユーザ名、パスワード(エンコードなし)の定義
project_gid = '1208370336650424'
proxy_url = 'http://13.230.229.249:80' # 自宅で立てたプロキシサーバ(明日にはIP変わってる)
asana_name = os.getenv('AD_NAME') # os.getenvで環境変数を取り出す際に指定するキーは人によって異なるので注意されたし
asana_pass = os.getenv('BASIC_PASS') # 環境変数と同じノリで記号をpercent-encodeしないこと

# プロキシ認証情報(Basic)とAsanaアクセストークンの定義
proxy_auth = f'{asana_name}:{asana_pass}'
access_token = os.getenv('ASANA_PERSONAL_ACCESS_TOKEN')

# プロキシ認証情報をBase64エンコード
encoded_auth = b64encode(proxy_auth.encode()).decode('utf-8')
proxy_headers = {
    'Proxy-Authorization': f'Basic {encoded_auth}'
}
auth_headers = {
    'Authorization': f'Bearer {access_token}'
}

# プロキシマネージャー生成
http = urllib3.ProxyManager(proxy_url=proxy_url, proxy_headers=proxy_headers)

# リクエストを送信
response = http.request('GET', f'https://app.asana.com/api/1.0/projects/{project_gid}/tasks', headers=auth_headers)

# デコードしてJSONに変換
decoded_data = response.data.decode('utf-8')
json_data = json.loads(decoded_data)

# データを表示
print(json.dumps(json_data, indent=4, ensure_ascii=False)) # json.dumpsの際に見やすいように整形
print(response.status)

注意すべき点は下記2点
①プロキシサーバは「http://ユーザ名:パスワード@ホスト:ポート」ではなく「http://ホスト:ポート」とすること。
②asana_passはパーセントエンコーディングを行わないこと。

コード見返してて気付いたけど、asanaライブラリ使ってないじゃんこれ。

今回は自宅環境(Asanaやプロキシサーバを含めて)でやってみたのですが、社内でやるときはもう少し慎重にやりたいので、当分は今動いているv3を社内では使うことになりそうです。

さて、自宅では問題なく動作していた上記コードですが、社内で実行するとタイムアウトが発生して使い物になりません。データ量が多すぎるとのことで。
そのため、以下を意識しつつコードを書き直していきました。
・圧倒的データ量の前にhttp.requestでGETメソッド投げるのはあまりに無力。
・社内プロキシの認証を無事にパスする
・欲しい属性データのみを抽出したい(Asanaライブラリを使えば実現は容易)

そんなこんなで書き下ろすことになってしまったコードは以下の通り

Asana_v5_refactor_with_asana_verified.py
import asana, urllib3, os
from asana.rest import ApiException
from pprint import pprint

# プロキシ設定
proxy_url = "プロキシサーバのホストとポート"
proxy_user = "プロキシ認証に必要なユーザ名"
proxy_pass = "プロキシ認証に必要なパスワード"

# プロキシ認証情報を設定
proxy_headers = urllib3.make_headers(proxy_basic_auth=f"{proxy_user}:{proxy_pass}")

# プロキシマネージャーを作成
proxy = urllib3.ProxyManager(proxy_url, proxy_headers=proxy_headers)

# Asana APIクライアントの設定
configuration = asana.Configuration()
configuration.access_token = 'ここにAsanaのアクセストークンを入れる'
api_client = asana.ApiClient(configuration)

# プロキシを使用するようにAPIクライアントを設定
api_client.rest_client.pool_manager = proxy

# APIクラスのインスタンスを作成
tasks_api_instance = asana.TasksApi(api_client)
project_gid = "対象のAsanaプロジェクトGIDを入れる"
opts = {
    'completed_since': "2024-06-22T02:06:58.158Z",
    'limit': 100, # 値は1~00
    'opt_fields': "assignee,name,due_on,parent,permalink_url,num_subtasks", #抽出したい属性を指定する
}

try:
    # プロジェクトからタスクを取得
    api_response = tasks_api_instance.get_tasks_for_project(project_gid, opts)
    for data in api_response:
        pprint(data)
except ApiException as e:
    print("TasksApi->get_tasks_for_projectの呼び出し中に例外が発生しました: %s\n" % e)

書き下ろしてみたコードの検証や、提供されているAsanaライブラリを活用する機会はまた今度ということで…。

memo:
前提として、urllib3でプロキシ認証を行う際にはBasic認証となる。
Basic認証を行うためにまず必要になるのは"Basic username:password"という文字列で、この文字列に対してBase64でエンコードしたものがプロキシ認証情報として扱われる。

この一連の処理を楽に済ませてくれる、urllib3.make_headersという関数がある。
具体的には、headers = urllib3.make_headers(proxy_basic_auth='username:password') 等のように使用されるが、出力として {'Proxy-Authorization'}: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='} といったHTTPリクエスト用のProxy-Authorizationヘッダーを生成するっぽい?

とりあえず掴んでおくと良さげなイメージとしては、

  1. プロキシの認証情報(プロキシURL、ユーザ名、パスワード)を文字列で記述する
    ※型がStringになっていれば名称は自由、よしなにどうぞ。次のプロセスからはurllib3の出番。
  2. プロキシ認証情報を形成するために proxy_headers = urllib3.make_headers(proxy_basic_auth=f"{ユーザ名}:{パスワード}") を記述する
  3. 1で記述したプロキシURLと、2で形成したプロキシ認証情報を組み合わせ、マネージャー(プログラム)からプロキシサーバに対して認証要求を出してもらうイメージ?コードとしては下記のようになる。
    proxy = urllib3.ProxyManager(proxy_url, proxy_headers=proxy_headers)
    ※urllib3の出番は一旦ここまで?
  4. 無事にプロキシ認証が通っていた場合、api_client.rest_client.pool_manager = proxyと記述することで、プログラムは当該プロキシサーバを経由してリクエストを送信するようになる
    といった具合かなと感じました。

Discussion