Skip to content

Commit b89c202

Browse files
committed
feat: handle HTTP 451 banned-Safe responses with a custom message
The Transaction Service now answers every Safe-scoped endpoint with HTTP 451 Unavailable For Legal Reasons when a Safe is banned from the indexer, reporting the reason under `detail`. `HttpErrorFactory` only reads `message` from an upstream error body, so these responses reached clients as a 451 carrying the generic `An error occurred`. Add `mapBannedSafeError`, which rewrites a 451 payload before the factory reads it, and apply it in the three datasources that call the Transaction Service: `TransactionApi`, `SafeBalancesApi` and `ExportApi`. The mapping is scoped to those datasources rather than placed inside `HttpErrorFactory` because 451 only carries this meaning for the Transaction Service; every other upstream keeps forwarding its own message unchanged. The `should forward a %s error` cases in the Transaction Service specs drew a random 4xx/5xx status that could land on 451, so they now exclude it through a single shared helper and 451 gets its own dedicated case. Refs: WA-2969 See: safe-global/safe-transaction-service#2966
1 parent 105e880 commit b89c202

11 files changed

Lines changed: 304 additions & 101 deletions

File tree

docs/agents/ARCHITECTURE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ Guard inventory:
240240
Each layer funnels exceptions through exactly one place.
241241
Datasources normalize any thrown error with `HttpErrorFactory` (`src/datasources/errors/http-error-factory.ts`) into a `DataSourceError` (`src/domain/errors/data-source.error.ts`), which carries a message safe to expose and an optional HTTP status code (defaulting to 503).
242242
`DataSourceErrorFilter` (`src/routes/common/filters/data-source-error.filter.ts`) is the only place that turns a `DataSourceError` into an HTTP response.
243+
`HttpErrorFactory` reads the upstream message from the response body's `message` key, so an upstream reporting it under another key yields a generic `An error occurred`.
244+
The Transaction Service's HTTP 451 for a banned Safe — returned by every Safe-scoped endpoint, with the reason under `detail` — is the one case handled explicitly: `mapBannedSafeError` (`src/datasources/errors/helpers/banned-safe.helper.ts`) rewrites that payload before the factory reads it, so the 451 reaches the client with a stable message rather than a generic one.
245+
It is applied by the three datasources that call the Transaction Service (`TransactionApi`, `SafeBalancesApi`, `ExportApi`) rather than inside `HttpErrorFactory`, because 451 only carries that meaning for that upstream.
243246

244247
Validation failures funnel through `ZodErrorFilter` (`src/routes/common/filters/zod-error.filter.ts`), which distinguishes the two places a Zod error can originate.
245248
A `ZodErrorWithCode` from a route-level `ValidationPipe` (user input) returns 422 with the parsed issue.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// SPDX-License-Identifier: FSL-1.1-MIT
2+
3+
/**
4+
* HTTP 451 Unavailable For Legal Reasons.
5+
*
6+
* Declared here because Nest's `HttpStatus` enum does not include it.
7+
* The Transaction Service returns it on every Safe-scoped endpoint when the
8+
* Safe is banned from the indexer for legal reasons.
9+
*
10+
* @see https://github.com/safe-global/safe-transaction-service/pull/2966
11+
*/
12+
export const UNAVAILABLE_FOR_LEGAL_REASONS_STATUS = 451;
13+
14+
/**
15+
* Client-facing message paired with {@link UNAVAILABLE_FOR_LEGAL_REASONS_STATUS}.
16+
*
17+
* The upstream payload is deliberately not forwarded: it is not client-facing
18+
* copy and its shape is not part of any contract this gateway relies on.
19+
*/
20+
export const UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE =
21+
'Unavailable for legal reasons';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: FSL-1.1-MIT
2+
import { faker } from '@faker-js/faker';
3+
import {
4+
UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
5+
UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
6+
} from '@/datasources/errors/constants';
7+
import { mapBannedSafeError } from '@/datasources/errors/helpers/banned-safe.helper';
8+
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
9+
import {
10+
NetworkRequestError,
11+
NetworkResponseError,
12+
} from '@/datasources/network/entities/network.error.entity';
13+
14+
describe('mapBannedSafeError', () => {
15+
it('replaces the payload of a banned-Safe response with a client-facing message', () => {
16+
const error = new NetworkResponseError(
17+
new URL(faker.internet.url()),
18+
{
19+
status: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
20+
} as Response,
21+
// The Transaction Service reports the reason under `detail`
22+
{ detail: 'Safe is unavailable for legal reasons' },
23+
);
24+
25+
const actual = mapBannedSafeError(error);
26+
27+
expect(actual).toBeInstanceOf(NetworkResponseError);
28+
expect(actual).toMatchObject({
29+
url: error.url,
30+
response: error.response,
31+
data: { message: UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE },
32+
});
33+
});
34+
35+
it('returns a response error of any other status untouched', () => {
36+
let statusCode: number;
37+
do {
38+
statusCode = faker.internet.httpStatusCode({
39+
types: ['clientError', 'serverError'],
40+
});
41+
} while (statusCode === UNAVAILABLE_FOR_LEGAL_REASONS_STATUS);
42+
const error = new NetworkResponseError(
43+
new URL(faker.internet.url()),
44+
{ status: statusCode } as Response,
45+
{ message: faker.word.words() },
46+
);
47+
48+
expect(mapBannedSafeError(error)).toBe(error);
49+
});
50+
51+
it('returns a request error untouched', () => {
52+
const error = new NetworkRequestError(new URL(faker.internet.url()));
53+
54+
expect(mapBannedSafeError(error)).toBe(error);
55+
});
56+
57+
it('returns an arbitrary error untouched', () => {
58+
const error = new Error(faker.word.words());
59+
60+
expect(mapBannedSafeError(error)).toBe(error);
61+
});
62+
63+
it('yields a 451 DataSourceError once funneled through HttpErrorFactory', () => {
64+
const error = new NetworkResponseError(
65+
new URL(faker.internet.url()),
66+
{
67+
status: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
68+
} as Response,
69+
{ detail: 'Safe is unavailable for legal reasons' },
70+
);
71+
72+
const actual = new HttpErrorFactory().from(mapBannedSafeError(error));
73+
74+
expect(actual.code).toBe(UNAVAILABLE_FOR_LEGAL_REASONS_STATUS);
75+
expect(actual.message).toBe(UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE);
76+
});
77+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: FSL-1.1-MIT
2+
import {
3+
UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
4+
UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
5+
} from '@/datasources/errors/constants';
6+
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
7+
8+
/**
9+
* Rewrites the payload of a banned-Safe response so that `HttpErrorFactory`
10+
* forwards a client-facing message alongside its status.
11+
*
12+
* The Transaction Service answers every Safe-scoped endpoint with
13+
* {@link UNAVAILABLE_FOR_LEGAL_REASONS_STATUS} when the Safe is banned from
14+
* the indexer for legal reasons, reporting the reason under `detail`.
15+
* `HttpErrorFactory` only reads `message`, so without this the client would
16+
* receive the status alongside a generic message.
17+
*
18+
* Any other error is returned untouched.
19+
*
20+
* @see https://github.com/safe-global/safe-transaction-service/pull/2966
21+
*/
22+
export function mapBannedSafeError(error: unknown): unknown {
23+
if (
24+
error instanceof NetworkResponseError &&
25+
error.response.status === UNAVAILABLE_FOR_LEGAL_REASONS_STATUS
26+
) {
27+
return new NetworkResponseError(error.url, error.response, {
28+
message: UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
29+
});
30+
}
31+
return error;
32+
}

src/modules/balances/datasources/safe-balances-api.service.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { IConfigurationService } from '@/config/configuration.service.inter
66
import type { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
77
import { CacheRouter } from '@/datasources/cache/cache.router';
88
import type { ICacheService } from '@/datasources/cache/cache.service.interface';
9+
import { mapBannedSafeError } from '@/datasources/errors/helpers/banned-safe.helper';
910
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
1011
import type { INetworkService } from '@/datasources/network/network.service.interface';
1112
import { getNumberString } from '@/domain/common/utils/utils';
@@ -105,7 +106,7 @@ export class SafeBalancesApi implements IBalancesApi {
105106
if (error instanceof ZodError) {
106107
throw error;
107108
}
108-
throw this.httpErrorFactory.from(error);
109+
throw this.httpErrorFactory.from(mapBannedSafeError(error));
109110
}
110111
}
111112

@@ -177,7 +178,7 @@ export class SafeBalancesApi implements IBalancesApi {
177178
if (error instanceof ZodError) {
178179
throw error;
179180
}
180-
throw this.httpErrorFactory.from(error);
181+
throw this.httpErrorFactory.from(mapBannedSafeError(error));
181182
}
182183
}
183184

@@ -217,7 +218,7 @@ export class SafeBalancesApi implements IBalancesApi {
217218
expireTimeSeconds: this.defaultExpirationTimeInSeconds,
218219
});
219220
} catch (error) {
220-
throw this.httpErrorFactory.from(error);
221+
throw this.httpErrorFactory.from(mapBannedSafeError(error));
221222
}
222223
}
223224

src/modules/balances/routes/balances.controller.integration.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
import { createTestModule } from '@/__tests__/testing-module';
1414
import { IConfigurationService } from '@/config/configuration.service.interface';
1515
import configuration from '@/config/entities/__tests__/configuration';
16+
import {
17+
UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
18+
UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
19+
} from '@/datasources/errors/constants';
1620
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
1721
import type { INetworkService } from '@/datasources/network/network.service.interface';
1822
import { NetworkService } from '@/datasources/network/network.service.interface';
@@ -830,6 +834,43 @@ describe('Balances Controller', () => {
830834

831835
expect(networkService.get.mock.calls.length).toBe(2);
832836
});
837+
838+
it(`451 error response for a banned Safe`, async () => {
839+
const chainId = '1';
840+
const safeAddress = getAddress(faker.finance.ethereumAddress());
841+
const chainResponse = chainBuilder().with('chainId', chainId).build();
842+
const transactionServiceUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/balances/`;
843+
networkService.get.mockImplementation(({ url }) => {
844+
if (url === `${safeConfigUrl}/api/v1/chains/${chainId}`) {
845+
return Promise.resolve({
846+
data: rawify(chainResponse),
847+
status: 200,
848+
});
849+
}
850+
if (url === transactionServiceUrl) {
851+
const error = new NetworkResponseError(
852+
new URL(transactionServiceUrl),
853+
{
854+
status: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
855+
} as Response,
856+
// The Transaction Service reports the reason under `detail`
857+
{ detail: 'Safe is unavailable for legal reasons' },
858+
);
859+
return Promise.reject(error);
860+
}
861+
return Promise.reject(new Error(`Could not match ${url}`));
862+
});
863+
864+
await request(app.getHttpServer())
865+
.get(`/v1/chains/${chainId}/safes/${safeAddress}/balances/usd`)
866+
.expect(UNAVAILABLE_FOR_LEGAL_REASONS_STATUS)
867+
.expect({
868+
message: UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
869+
code: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
870+
});
871+
872+
expect(networkService.get.mock.calls.length).toBe(2);
873+
});
833874
});
834875

835876
it(`502 error if validation fails`, async () => {

src/modules/csv-export/v1/datasources/export-api.service.spec.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getAddress } from 'viem';
55
import type { MockedObject } from 'vitest';
66
import type { IConfigurationService } from '@/config/configuration.service.interface';
77
import type { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
8+
import { UNAVAILABLE_FOR_LEGAL_REASONS_STATUS } from '@/datasources/errors/constants';
89
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
910
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
1011
import { pageBuilder } from '@/domain/entities/__tests__/page.builder';
@@ -139,9 +140,13 @@ describe('ExportApi', () => {
139140
const executionDateLte = faker.date.recent().toISOString();
140141

141142
const errorMessage = faker.word.words();
142-
const statusCode = faker.internet.httpStatusCode({
143-
types: ['clientError', 'serverError'],
144-
});
143+
// 451 is mapped to a dedicated banned-Safe message
144+
let statusCode: number;
145+
do {
146+
statusCode = faker.internet.httpStatusCode({
147+
types: ['clientError', 'serverError'],
148+
});
149+
} while (statusCode === UNAVAILABLE_FOR_LEGAL_REASONS_STATUS);
145150

146151
const exportUrl = `${baseUrl}/api/v1/safes/${safeAddress}/export/`;
147152
mockCacheFirstDataSource.get.mockImplementation(({ url }) => {

src/modules/csv-export/v1/datasources/export-api.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Address } from 'viem';
44
import { IConfigurationService } from '@/config/configuration.service.interface';
55
import type { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
66
import { CacheRouter } from '@/datasources/cache/cache.router';
7+
import { mapBannedSafeError } from '@/datasources/errors/helpers/banned-safe.helper';
78
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
89
import type { Page } from '@/domain/entities/page.entity';
910
import type { IExportApi } from '@/modules/csv-export/v1/datasources/export-api.interface';
@@ -60,7 +61,7 @@ export class ExportApi implements IExportApi {
6061
notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds,
6162
});
6263
} catch (error) {
63-
throw this.httpErrorFactory.from(error);
64+
throw this.httpErrorFactory.from(mapBannedSafeError(error));
6465
}
6566
}
6667
}

src/modules/safe/routes/safes.controller.integration.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
} from '@/__tests__/test-app.provider';
1313
import { createTestModule } from '@/__tests__/testing-module';
1414
import { IConfigurationService } from '@/config/configuration.service.interface';
15+
import {
16+
UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
17+
UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
18+
} from '@/datasources/errors/constants';
19+
import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity';
1520
import type { INetworkService } from '@/datasources/network/network.service.interface';
1621
import { NetworkService } from '@/datasources/network/network.service.interface';
1722
import { pageBuilder } from '@/domain/entities/__tests__/page.builder';
@@ -2395,4 +2400,42 @@ describe('Safes Controller', () => {
23952400
}),
23962401
);
23972402
});
2403+
2404+
it('returns 451 when the Transaction Service reports the Safe as banned', async () => {
2405+
const chain = chainBuilder().build();
2406+
const safeAddress = getAddress(faker.finance.ethereumAddress());
2407+
const safeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
2408+
2409+
networkService.get.mockImplementation(({ url }) => {
2410+
switch (url) {
2411+
case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`:
2412+
return Promise.resolve({ data: rawify(chain), status: 200 });
2413+
case `${chain.transactionService}/api/v1/about/singletons/`:
2414+
return Promise.resolve({
2415+
data: rawify([singletonBuilder().build()]),
2416+
status: 200,
2417+
});
2418+
case safeUrl:
2419+
return Promise.reject(
2420+
new NetworkResponseError(
2421+
new URL(safeUrl),
2422+
{
2423+
status: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
2424+
} as Response,
2425+
// The Transaction Service reports the reason under `detail`
2426+
{ detail: 'Safe is unavailable for legal reasons' },
2427+
),
2428+
);
2429+
}
2430+
return Promise.reject(`No matching rule for url: ${url}`);
2431+
});
2432+
2433+
await request(app.getHttpServer())
2434+
.get(`/v1/chains/${chain.chainId}/safes/${safeAddress}`)
2435+
.expect(UNAVAILABLE_FOR_LEGAL_REASONS_STATUS)
2436+
.expect({
2437+
code: UNAVAILABLE_FOR_LEGAL_REASONS_STATUS,
2438+
message: UNAVAILABLE_FOR_LEGAL_REASONS_MESSAGE,
2439+
});
2440+
});
23982441
});

0 commit comments

Comments
 (0)