From 0934f0d791298deab205a76e21cedef4e867694f Mon Sep 17 00:00:00 2001 From: panish16 Date: Thu, 19 Mar 2026 09:45:56 -0700 Subject: [PATCH 1/3] chore: upgrade to Node.js 24 --- .github/workflows/strr-base-web-ci.yaml | 2 +- .github/workflows/strr-examiner-ui-cd.yaml | 2 +- .github/workflows/strr-examiner-ui-ci.yaml | 2 +- .github/workflows/strr-host-pm-ui-cd.yaml | 2 +- .github/workflows/strr-host-pm-ui-ci.yaml | 2 +- .github/workflows/strr-platform-ui-cd.yaml | 2 +- .github/workflows/strr-platform-ui-ci.yaml | 2 +- .github/workflows/strr-strata-ui-cd.yaml | 2 +- .github/workflows/strr-strata-ui-ci.yaml | 2 +- strr-base-web/package.json | 3 +++ strr-examiner-web/Dockerfile | 2 +- strr-examiner-web/package.json | 3 +++ strr-host-pm-web/Dockerfile | 2 +- strr-host-pm-web/package.json | 3 +++ strr-platform-web/Dockerfile | 2 +- strr-platform-web/package.json | 3 +++ strr-strata-web/Dockerfile | 2 +- strr-strata-web/package.json | 3 +++ 18 files changed, 28 insertions(+), 13 deletions(-) diff --git a/.github/workflows/strr-base-web-ci.yaml b/.github/workflows/strr-base-web-ci.yaml index 5ef32934c..6c5bf5397 100644 --- a/.github/workflows/strr-base-web-ci.yaml +++ b/.github/workflows/strr-base-web-ci.yaml @@ -18,4 +18,4 @@ jobs: app_name: "strr-base-web-ui" working_directory: "./strr-base-web" codecov_flag: "strrbasewebui" - node_version: 22 + node_version: 24 diff --git a/.github/workflows/strr-examiner-ui-cd.yaml b/.github/workflows/strr-examiner-ui-cd.yaml index 2e3e2e895..50929e8ab 100644 --- a/.github/workflows/strr-examiner-ui-cd.yaml +++ b/.github/workflows/strr-examiner-ui-cd.yaml @@ -36,7 +36,7 @@ jobs: target: ${{ inputs.target }} app_name: "strr-examiner-ui" working_directory: "./strr-examiner-web" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 redeploy: ${{ inputs.redeploy }} secrets: diff --git a/.github/workflows/strr-examiner-ui-ci.yaml b/.github/workflows/strr-examiner-ui-ci.yaml index 834e81242..d174d8ad9 100644 --- a/.github/workflows/strr-examiner-ui-ci.yaml +++ b/.github/workflows/strr-examiner-ui-ci.yaml @@ -18,5 +18,5 @@ jobs: app_name: "strr-examiner-ui" working_directory: "./strr-examiner-web" codecov_flag: "strrexaminerui" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 diff --git a/.github/workflows/strr-host-pm-ui-cd.yaml b/.github/workflows/strr-host-pm-ui-cd.yaml index 25a178057..ae5877304 100644 --- a/.github/workflows/strr-host-pm-ui-cd.yaml +++ b/.github/workflows/strr-host-pm-ui-cd.yaml @@ -36,7 +36,7 @@ jobs: target: ${{ inputs.target }} app_name: "strr-hosts" working_directory: "./strr-host-pm-web" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 redeploy: ${{ inputs.redeploy }} secrets: diff --git a/.github/workflows/strr-host-pm-ui-ci.yaml b/.github/workflows/strr-host-pm-ui-ci.yaml index 610e72590..1165b406a 100644 --- a/.github/workflows/strr-host-pm-ui-ci.yaml +++ b/.github/workflows/strr-host-pm-ui-ci.yaml @@ -18,5 +18,5 @@ jobs: app_name: "strr-hosts" working_directory: "./strr-host-pm-web" codecov_flag: "strrhostpmui" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 diff --git a/.github/workflows/strr-platform-ui-cd.yaml b/.github/workflows/strr-platform-ui-cd.yaml index 011deab65..c556ff89d 100644 --- a/.github/workflows/strr-platform-ui-cd.yaml +++ b/.github/workflows/strr-platform-ui-cd.yaml @@ -36,7 +36,7 @@ jobs: target: ${{ inputs.target }} app_name: "strr-platform-ui" working_directory: "./strr-platform-web" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 redeploy: ${{ inputs.redeploy }} secrets: diff --git a/.github/workflows/strr-platform-ui-ci.yaml b/.github/workflows/strr-platform-ui-ci.yaml index 64a2439f7..200cca511 100644 --- a/.github/workflows/strr-platform-ui-ci.yaml +++ b/.github/workflows/strr-platform-ui-ci.yaml @@ -18,5 +18,5 @@ jobs: app_name: "strr-platform-ui" working_directory: "./strr-platform-web" codecov_flag: "strrplatformui" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 diff --git a/.github/workflows/strr-strata-ui-cd.yaml b/.github/workflows/strr-strata-ui-cd.yaml index 08312f3a2..87a6a56fd 100644 --- a/.github/workflows/strr-strata-ui-cd.yaml +++ b/.github/workflows/strr-strata-ui-cd.yaml @@ -36,7 +36,7 @@ jobs: target: ${{ inputs.target }} app_name: "strr-strata-hotel-ui" working_directory: "./strr-strata-web" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 redeploy: ${{ inputs.redeploy }} secrets: diff --git a/.github/workflows/strr-strata-ui-ci.yaml b/.github/workflows/strr-strata-ui-ci.yaml index 76d86f426..760d54deb 100644 --- a/.github/workflows/strr-strata-ui-ci.yaml +++ b/.github/workflows/strr-strata-ui-ci.yaml @@ -18,5 +18,5 @@ jobs: app_name: "strr-strata-hotel-ui" working_directory: "./strr-strata-web" codecov_flag: "strrstrataui" - node_version: 22 + node_version: 24 pnpm_version: 10.0.0 diff --git a/strr-base-web/package.json b/strr-base-web/package.json index 8faf50384..793ccbd9d 100644 --- a/strr-base-web/package.json +++ b/strr-base-web/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "0.0.42", + "engines": { + "node": ">=24" + }, "scripts": { "build-check": "nuxt build", "build": "nuxt generate", diff --git a/strr-examiner-web/Dockerfile b/strr-examiner-web/Dockerfile index b8f253367..21e63fcf0 100644 --- a/strr-examiner-web/Dockerfile +++ b/strr-examiner-web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS base +FROM node:24-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/strr-examiner-web/package.json b/strr-examiner-web/package.json index eb3fe2681..7ed4a853e 100644 --- a/strr-examiner-web/package.json +++ b/strr-examiner-web/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "0.2.17", + "engines": { + "node": ">=24" + }, "scripts": { "build-check": "nuxt build", "build": "nuxt generate", diff --git a/strr-host-pm-web/Dockerfile b/strr-host-pm-web/Dockerfile index b8f253367..21e63fcf0 100644 --- a/strr-host-pm-web/Dockerfile +++ b/strr-host-pm-web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS base +FROM node:24-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/strr-host-pm-web/package.json b/strr-host-pm-web/package.json index f6b85dd7d..59cd9f63d 100644 --- a/strr-host-pm-web/package.json +++ b/strr-host-pm-web/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "1.3.15", + "engines": { + "node": ">=24" + }, "scripts": { "build-check": "nuxt build", "build": "nuxt generate", diff --git a/strr-platform-web/Dockerfile b/strr-platform-web/Dockerfile index b8f253367..21e63fcf0 100644 --- a/strr-platform-web/Dockerfile +++ b/strr-platform-web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS base +FROM node:24-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/strr-platform-web/package.json b/strr-platform-web/package.json index 20326ac29..9196b7596 100644 --- a/strr-platform-web/package.json +++ b/strr-platform-web/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "1.2.1", + "engines": { + "node": ">=24" + }, "scripts": { "build-check": "nuxt build", "build": "nuxt generate", diff --git a/strr-strata-web/Dockerfile b/strr-strata-web/Dockerfile index b8f253367..21e63fcf0 100644 --- a/strr-strata-web/Dockerfile +++ b/strr-strata-web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS base +FROM node:24-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/strr-strata-web/package.json b/strr-strata-web/package.json index a51556ad7..06fd044ff 100644 --- a/strr-strata-web/package.json +++ b/strr-strata-web/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "version": "1.2.1", + "engines": { + "node": ">=24" + }, "scripts": { "build-check": "nuxt build", "build": "nuxt generate", From 48011c71c3952719209f8adcf0777ce9eca9826e Mon Sep 17 00:00:00 2001 From: panish16 Date: Wed, 27 May 2026 10:05:55 -0700 Subject: [PATCH 2/3] fix: add pg8000 graceful shutdown on Cloud Run scale-down --- .../strr-email/src/strr_email/__init__.py | 31 +++ .../tests/integration/test_queue_email.py | 118 ++++++++++ .../tests/unit/test_email_listener_utils.py | 211 ++++++++++++++++++ .../strr-pay/src/strr_pay/__init__.py | 32 +++ 4 files changed, 392 insertions(+) create mode 100644 queue_services/strr-email/tests/unit/test_email_listener_utils.py diff --git a/queue_services/strr-email/src/strr_email/__init__.py b/queue_services/strr-email/src/strr_email/__init__.py index 1b720e8d3..afd41ee02 100644 --- a/queue_services/strr-email/src/strr_email/__init__.py +++ b/queue_services/strr-email/src/strr_email/__init__.py @@ -34,10 +34,13 @@ """The email queue listener service.""" from __future__ import annotations +import logging + from flask import Flask from flask_migrate import Migrate import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration +from sqlalchemy import event from strr_api import db from .config import Config @@ -46,6 +49,31 @@ from .services import gcp_queue +def _setup_pg8000_graceful_shutdown(engine) -> None: + """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" + if getattr(engine, "driver", None) != "pg8000": + return + try: + from pg8000.exceptions import InterfaceError as _InterfaceError + except ImportError: + _InterfaceError = None + + @event.listens_for(engine, "connect") + def on_connect(dbapi_conn, _connection_record): + orig_close = dbapi_conn.close + + def safe_close(): + try: + orig_close() + except Exception as exc: + if _InterfaceError and isinstance(exc, _InterfaceError): + logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") + else: + raise + + dbapi_conn.close = safe_close + + def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: """Return a configured Flask App using the Factory method.""" app = Flask(__name__) @@ -63,6 +91,9 @@ def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: if app.config.get("POD_NAMESPACE", None) == "Testing": Migrate(app, db) + with app.app_context(): + _setup_pg8000_graceful_shutdown(db.engine) + gcp_queue.init_app(app) register_endpoints(app) diff --git a/queue_services/strr-email/tests/integration/test_queue_email.py b/queue_services/strr-email/tests/integration/test_queue_email.py index fbb71c7ae..c9121f0cf 100644 --- a/queue_services/strr-email/tests/integration/test_queue_email.py +++ b/queue_services/strr-email/tests/integration/test_queue_email.py @@ -265,3 +265,121 @@ def notify_callback(request): assert stored.notify_reference assert stored.registration_id == registration.id assert stored.status == InteractionStatus.SENT + + +def test_email_no_cloud_event_data(client, queue_envelope, simple_cloud_event): + """Cloud event with no data is a no-op returning 200.""" + ce = simple_cloud_event(type="email", data={}) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.OK + + +def test_email_application_not_found(client, queue_envelope, simple_cloud_event): + """Returns 404 when the application_number in the event does not exist.""" + data = { + "email_type": "HOST_AUTO_APPROVED", + "application_number": "NONEXISTENT-APP-12345", + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.NOT_FOUND + + +def test_email_registration_not_found(client, queue_envelope, simple_cloud_event): + """Returns 404 when the registration_number in the event does not exist.""" + data = { + "email_type": "HOST_RENEWAL_REMINDER", + "registration_number": "NONEXISTENT-REG-99999", + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.conf( + KEYCLOAK_AUTH_TOKEN_URL="http://my-auth-url", + NOTIFY_SVC_URL="http://my-notify-mock", + NOTIFY_API_TIMEOUT=30, + EMAIL_HOUSING_RECIPIENT_EMAIL="ops@gov.bc.ca", +) +@responses.activate +def test_email_notify_api_failure( + app, + client, + session, + simple_cloud_event, + queue_envelope, + setup_parents, + inject_config, +): + """Returns an error status when the notify API call fails.""" + responses.add( + responses.POST, + app.config.get("KEYCLOAK_AUTH_TOKEN_URL"), + json={"access_token": "token"}, + status=200, + ) + responses.add( + responses.POST, + app.config.get("NOTIFY_SVC_URL"), + json={"message": "Service unavailable"}, + status=503, + ) + + registration = create_registration(session, setup_parents) + + data = { + "email_type": "HOST_REGISTRATION_CANCELLED", + "registration_number": registration.registration_number, + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + + response = client.post("/", json=envelope) + assert response.status_code == 503 + + +@pytest.mark.conf( + KEYCLOAK_AUTH_TOKEN_URL="http://my-auth-url", + NOTIFY_SVC_URL="http://my-notify-mock", + NOTIFY_API_TIMEOUT=30, + EMAIL_HOUSING_RECIPIENT_EMAIL="ops@gov.bc.ca", +) +@responses.activate +def test_email_host_registration_cancelled( + app, + client, + session, + simple_cloud_event, + queue_envelope, + setup_parents, + inject_config, +): + """Returns 200 and calls notify-api directly for non-renewal email types.""" + responses.add( + responses.POST, + app.config.get("KEYCLOAK_AUTH_TOKEN_URL"), + json={"access_token": "token"}, + status=200, + ) + responses.add( + responses.POST, + app.config.get("NOTIFY_SVC_URL"), + json={"id": "notif-123"}, + status=200, + ) + + registration = create_registration(session, setup_parents) + + data = { + "email_type": "HOST_REGISTRATION_CANCELLED", + "registration_number": registration.registration_number, + } + ce = simple_cloud_event(type="email", data=data) + envelope = queue_envelope(cloud_event=ce) + + response = client.post("/", json=envelope) + assert response.status_code == HTTPStatus.OK diff --git a/queue_services/strr-email/tests/unit/test_email_listener_utils.py b/queue_services/strr-email/tests/unit/test_email_listener_utils.py new file mode 100644 index 000000000..b7fc1eb9c --- /dev/null +++ b/queue_services/strr-email/tests/unit/test_email_listener_utils.py @@ -0,0 +1,211 @@ +"""Unit tests for email_listener utility functions (no Flask or DB required).""" + +import pytest +from simple_cloudevent import SimpleCloudEvent +from strr_api.models import Registration + +from strr_email.resources.email_listener import ( + _get_address_detail, + _get_client_recipients, + _get_expiry_date, + _get_rental_nickname, + _get_service_provider, + dict_keys_to_snake_case, + get_email_info, +) + + +class TestDictKeysToSnakeCase: + def test_converts_camel_case(self): + result = dict_keys_to_snake_case({"emailType": "HOST", "applicationNumber": "APP-001"}) + assert result == {"email_type": "HOST", "application_number": "APP-001"} + + def test_already_snake_case_unchanged(self): + result = dict_keys_to_snake_case({"email_type": "HOST"}) + assert result == {"email_type": "HOST"} + + def test_empty_dict(self): + assert dict_keys_to_snake_case({}) == {} + + def test_preserves_none_values(self): + result = dict_keys_to_snake_case({"customContent": None, "registrationNumber": "H123"}) + assert result["custom_content"] is None + assert result["registration_number"] == "H123" + + def test_single_word_key(self): + result = dict_keys_to_snake_case({"type": "HOST"}) + assert result == {"type": "HOST"} + + +class TestGetEmailInfo: + def test_returns_email_info_from_valid_data(self): + ce = SimpleCloudEvent( + id="id", + source="src", + subject="sub", + type="email", + data={"emailType": "HOST_RENEWAL_REMINDER", "registrationNumber": "H123"}, + ) + info = get_email_info(ce) + assert info is not None + assert info.email_type == "HOST_RENEWAL_REMINDER" + assert info.registration_number == "H123" + + def test_returns_none_when_no_data(self): + ce = SimpleCloudEvent(id="id", source="src", subject="sub", type="email") + assert get_email_info(ce) is None + + def test_returns_none_when_data_is_not_dict(self): + ce = SimpleCloudEvent( + id="id", source="src", subject="sub", type="email", data="not-a-dict" + ) + assert get_email_info(ce) is None + + def test_converts_camel_case_fields(self): + ce = SimpleCloudEvent( + id="id", + source="src", + subject="sub", + type="email", + data={"emailType": "NOC", "applicationNumber": "APP-999", "customContent": "hello"}, + ) + info = get_email_info(ce) + assert info.email_type == "NOC" + assert info.application_number == "APP-999" + assert info.custom_content == "hello" + + +class TestGetAddressDetail: + def test_host_returns_requested_field(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "123"}}} + result = _get_address_detail(app_dict, Registration.RegistrationType.HOST, "streetNumber") + assert result == "123" + + def test_non_host_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "123"}}} + result = _get_address_detail( + app_dict, Registration.RegistrationType.PLATFORM, "streetNumber" + ) + assert result == "" + + def test_missing_field_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {}}} + result = _get_address_detail(app_dict, Registration.RegistrationType.HOST, "unitNumber") + assert result == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = {"registration": {"unitAddress": {"streetNumber": "456"}}} + result = _get_address_detail( + app_dict, Registration.RegistrationType.STRATA_HOTEL, "streetNumber" + ) + assert result == "" + + +class TestGetExpiryDate: + def test_formats_valid_iso_date(self): + app_dict = {"header": {"registrationEndDate": "2025-12-15T00:00:00+00:00"}} + result = _get_expiry_date(app_dict) + assert "December" in result + assert "2025" in result + + def test_returns_empty_when_no_date(self): + assert _get_expiry_date({"header": {}}) == "" + + def test_returns_empty_when_no_header(self): + assert _get_expiry_date({}) == "" + + def test_returns_empty_when_date_is_none(self): + assert _get_expiry_date({"header": {"registrationEndDate": None}}) == "" + + +class TestGetServiceProvider: + def test_platform_returns_legal_name(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme Rentals Ltd."}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.PLATFORM) + assert result == "Acme Rentals Ltd." + + def test_host_returns_empty_string(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme Rentals Ltd."}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.HOST) + assert result == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = {"registration": {"businessDetails": {"legalName": "Acme"}}} + result = _get_service_provider(app_dict, Registration.RegistrationType.STRATA_HOTEL) + assert result == "" + + +class TestGetClientRecipients: + def test_host_returns_primary_contact_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + } + } + result = _get_client_recipients(app_dict) + assert result == "host@example.com" + + def test_host_with_property_manager_contact_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + "propertyManager": {"contact": {"emailAddress": "pm@example.com"}}, + } + } + result = _get_client_recipients(app_dict) + assert "host@example.com" in result + assert "pm@example.com" in result + + def test_host_with_property_manager_business_email(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.HOST.value, + "primaryContact": {"emailAddress": "host@example.com"}, + "propertyManager": { + "business": {"primaryContact": {"emailAddress": "biz@example.com"}} + }, + } + } + result = _get_client_recipients(app_dict) + assert "host@example.com" in result + assert "biz@example.com" in result + + def test_platform_returns_empty_string(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.PLATFORM.value, + } + } + assert _get_client_recipients(app_dict) == "" + + def test_strata_hotel_returns_empty_string(self): + app_dict = { + "registration": { + "registrationType": Registration.RegistrationType.STRATA_HOTEL.value, + } + } + assert _get_client_recipients(app_dict) == "" + + +class TestGetRentalNickname: + def test_host_with_nickname_returns_nickname(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Beach House"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.HOST) + assert result == "Beach House" + + def test_host_without_nickname_returns_none(self): + app_dict = {"registration": {"unitAddress": {}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.HOST) + assert result is None + + def test_platform_returns_none(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Beach House"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.PLATFORM) + assert result is None + + def test_strata_hotel_returns_none(self): + app_dict = {"registration": {"unitAddress": {"nickname": "Resort"}}} + result = _get_rental_nickname(app_dict, Registration.RegistrationType.STRATA_HOTEL) + assert result is None diff --git a/queue_services/strr-pay/src/strr_pay/__init__.py b/queue_services/strr-pay/src/strr_pay/__init__.py index ddb3ed438..c3d3ac4a9 100644 --- a/queue_services/strr-pay/src/strr_pay/__init__.py +++ b/queue_services/strr-pay/src/strr_pay/__init__.py @@ -37,7 +37,10 @@ """ from __future__ import annotations +import logging + from flask import Flask +from sqlalchemy import event from strr_api import db from .config import Config @@ -46,12 +49,41 @@ from .services import gcp_queue +def _setup_pg8000_graceful_shutdown(engine) -> None: + """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" + if getattr(engine, "driver", None) != "pg8000": + return + try: + from pg8000.exceptions import InterfaceError as _InterfaceError + except ImportError: + _InterfaceError = None + + @event.listens_for(engine, "connect") + def on_connect(dbapi_conn, _connection_record): + orig_close = dbapi_conn.close + + def safe_close(): + try: + orig_close() + except Exception as exc: + if _InterfaceError and isinstance(exc, _InterfaceError): + logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") + else: + raise + + dbapi_conn.close = safe_close + + def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: """Return a configured Flask App using the Factory method.""" app = Flask(__name__) app.config.from_object(environment) db.init_app(app) + + with app.app_context(): + _setup_pg8000_graceful_shutdown(db.engine) + gcp_queue.init_app(app) register_endpoints(app) From 79d0114e7ee88cb0b913753473f34cd308ad589a Mon Sep 17 00:00:00 2001 From: panish16 Date: Wed, 27 May 2026 12:41:14 -0700 Subject: [PATCH 3/3] fix: extract pg8000 graceful shutdown to shared utility in strr_api Eliminates code duplication between strr-email and strr-pay by moving the inline _setup_pg8000_graceful_shutdown function to strr_api/common/utils.py. Also fixes SonarQube naming violation (_InterfaceError -> _interface_error). --- .../strr-email/src/strr_email/__init__.py | 31 ++----------------- .../strr-pay/src/strr_pay/__init__.py | 31 ++----------------- strr-api/src/strr_api/common/utils.py | 28 +++++++++++++++++ 3 files changed, 32 insertions(+), 58 deletions(-) diff --git a/queue_services/strr-email/src/strr_email/__init__.py b/queue_services/strr-email/src/strr_email/__init__.py index afd41ee02..080809a77 100644 --- a/queue_services/strr-email/src/strr_email/__init__.py +++ b/queue_services/strr-email/src/strr_email/__init__.py @@ -34,14 +34,12 @@ """The email queue listener service.""" from __future__ import annotations -import logging - from flask import Flask from flask_migrate import Migrate import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration -from sqlalchemy import event from strr_api import db +from strr_api.common.utils import setup_pg8000_graceful_shutdown from .config import Config from .config import ProdConfig @@ -49,31 +47,6 @@ from .services import gcp_queue -def _setup_pg8000_graceful_shutdown(engine) -> None: - """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" - if getattr(engine, "driver", None) != "pg8000": - return - try: - from pg8000.exceptions import InterfaceError as _InterfaceError - except ImportError: - _InterfaceError = None - - @event.listens_for(engine, "connect") - def on_connect(dbapi_conn, _connection_record): - orig_close = dbapi_conn.close - - def safe_close(): - try: - orig_close() - except Exception as exc: - if _InterfaceError and isinstance(exc, _InterfaceError): - logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") - else: - raise - - dbapi_conn.close = safe_close - - def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: """Return a configured Flask App using the Factory method.""" app = Flask(__name__) @@ -92,7 +65,7 @@ def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: Migrate(app, db) with app.app_context(): - _setup_pg8000_graceful_shutdown(db.engine) + setup_pg8000_graceful_shutdown(db.engine) gcp_queue.init_app(app) register_endpoints(app) diff --git a/queue_services/strr-pay/src/strr_pay/__init__.py b/queue_services/strr-pay/src/strr_pay/__init__.py index c3d3ac4a9..91b19bc6a 100644 --- a/queue_services/strr-pay/src/strr_pay/__init__.py +++ b/queue_services/strr-pay/src/strr_pay/__init__.py @@ -37,11 +37,9 @@ """ from __future__ import annotations -import logging - from flask import Flask -from sqlalchemy import event from strr_api import db +from strr_api.common.utils import setup_pg8000_graceful_shutdown from .config import Config from .config import ProdConfig @@ -49,31 +47,6 @@ from .services import gcp_queue -def _setup_pg8000_graceful_shutdown(engine) -> None: - """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" - if getattr(engine, "driver", None) != "pg8000": - return - try: - from pg8000.exceptions import InterfaceError as _InterfaceError - except ImportError: - _InterfaceError = None - - @event.listens_for(engine, "connect") - def on_connect(dbapi_conn, _connection_record): - orig_close = dbapi_conn.close - - def safe_close(): - try: - orig_close() - except Exception as exc: - if _InterfaceError and isinstance(exc, _InterfaceError): - logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") - else: - raise - - dbapi_conn.close = safe_close - - def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: """Return a configured Flask App using the Factory method.""" app = Flask(__name__) @@ -82,7 +55,7 @@ def create_app(environment: Config = ProdConfig, **_kwargs) -> Flask: db.init_app(app) with app.app_context(): - _setup_pg8000_graceful_shutdown(db.engine) + setup_pg8000_graceful_shutdown(db.engine) gcp_queue.init_app(app) register_endpoints(app) diff --git a/strr-api/src/strr_api/common/utils.py b/strr-api/src/strr_api/common/utils.py index 491ef431f..6560cac01 100644 --- a/strr-api/src/strr_api/common/utils.py +++ b/strr-api/src/strr_api/common/utils.py @@ -1,8 +1,36 @@ """Common utility functions.""" +import logging + +from sqlalchemy import event from strr_api.models import Address from strr_api.requests import SBCMailingAddress +def setup_pg8000_graceful_shutdown(engine) -> None: + """Suppress pg8000 InterfaceError on connection close during Cloud Run scale-down.""" + if getattr(engine, "driver", None) != "pg8000": + return + try: + from pg8000.exceptions import InterfaceError as _interface_error + except ImportError: + _interface_error = None + + @event.listens_for(engine, "connect") + def on_connect(dbapi_conn, _connection_record): + orig_close = dbapi_conn.close + + def safe_close(): + try: + orig_close() + except Exception as exc: + if _interface_error and isinstance(exc, _interface_error): + logging.getLogger(__name__).debug("Suppressed pg8000 InterfaceError on teardown.") + else: + raise + + dbapi_conn.close = safe_close + + def compare_addresses(property_address: Address, sbc_address: SBCMailingAddress): """Compare property address with sbc address."""