Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Improve error detail when re-requesting credential #1194

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/models/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"ACAPY_AUTO_RESPOND_CREDENTIAL_OFFER",
"ACAPY_AUTO_RESPOND_CREDENTIAL_REQUEST",
"ACAPY_AUTO_VERIFY_PRESENTATION",
"ACAPY_AUTO_STORE_CREDENTIAL",
# "ACAPY_LOG_LEVEL",
# "ACAPY_MONITOR_PING",
# "ACAPY_NOTIFY_REVOCATION",
Expand Down
22 changes: 16 additions & 6 deletions app/services/issuer/acapy_issuer_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,22 @@ async def request_credential(

bound_logger.debug("Sending v2 credential request")
request_body = V20CredRequestRequest(auto_remove=auto_remove)
record = await handle_acapy_call(
logger=bound_logger,
acapy_call=controller.issue_credential_v2_0.send_request,
cred_ex_id=credential_exchange_id,
body=request_body,
)

try:
record = await handle_acapy_call(
logger=bound_logger,
acapy_call=controller.issue_credential_v2_0.send_request,
cred_ex_id=credential_exchange_id,
body=request_body,
)
except CloudApiException as e:
# Provide improved error message:
if "create_request() called multiple times" in e.detail:
raise CloudApiException(
Dismissed Show dismissed Hide dismissed
f"Credential {credential_exchange_id} has already been requested",
status_code=409,
) from e
raise e

bound_logger.debug("Returning v2 send request result as CredentialExchange.")
return cls.__record_to_model(record)
Expand Down
80 changes: 79 additions & 1 deletion app/tests/e2e/issuer/test_indy_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import pytest
from assertpy import assert_that

from app.routes.admin.tenants import router as tenant_admin_router
from app.routes.definitions import CredentialSchema
from app.routes.issuer import router as issuer_router
from app.routes.oob import router as oob_router
from app.tests.fixtures.credentials import sample_credential_attributes
from app.tests.util.connections import FaberAliceConnect
from app.tests.util.webhooks import check_webhook_state
from app.tests.util.regression_testing import TestMode
from app.tests.util.webhooks import check_webhook_state, get_wallet_id_from_async_client
from shared import RichAsyncClient

CREDENTIALS_BASE_PATH = issuer_router.prefix
OOB_BASE_PATH = oob_router.prefix
TENANTS_BASE_PATH = tenant_admin_router.prefix


@pytest.mark.anyio
Expand Down Expand Up @@ -299,3 +302,78 @@

assert response.status_code == 200
assert len(response.json()["cred_rev_ids_published"]) == 1


@pytest.mark.anyio
@pytest.mark.skipif(
TestMode.regression_run in TestMode.fixture_params,
reason="We don't want to modify wallet settings for regression runs",
)
async def test_requesting_already_issued_credential(
alice_member_client: RichAsyncClient,
tenant_admin_client: RichAsyncClient,
faber_client: RichAsyncClient,
faber_and_alice_connection: FaberAliceConnect,
credential_definition_id: str,
):
# First, configure Alice to not auto-complete credential flow
alice_wallet_id = get_wallet_id_from_async_client(alice_member_client)
update_request = {"extra_settings": {"ACAPY_AUTO_STORE_CREDENTIAL": False}}
update_response = await tenant_admin_client.put(
f"{TENANTS_BASE_PATH}/{alice_wallet_id}",
json=update_request,
)
assert update_response.status_code == 200

# Create credential offer
credential = {
"connection_id": faber_and_alice_connection.faber_connection_id,
"indy_credential_detail": {
"credential_definition_id": credential_definition_id,
"attributes": sample_credential_attributes,
},
"save_exchange_record": True,
}

# Send credential offer
response = await faber_client.post(
CREDENTIALS_BASE_PATH,
json=credential,
)
credential_exchange = response.json()
thread_id = credential_exchange["thread_id"]

# Wait for offer to be received
await check_webhook_state(
client=alice_member_client,
topic="credentials",
state="offer-received",
filter_map={
"thread_id": thread_id,
},
)

# Get credential exchange ID
await asyncio.sleep(0.5) # credential may take moment to reflect after webhook
response = await alice_member_client.get(
CREDENTIALS_BASE_PATH,
params={"thread_id": thread_id},
)
credential_exchange_id = (response.json())[0]["credential_exchange_id"]

# First request should succeed
request_response = await alice_member_client.post(
f"{CREDENTIALS_BASE_PATH}/{credential_exchange_id}/request",
)
assert request_response.status_code == 200

await asyncio.sleep(1) # sleep for record to update

# Second request should fail with 409
error_response = await alice_member_client.post(

Check failure on line 373 in app/tests/e2e/issuer/test_indy_credentials.py

View workflow job for this annotation

GitHub Actions / JUnit Test Report

test_indy_credentials.test_requesting_already_issued_credential[clean-clean-clean-clean-clean]

fastapi.exceptions.HTTPException: 404: {"detail":"Record not found: cred_ex_v20/100dd442-ac39-44f1-8e72-e8e593580bcf."}
Raw output
self = <shared.util.rich_async_client.RichAsyncClient object at 0x7f2dea786ae0>
method = 'post'
url = '/v1/issuer/credentials/v2-100dd442-ac39-44f1-8e72-e8e593580bcf/request'
kwargs = {}, attempt = 0, response = <Response [404 Not Found]>, code = 404

    async def _request_with_retries(self, method: str, url: str, **kwargs) -> Response:
        for attempt in range(self.retries):
            try:
                response = await getattr(super(), method)(url, **kwargs)
>               return await self._handle_response(response)

shared/util/rich_async_client.py:67: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
shared/util/rich_async_client.py:50: in _handle_response
    response.raise_for_status()  # Raise exception for 4xx and 5xx status codes
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Response [404 Not Found]>

    def raise_for_status(self) -> Response:
        """
        Raise the `HTTPStatusError` if one occurred.
        """
        request = self._request
        if request is None:
            raise RuntimeError(
                "Cannot call `raise_for_status` as the request "
                "instance has not been set on this response."
            )
    
        if self.is_success:
            return self
    
        if self.has_redirect_location:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "Redirect location: '{0.headers[location]}'\n"
                "For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{0.status_code}"
            )
        else:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{0.status_code}"
            )
    
        status_class = self.status_code // 100
        error_types = {
            1: "Informational response",
            3: "Redirect response",
            4: "Client error",
            5: "Server error",
        }
        error_type = error_types.get(status_class, "Invalid status code")
        message = message.format(self, error_type=error_type)
>       raise HTTPStatusError(message, request=request, response=self)
E       httpx.HTTPStatusError: Client error '404 Not Found' for url 'https://tenant-web.cloudapi.dev.didxtech.com/tenant/v1/issuer/credentials/v2-100dd442-ac39-44f1-8e72-e8e593580bcf/request'
E       For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404

/usr/local/lib/python3.12/site-packages/httpx/_models.py:829: HTTPStatusError

The above exception was the direct cause of the following exception:

alice_member_client = <shared.util.rich_async_client.RichAsyncClient object at 0x7f2dea786ae0>
tenant_admin_client = <shared.util.rich_async_client.RichAsyncClient object at 0x7f2dea784050>
faber_client = <shared.util.rich_async_client.RichAsyncClient object at 0x7f2de9f0b710>
faber_and_alice_connection = FaberAliceConnect(alice_connection_id='958da2fe-e8e0-47b3-8420-9e07472764d8', faber_connection_id='c2a2a32f-8a39-422d-8806-aec799d37035')
credential_definition_id = 'JsKC6vL9i2EQ1TbiuA6WGk:3:CL:2437:tag'

    @pytest.mark.anyio
    @pytest.mark.skipif(
        TestMode.regression_run in TestMode.fixture_params,
        reason="We don't want to modify wallet settings for regression runs",
    )
    async def test_requesting_already_issued_credential(
        alice_member_client: RichAsyncClient,
        tenant_admin_client: RichAsyncClient,
        faber_client: RichAsyncClient,
        faber_and_alice_connection: FaberAliceConnect,
        credential_definition_id: str,
    ):
        # First, configure Alice to not auto-complete credential flow
        alice_wallet_id = get_wallet_id_from_async_client(alice_member_client)
        update_request = {"extra_settings": {"ACAPY_AUTO_STORE_CREDENTIAL": False}}
        update_response = await tenant_admin_client.put(
            f"{TENANTS_BASE_PATH}/{alice_wallet_id}",
            json=update_request,
        )
        assert update_response.status_code == 200
    
        # Create credential offer
        credential = {
            "connection_id": faber_and_alice_connection.faber_connection_id,
            "indy_credential_detail": {
                "credential_definition_id": credential_definition_id,
                "attributes": sample_credential_attributes,
            },
            "save_exchange_record": True,
        }
    
        # Send credential offer
        response = await faber_client.post(
            CREDENTIALS_BASE_PATH,
            json=credential,
        )
        credential_exchange = response.json()
        thread_id = credential_exchange["thread_id"]
    
        # Wait for offer to be received
        await check_webhook_state(
            client=alice_member_client,
            topic="credentials",
            state="offer-received",
            filter_map={
                "thread_id": thread_id,
            },
        )
    
        # Get credential exchange ID
        await asyncio.sleep(0.5)  # credential may take moment to reflect after webhook
        response = await alice_member_client.get(
            CREDENTIALS_BASE_PATH,
            params={"thread_id": thread_id},
        )
        credential_exchange_id = (response.json())[0]["credential_exchange_id"]
    
        # First request should succeed
        request_response = await alice_member_client.post(
            f"{CREDENTIALS_BASE_PATH}/{credential_exchange_id}/request",
        )
        assert request_response.status_code == 200
    
        await asyncio.sleep(1)  # sleep for record to update
    
        # Second request should fail with 409
>       error_response = await alice_member_client.post(
            f"{CREDENTIALS_BASE_PATH}/{credential_exchange_id}/request",
        )

app/tests/e2e/issuer/test_indy_credentials.py:373: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
shared/util/rich_async_client.py:81: in post
    return await self._request_with_retries("post", url, **kwargs)
shared/util/rich_async_client.py:78: in _request_with_retries
    await self._handle_error(e, url, method)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <shared.util.rich_async_client.RichAsyncClient object at 0x7f2dea786ae0>
e = HTTPStatusError("Client error '404 Not Found' for url 'https://tenant-web.cloudapi.dev.didxtech.com/tenant/v1/issuer/c...-8e72-e8e593580bcf/request'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404")
url = '/v1/issuer/credentials/v2-100dd442-ac39-44f1-8e72-e8e593580bcf/request'
method = 'post'

    async def _handle_error(self, e: HTTPStatusError, url: str, method: str) -> None:
        code = e.response.status_code
        message = e.response.text
        log_message = (
            f"{self.name} {method} `{url}` failed. "
            f"Status code: {code}. Response: `{message}`."
        )
        logger.error(log_message)
>       raise HTTPException(status_code=code, detail=message) from e
E       fastapi.exceptions.HTTPException: 404: {"detail":"Record not found: cred_ex_v20/100dd442-ac39-44f1-8e72-e8e593580bcf."}

shared/util/rich_async_client.py:61: HTTPException
f"{CREDENTIALS_BASE_PATH}/{credential_exchange_id}/request",
)
assert error_response.status_code == 409
assert error_response.json()["detail"] == (
f"Credential {credential_exchange_id} has already been requested"
)
Loading