From b2a0ba59be8af1825a28515c28660db9248fe3d9 Mon Sep 17 00:00:00 2001 From: mariatta Date: Sat, 24 Oct 2026 17:24:28 -0700 Subject: [PATCH] Handle case when GitHub is down during login The OAuth callback leg of /add_blurb posted the code to GitHub's token endpoint and assumed the exchange worked. When GitHub answered with an outage or interstitial HTML page instead, its Content-Security-Policy header exceeded aiohttp's 8190 byte header limit and the request crashed with LineTooLong, which Sentry reported as a 500. Move the exchange into exchange_code_for_token(), which asks for JSON and sorts failures into three cases: - GitHubUnavailable: transport errors such as the oversized header, timeouts, 429, 5xx, or a non-JSON body. The handler renders a 503 that tells the user GitHub is not responding, and the error template adds a link to GitHub's status page on 503 pages. Outage errors from the follow-up /user lookup and the App installation check get the same page, and so does the already-signed-in branch of the same route, which checks the App installation on every visit and previously had no guard at all. - SignInFailed: GitHub is up but rejects the code because it expired or was reused, or refuses the token it just issued. The handler renders a 400 quoting GitHub's reason and asks the user to sign in again. - OAuthMisconfigured: any other error code from the token endpoint, such as incorrect_client_credentials. Only a deploy can fix those, so they are left to surface as a 500 that Sentry reports. Both branches share one require_app_installed() helper, and their sessions use a 30 second ClientTimeout so a GitHub that accepts the connection but never answers becomes a fast 503 rather than a five minute hang followed by a 500. The token endpoint URL is a keyword argument so tests can point the helper at a local aiohttp server. One test sends a header larger than 8190 bytes and reproduces the exact error from Sentry. --- blurb_it/__main__.py | 160 +++++++++++----- blurb_it/error.py | 23 +++ templates/error.html | 6 + tests/test_main.py | 445 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 585 insertions(+), 49 deletions(-) diff --git a/blurb_it/__main__.py b/blurb_it/__main__.py index ae83ac9..038ac08 100644 --- a/blurb_it/__main__.py +++ b/blurb_it/__main__.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json import os import aiohttp @@ -24,6 +25,23 @@ sentry_sdk.init(os.environ.get("SENTRY_DSN")) +GITHUB_OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token" +# Rendered as a 503; the error template adds the link to GitHub's status page. +GITHUB_UNAVAILABLE_MESSAGE = "GitHub is not responding right now." +# Fail fast when GitHub accepts the connection but never answers, instead of +# holding the user's browser for aiohttp's default five minutes. +GITHUB_TIMEOUT = aiohttp.ClientTimeout(total=30) +# What a GitHub outage looks like from here. The exceptions in +# exchange_code_for_token are wrapped in GitHubUnavailable; the rest come from +# gidgethub calls made directly by the handler. +GITHUB_UNAVAILABLE_ERRORS = ( + error.GitHubUnavailable, + gidgethub.GitHubBroken, + gidgethub.RateLimitExceeded, + aiohttp.ClientError, + TimeoutError, +) + @routes.get("/", name="home") async def handle_get(request: Request) -> Response: @@ -73,50 +91,45 @@ async def handle_add_blurb_get(request: Request) -> Response: if await util.has_session(request): context.update(await util.get_session_context(request, context)) - async with aiohttp.ClientSession() as session: - - gh = GitHubAPI(session, context["username"]) - - jwt = get_jwt( - app_id=os.getenv("GH_APP_ID"), private_key=os.getenv("GH_PRIVATE_KEY") - ) + async with aiohttp.ClientSession(timeout=GITHUB_TIMEOUT) as session: try: - await util.get_installation(gh, jwt, context["username"]) - except error.InstallationNotFound: - raise web.HTTPFound(location=request.app.router["install"].url_for()) + await require_app_installed(request, session, context["username"]) + except GITHUB_UNAVAILABLE_ERRORS as exc: + raise web.HTTPServiceUnavailable( + text=GITHUB_UNAVAILABLE_MESSAGE + ) from exc elif token is not None: - async with aiohttp.ClientSession() as session: - payload = { - "client_id": os.environ.get("GH_CLIENT_ID"), - "client_secret": os.environ.get("GH_CLIENT_SECRET"), - "code": token, - } - async with session.post( - "https://github.com/login/oauth/access_token", data=payload - ) as response: - response_text = await response.text() - access_token = get_access_token(response_text) + async with aiohttp.ClientSession(timeout=GITHUB_TIMEOUT) as session: + try: + access_token = await exchange_code_for_token(session, token) gh = GitHubAPI(session, "blurb-it", oauth_token=access_token) - response = await gh.getitem("/user") - login_name = response["login"] + try: + user = await gh.getitem("/user") + except gidgethub.RateLimitExceeded: + # Throttling is an outage, not a bad token: let the outer + # handler turn it into the 503 page. + raise + except gidgethub.BadRequest as exc: + raise error.SignInFailed( + f"GitHub rejected the new token ({exc})" + ) from exc + login_name = user["login"] request_session["username"] = login_name request_session["token"] = access_token - context["username"] = request_session["username"] - - gh = GitHubAPI(session, context["username"]) - - jwt = get_jwt( - app_id=os.getenv("GH_APP_ID"), - private_key=os.getenv("GH_PRIVATE_KEY"), - ) - try: - await util.get_installation(gh, jwt, context["username"]) - except error.InstallationNotFound: - raise web.HTTPFound( - location=request.app.router["install"].url_for() - ) + context["username"] = login_name + await require_app_installed(request, session, login_name) + except GITHUB_UNAVAILABLE_ERRORS as exc: + raise web.HTTPServiceUnavailable( + text=GITHUB_UNAVAILABLE_MESSAGE + ) from exc + except error.SignInFailed as exc: + reason = str(exc).rstrip(".") + raise web.HTTPBadRequest( + text=f"GitHub sign-in failed: {reason}. " + "Please go back to the home page and sign in again." + ) from exc else: raise web.HTTPFound(location=request.app.router["home"].url_for()) @@ -127,12 +140,73 @@ async def handle_add_blurb_get(request: Request) -> Response: return response -def get_access_token(token_str: str) -> str | None: - for token in token_str.split("&"): - token_split = token.split("=") - if token_split[0] == "access_token": - return token_split[1] - return None +async def require_app_installed( + request: Request, session: aiohttp.ClientSession, username: str +) -> None: + """Redirect to the install page unless ``username`` has installed the App. + + GitHub errors other than "not installed" propagate so the caller can + decide what an outage looks like for its page. + """ + gh = GitHubAPI(session, username) + jwt = get_jwt( + app_id=os.getenv("GH_APP_ID"), private_key=os.getenv("GH_PRIVATE_KEY") + ) + try: + await util.get_installation(gh, jwt, username) + except error.InstallationNotFound: + raise web.HTTPFound(location=request.app.router["install"].url_for()) + + +async def exchange_code_for_token( + session: aiohttp.ClientSession, code: str, *, url: str = GITHUB_OAUTH_TOKEN_URL +) -> str: + """Trade the ``code`` from GitHub's OAuth redirect for a user access token. + + ``url`` is GitHub's token endpoint; tests point it at a local server. + + Raises ``GitHubUnavailable`` when GitHub cannot be reached or times out, + answers with a server error, or sends back something other than the token + endpoint's JSON. The last case is what an outage or rate-limit interstitial looks + like: an HTML page whose Content-Security-Policy header is larger than + aiohttp's 8 KB header limit, so aiohttp rejects the response before the + body is even read. + + Raises ``SignInFailed`` when GitHub rejects the code because it expired or + was already used, which the user fixes by signing in again. Any other + ``error`` code from GitHub means our client id, secret or callback URL is + wrong and raises ``OAuthMisconfigured`` so the deploy problem reaches Sentry. + """ + payload = { + "client_id": os.environ.get("GH_CLIENT_ID"), + "client_secret": os.environ.get("GH_CLIENT_SECRET"), + "code": code, + } + headers = {"Accept": "application/json"} + try: + async with session.post(url, data=payload, headers=headers) as response: + status = response.status + try: + data = await response.json() + except json.JSONDecodeError: + data = None + except (aiohttp.ClientError, TimeoutError) as exc: + raise error.GitHubUnavailable(str(exc)) from exc + + if status == 429 or status >= 500 or not isinstance(data, dict): + raise error.GitHubUnavailable( + f"GitHub answered HTTP {status} instead of a token" + ) + if "error" in data: + code_error = data["error"] + description = data.get("error_description", "") + if code_error == "bad_verification_code": + raise error.SignInFailed(description or code_error) + raise error.OAuthMisconfigured(f"{code_error} {description}".strip()) + access_token = data.get("access_token") + if not access_token: + raise error.SignInFailed("GitHub did not return an access token") + return access_token @routes.post("/add_blurb") diff --git a/blurb_it/error.py b/blurb_it/error.py index 8f12bd1..21dba7b 100644 --- a/blurb_it/error.py +++ b/blurb_it/error.py @@ -6,3 +6,26 @@ class BlurbItException(Exception): class InstallationNotFound(BlurbItException): """App not installed by user.""" + + +class GitHubUnavailable(BlurbItException): + + """GitHub could not be reached or did not answer properly. + + Raised when GitHub is down, rate limiting, or serving an HTML + interstitial page instead of the API response we asked for. + """ + + +class SignInFailed(BlurbItException): + + """GitHub rejected the OAuth sign-in, for example an expired code.""" + + +class OAuthMisconfigured(BlurbItException): + + """GitHub rejected our OAuth app credentials or callback URL. + + Only a deploy can fix this, so it is left to surface as a 500 that + Sentry reports rather than being shown to the user as their mistake. + """ diff --git a/templates/error.html b/templates/error.html index 1c55ae3..6a8fa34 100644 --- a/templates/error.html +++ b/templates/error.html @@ -3,5 +3,11 @@

{{ status }}

+ {% if status == 503 %} +

+ Check GitHub's status page + for the outage, then try again in a few minutes. +

+ {% endif %}
{% endblock %} diff --git a/tests/test_main.py b/tests/test_main.py index 582cd85..a481939 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,9 +1,12 @@ from __future__ import annotations +import asyncio import base64 import pathlib +import aiohttp import aiohttp_jinja2 +import gidgethub import jinja2 import pytest from aiohttp import web @@ -104,18 +107,190 @@ async def test_handle_install(aiohttp_client) -> None: assert "Please install" in text +async def fake_github(aiohttp_server, handler) -> str: + """Stand up ``handler`` as GitHub's OAuth token endpoint and return its URL.""" + app = web.Application() + app.router.add_post("/login/oauth/access_token", handler) + server = await aiohttp_server(app) + return str(server.make_url("/login/oauth/access_token")) + + +async def test_exchange_code_for_token(aiohttp_server, monkeypatch) -> None: + # Arrange: GitHub answers the exchange with the token as JSON. + monkeypatch.setenv("GH_CLIENT_ID", "client-id") + monkeypatch.setenv("GH_CLIENT_SECRET", "client-secret") + seen = {} + + async def token_endpoint(request: Request) -> web.Response: + seen["accept"] = request.headers["Accept"] + seen["form"] = dict(await request.post()) + return web.json_response( + { + "access_token": "gho_token", + "scope": "repo,gist", + "token_type": "bearer", + } + ) + + url = await fake_github(aiohttp_server, token_endpoint) + + # Act + async with aiohttp.ClientSession() as session: + token = await __main__.exchange_code_for_token(session, "the-code", url=url) + + # Assert + assert token == "gho_token" + assert seen["accept"] == "application/json" + assert seen["form"] == { + "client_id": "client-id", + "client_secret": "client-secret", + "code": "the-code", + } + + @pytest.mark.parametrize( - "token_str, expected", + "body, message", [ ( - "access_token=gho_16C7e42F292c6912E7710c838347Ae178B4a&scope=repo%2Cgist&token_type=bearer", - "gho_16C7e42F292c6912E7710c838347Ae178B4a", + { + "error": "bad_verification_code", + "error_description": "The code passed is incorrect or expired.", + }, + "The code passed is incorrect or expired.", + ), + ( + {"scope": "repo,gist", "token_type": "bearer"}, + "did not return an access token", ), - ("scope=repo%2Cgist&token_type=bearer", None), ], ) -def test_get_access_token(token_str: str, expected: str | None) -> None: - assert __main__.get_access_token(token_str) == expected +async def test_exchange_code_for_token_rejected( + aiohttp_server, body: dict, message: str +) -> None: + # Arrange: GitHub is up but refuses the code (GitHub sends these as 200). + async def token_endpoint(request: Request) -> web.Response: + return web.json_response(body) + + url = await fake_github(aiohttp_server, token_endpoint) + + # Act / Assert + async with aiohttp.ClientSession() as session: + with pytest.raises(__main__.error.SignInFailed, match=message): + await __main__.exchange_code_for_token(session, "the-code", url=url) + + +@pytest.mark.parametrize( + "body", + [ + { + "error": "incorrect_client_credentials", + "error_description": "The client_id and/or client_secret are incorrect.", + }, + {"error": "redirect_uri_mismatch"}, + ], + ids=["bad-secret", "bad-callback-url"], +) +async def test_exchange_code_for_token_misconfigured( + aiohttp_server, body: dict +) -> None: + # Arrange: GitHub rejects our app's credentials. The user cannot fix this + # by signing in again, so it must not become a SignInFailed. + async def token_endpoint(request: Request) -> web.Response: + return web.json_response(body) + + url = await fake_github(aiohttp_server, token_endpoint) + + # Act / Assert + async with aiohttp.ClientSession() as session: + with pytest.raises(__main__.error.OAuthMisconfigured, match=body["error"]): + await __main__.exchange_code_for_token(session, "the-code", url=url) + + +async def oversized_csp_page(request: Request) -> web.Response: + """GitHub's outage/interstitial HTML page. + + Its Content-Security-Policy header is bigger than aiohttp's default 8190 + byte header limit, so the client rejects the response before reading the + body. This is the failure Sentry reported for /add_blurb. + """ + csp = "default-src 'none'; base-uri 'self'; child-src " + " ".join( + f"https://host{i}.githubassets.com" for i in range(300) + ) + assert len(csp) > 8190 + return web.Response( + text="Unicorn!", + content_type="text/html", + headers={"Content-Security-Policy": csp}, + ) + + +async def html_page(request: Request) -> web.Response: + return web.Response(text="Unicorn!", content_type="text/html") + + +async def rate_limited(request: Request) -> web.Response: + return web.json_response({"message": "slow down"}, status=429) + + +async def server_error(request: Request) -> web.Response: + return web.Response( + text="Unicorn!", content_type="text/html", status=503 + ) + + +async def broken_json(request: Request) -> web.Response: + return web.Response(text="", content_type="application/json") + + +async def test_exchange_code_for_token_reproduces_sentry_error(aiohttp_server) -> None: + # Arrange: the interstitial page from the Sentry report. + url = await fake_github(aiohttp_server, oversized_csp_page) + + # Act / Assert: it is the header-limit error, wrapped as an outage. + async with aiohttp.ClientSession() as session: + with pytest.raises(__main__.error.GitHubUnavailable, match="8190 bytes"): + await __main__.exchange_code_for_token(session, "the-code", url=url) + + +@pytest.mark.parametrize( + "handler", [html_page, rate_limited, server_error, broken_json] +) +async def test_exchange_code_for_token_github_unavailable( + aiohttp_server, handler +) -> None: + # Arrange: GitHub is down, rate limiting, or serving an HTML page. + url = await fake_github(aiohttp_server, handler) + + # Act / Assert + async with aiohttp.ClientSession() as session: + with pytest.raises(__main__.error.GitHubUnavailable): + await __main__.exchange_code_for_token(session, "the-code", url=url) + + +async def test_exchange_code_for_token_timeout(aiohttp_server) -> None: + # Arrange: GitHub accepts the connection but never answers. aiohttp raises + # the builtin TimeoutError here, which is not a ClientError. + async def hangs(request: Request) -> web.Response: + await asyncio.sleep(1) + return web.json_response({"access_token": "too-late"}) + + url = await fake_github(aiohttp_server, hangs) + + # Act / Assert + timeout = aiohttp.ClientTimeout(total=0.1) + async with aiohttp.ClientSession(timeout=timeout) as session: + with pytest.raises(__main__.error.GitHubUnavailable): + await __main__.exchange_code_for_token(session, "the-code", url=url) + + +async def test_exchange_code_for_token_connection_error() -> None: + # Arrange: nothing answers on GitHub's side at all. + url = "http://127.0.0.1:1/token" + + # Act / Assert + async with aiohttp.ClientSession() as session: + with pytest.raises(__main__.error.GitHubUnavailable): + await __main__.exchange_code_for_token(session, "the-code", url=url) async def test_handle_get_redirects_signed_in_user(aiohttp_client) -> None: @@ -180,3 +355,261 @@ async def get_installation(gh, jwt, username): assert f"" in text assert 'id="cve-group"' in text assert 'getElementById("cve-group")' in text + + +@pytest.mark.parametrize( + "exc", + [ + gidgethub.GitHubBroken(502, "Bad Gateway"), + aiohttp.ClientConnectionError("connection reset"), + TimeoutError(), + ], + ids=["5xx", "connection-error", "timeout"], +) +async def test_handle_add_blurb_get_signed_in_when_github_is_down( + aiohttp_client, monkeypatch, exc: Exception +) -> None: + # Arrange: a signed-in user, and GitHub fails while we check the App install. + monkeypatch.setattr(__main__, "get_jwt", lambda **kwargs: "jwt") + + async def get_installation(gh, jwt, username): + raise exc + + monkeypatch.setattr(__main__.util, "get_installation", get_installation) + + app = create_app() + app.router.add_get("/sign-in", sign_in) + app.router.add_get("/install", __main__.handle_install, name="install") + app.router.add_get("/add_blurb", __main__.handle_add_blurb_get, name="add_blurb") + client = await aiohttp_client(app) + await client.get("/sign-in") + + # Act + resp = await client.get("/add_blurb") + + # Assert: the same 503 page as the sign-in leg, with the status link. + assert resp.status == 503 + text = await resp.text() + assert "GitHub is not responding right now" in text + assert ' None: + self.oauth_token = oauth_token + + async def getitem(self, url): + assert url == "/user" + assert self.oauth_token == "gho_token" + return {"login": "octocat"} + + +def failing_github_api(exc: Exception) -> type[FakeGitHubAPI]: + """A FakeGitHubAPI whose ``/user`` lookup raises ``exc``.""" + + class FailingGitHubAPI(FakeGitHubAPI): + async def getitem(self, url): + raise exc + + return FailingGitHubAPI + + +def oauth_callback_app( + monkeypatch, exchange, github_api: type = FakeGitHubAPI +) -> web.Application: + """App for the ``?code=`` leg of sign-in, with GitHub stubbed by ``exchange``.""" + monkeypatch.setattr(__main__, "exchange_code_for_token", exchange) + monkeypatch.setattr(__main__, "GitHubAPI", github_api) + monkeypatch.setattr(__main__, "get_jwt", lambda **kwargs: "jwt") + + async def get_installation(gh, jwt, username): + return {"id": 1} + + monkeypatch.setattr(__main__.util, "get_installation", get_installation) + + app = create_app() + app.router.add_get("/", __main__.handle_get, name="home") + app.router.add_get("/install", __main__.handle_install, name="install") + app.router.add_get("/add_blurb", __main__.handle_add_blurb_get, name="add_blurb") + return app + + +async def test_handle_add_blurb_get_signs_in_with_code( + aiohttp_client, monkeypatch +) -> None: + # Arrange: GitHub redirected back with a code and will exchange it. + seen = {} + + async def exchange(session, code): + seen["code"] = code + return "gho_token" + + client = await aiohttp_client(oauth_callback_app(monkeypatch, exchange)) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert: the form renders for the user, and the session now remembers + # them, so the next visit needs no code at all. + assert resp.status == 200 + assert seen["code"] == "the-code" + assert "octocat" in await resp.text() + resp = await client.get("/add_blurb", allow_redirects=False) + assert resp.status == 200 + + +async def test_handle_add_blurb_get_when_github_is_down( + aiohttp_client, monkeypatch +) -> None: + # Arrange: the exchange fails because GitHub is unavailable. + async def exchange(session, code): + raise __main__.error.GitHubUnavailable("Got more than 8190 bytes when reading") + + client = await aiohttp_client(oauth_callback_app(monkeypatch, exchange)) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert: a 503 that names GitHub as the cause, not a 500 or a sign-in + # error, and the template adds the link to GitHub's status page. + assert resp.status == 503 + text = await resp.text() + assert "GitHub is not responding right now" in text + assert ' None: + # Arrange: GitHub is up, but the code was already used or has expired. + async def exchange(session, code): + raise __main__.error.SignInFailed("The code passed is incorrect or expired.") + + client = await aiohttp_client(oauth_callback_app(monkeypatch, exchange)) + + # Act + resp = await client.get("/add_blurb", params={"code": "stale-code"}) + + # Assert: a 400 that tells the user to sign in again. + assert resp.status == 400 + text = await resp.text() + assert ( + "GitHub sign-in failed: The code passed is incorrect or expired. " + "Please go back to the home page and sign in again." in text + ) + assert "githubstatus.com" not in text + + +async def test_handle_add_blurb_get_when_oauth_app_is_misconfigured( + aiohttp_client, monkeypatch +) -> None: + # Arrange: GitHub rejects our client secret. The user cannot fix that by + # signing in again, so it must stay a 500 that Sentry reports. + async def exchange(session, code): + raise __main__.error.OAuthMisconfigured("incorrect_client_credentials") + + client = await aiohttp_client(oauth_callback_app(monkeypatch, exchange)) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert + assert resp.status == 500 + assert "sign in again" not in await resp.text() + + +async def test_handle_add_blurb_get_with_code_but_app_not_installed( + aiohttp_client, monkeypatch +) -> None: + # Arrange: sign-in works, but the user has not installed the GitHub App. + async def exchange(session, code): + return "gho_token" + + app = oauth_callback_app(monkeypatch, exchange) + + async def get_installation(gh, jwt, username): + raise __main__.error.InstallationNotFound() + + monkeypatch.setattr(__main__.util, "get_installation", get_installation) + client = await aiohttp_client(app) + + # Act + resp = await client.get( + "/add_blurb", params={"code": "the-code"}, allow_redirects=False + ) + + # Assert + assert resp.status == 302 + assert resp.headers["Location"] == "/install" + + +async def exchange_ok(session, code): + return "gho_token" + + +@pytest.mark.parametrize( + "exc", + [ + gidgethub.GitHubBroken(502, "Bad Gateway"), + gidgethub.RateLimitExceeded( + gidgethub.sansio.RateLimit(limit=5000, remaining=0, reset_epoch=0) + ), + aiohttp.ClientConnectionError("connection reset"), + TimeoutError(), + ], + ids=["5xx", "rate-limited", "connection-error", "timeout"], +) +async def test_handle_add_blurb_get_when_user_lookup_hits_outage( + aiohttp_client, monkeypatch, exc: Exception +) -> None: + # Arrange: the code exchange worked, but GitHub fails on the /user lookup. + app = oauth_callback_app(monkeypatch, exchange_ok, failing_github_api(exc)) + client = await aiohttp_client(app) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert + assert resp.status == 503 + assert "GitHub is not responding right now" in await resp.text() + + +async def test_handle_add_blurb_get_when_new_token_is_rejected( + aiohttp_client, monkeypatch +) -> None: + # Arrange: GitHub issued a token and then refuses it on /user. + exc = gidgethub.BadRequest(401, "Bad credentials") + app = oauth_callback_app(monkeypatch, exchange_ok, failing_github_api(exc)) + client = await aiohttp_client(app) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert: a sign-in failure, not an outage. + assert resp.status == 400 + text = await resp.text() + assert "GitHub sign-in failed: GitHub rejected the new token" in text + assert "sign in again" in text + + +async def test_handle_add_blurb_get_when_installation_lookup_hits_outage( + aiohttp_client, monkeypatch +) -> None: + # Arrange: sign-in worked, but GitHub fails while we check the App install. + app = oauth_callback_app(monkeypatch, exchange_ok) + + async def get_installation(gh, jwt, username): + raise gidgethub.GitHubBroken(502, "Bad Gateway") + + monkeypatch.setattr(__main__.util, "get_installation", get_installation) + client = await aiohttp_client(app) + + # Act + resp = await client.get("/add_blurb", params={"code": "the-code"}) + + # Assert + assert resp.status == 503 + assert "GitHub is not responding right now" in await resp.text()