Handling VIES MS_ MAX_ CONCURRENT_ REQ in Django
VIES can reject a lookup because a member state's service is busy. Django should show a temporary availability error, not crash the form or record the VAT number as invalid.
Why the fault is not an invalid VAT result
A django-vies user reported a blunt failure mode in issue #314: VIES returned MS_MAX_CONCURRENT_REQ and the Django admin page crashed. The VAT number had not been checked. The SOAP fault simply escaped the validator and became an HTTP 500.
The patch is short. The part that needs care is the state model. VIES sits in front of national systems, and either the central service or one member state can be unavailable. Nothing in that response says the number is bad. It says the lookup did not finish.
For a Django form, the least surprising behavior is a field-level error that asks the user to try again. A checkout or asynchronous onboarding flow may choose a pending state instead. Both are better than turning an outage into invalid VAT data.
Which VIES faults are temporary?
The production WSDL documents five fault strings that tell a caller to try again later. They cover central concurrency, member-state concurrency, application or network availability, member-state availability, and timeout. INVALID_INPUT is different: it says the request itself is malformed.
The official test service gives each case a deterministic VAT number, which is useful for integration tests. For example, 600 produces MS_MAX_CONCURRENT_REQ and 301 produces MS_UNAVAILABLE.
- GLOBAL_MAX_CONCURRENT_REQ: the central VIES service reached its concurrency limit.
- MS_MAX_CONCURRENT_REQ: the selected member state reached its concurrency limit.
- SERVICE_UNAVAILABLE: VIES hit an application-level or network-level error.
- MS_UNAVAILABLE: the member-state service did not reply or is unavailable.
- TIMEOUT: the member-state reply did not arrive within the allocated time.
Catch only the faults you understand
Zeep raises these responses as Fault exceptions. Catch the known temporary strings, translate them into one stable application error code, and re-raise anything else. A broad except block that labels every SOAP fault as temporary would hide programming errors and new provider behavior.
The public error text can stay simple. The code is what lets a form, API view, or job runner choose a policy without parsing English text.
from django.core.exceptions import ValidationError
from django.utils.translation import gettext
from zeep.exceptions import Fault
TRANSIENT_VIES_FAULTS = {
"GLOBAL_MAX_CONCURRENT_REQ",
"MS_MAX_CONCURRENT_REQ",
"MS_UNAVAILABLE",
"SERVICE_UNAVAILABLE",
"TIMEOUT",
}
try:
return client.service.checkVat(country_code, vat_number)
except Fault as exc:
if exc.message not in TRANSIENT_VIES_FAULTS:
logger.exception(
"Unexpected VIES SOAP fault for country %s",
country_code,
)
raise
logger.warning(
"VIES could not complete validation for country %s",
country_code,
exc_info=True,
)
message = gettext(
"The VIES service is temporarily unavailable. "
"Please try again later."
)
raise ValidationError(message, code="vies_unavailable") from excGive the form a state it can use
Django already knows how to attach ValidationError to a field. Once the SOAP layer raises vies_unavailable, the admin form returns normally with a message beside the VAT field. It is still an unsuccessful submission, but it is no longer a server crash and the error no longer claims that the number is invalid.
Keep the code stable even if the translated message changes. Code that needs to distinguish invalid input from an unavailable registry should inspect error.code, not the sentence shown to a user.
@patch("vies.types.Client")
def test_transient_vies_fault_is_form_error(mock_client):
check_vat = mock_client.return_value.service.checkVat
check_vat.side_effect = Fault("MS_MAX_CONCURRENT_REQ")
form = VIESModelForm(
{"vat_0": "DE", "vat_1": "123456789"}
)
form.fields["vat"].validators.append(
VATINValidator(validate=True)
)
assert not form.is_valid()
error = form.errors.as_data()["vat"][0]
assert error.code == "vies_unavailable"Test the temporary set and the escape hatch
A single MS_MAX_CONCURRENT_REQ fixture is not enough. The handler is a classification boundary, so the test should cover every fault in that set. Add a non-transient fault as well and assert that it still escapes. That second test prevents a later refactor from quietly converting every SOAP problem into the same friendly message.
The upstream patch uses both levels: a unit test around the VATIN object and a Django form test. At the time of writing, the patch passes the project's full test suite and is still under maintainer review.
@pytest.mark.parametrize(
"fault_message",
[
"GLOBAL_MAX_CONCURRENT_REQ",
"MS_MAX_CONCURRENT_REQ",
"MS_UNAVAILABLE",
"SERVICE_UNAVAILABLE",
"TIMEOUT",
],
)
@patch("vies.types.Client")
def test_transient_fault_is_validation_error(
mock_client, fault_message
):
check_vat = mock_client.return_value.service.checkVat
check_vat.side_effect = Fault(fault_message)
with pytest.raises(ValidationError) as error:
VATIN("DE", "123456789").validate()
assert error.value.code == "vies_unavailable"
@patch("vies.types.Client")
def test_non_transient_fault_is_raised(mock_client):
check_vat = mock_client.return_value.service.checkVat
check_vat.side_effect = Fault("INVALID_INPUT")
with pytest.raises(Fault, match="INVALID_INPUT"):
VATIN("DE", "123456789").validate()Log the failure without logging the VAT number
A useful log entry says which dependency failed, whether the failure was expected, and which member state was involved. The exception traceback belongs in the log too. Python logging already supports that through exc_info=True or logger.exception().
Do not put the full VAT number in an ordinary warning. The country code and fault type are enough for service monitoring, while the exception retains the technical detail needed for diagnosis. If a business record needs the submitted number, keep it in the protected application data tied to that request.
Decide where retries belong
An immediate retry can help with a brief network fault, but it can also add traffic while VIES is already rejecting concurrent work. Keep synchronous retries few, delayed, and inside a clear latency budget.
If the workflow requires a definitive answer, save a pending verification and retry from a background job. Store unavailable as its own outcome. Never reuse it as invalid, and do not cache it as if VIES had answered the business question.
For an admin form, asking the user to try again later is often enough. For checkout, return a temporary service response or continue under an explicit review policy. That choice belongs to the application, not the SOAP client.
Frequently asked questions
Does MS_MAX_CONCURRENT_REQ mean the VAT number is invalid?
No. It means the member-state service reached its concurrent-request limit and did not complete the lookup.
Should Django accept the form when VIES is unavailable?
That is an application policy. The django-vies patch returns a field-level temporary error. A different workflow can store the check as pending and retry outside the request.
Should I retry the request immediately?
Use at most a small, delayed retry inside a synchronous request. For a required answer, a background job with bounded backoff is safer than holding the form open.
Is the django-vies change released?
Pull request #363 is under maintainer review at the time of writing. Check the upstream pull request before relying on a package version.