Skip to content

Commit f78b0ca

Browse files
committed
feat(ipfs): InsurNiffy#356 multi-gateway provider fallback with health checks
- Add IpfsProviderChainService with ordered provider fallback - Add Web3StorageIpfsProvider as additional backend - Update IpfsService to use provider chain for uploads/exists/unpin - Update IpfsController health check to report all providers - Update IpfsModule to build chain from comma-separated IPFS_PROVIDERS env - Add unit tests for fallback logic and health check behavior
1 parent e223158 commit f78b0ca

7 files changed

Lines changed: 757 additions & 70 deletions

File tree

TODO.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Implementation TODO — Four Backend Issues
2+
3+
## Branch 1: blackboxai/356-ipfs-provider-fallback
4+
- [ ] Create branch from main
5+
- [ ] Create `ipfs-provider-chain.service.ts` with multi-gateway fallback + health checks
6+
- [ ] Update `ipfs.service.ts` to use provider chain
7+
- [ ] Update `ipfs.controller.ts` health check endpoint
8+
- [ ] Update `ipfs.module.ts` to register provider chain
9+
- [ ] Add `web3storage-ipfs.provider.ts` as additional provider
10+
- [ ] Add unit tests `ipfs-provider-chain.service.spec.ts`
11+
- [ ] Commit and push
12+
13+
## Branch 2: blackboxai/354-claim-rate-limiting
14+
- [ ] Create branch from main
15+
- [ ] Update `rate-limit.constants.ts` with wallet/global keys
16+
- [ ] Update `rate-limit.service.ts` with per-wallet + global sliding window
17+
- [ ] Update `rate-limit.guard.ts` to apply wallet/global checks and Retry-After header
18+
- [ ] Update `rate-limit.exception.ts` to include retryAfterSeconds
19+
- [ ] Add unit tests
20+
- [ ] Update docs
21+
- [ ] Commit and push
22+
23+
## Branch 3: blackboxai/335-claim-aggregation-service
24+
- [ ] Create branch from main
25+
- [ ] Create `claim-aggregation.service.ts`
26+
- [ ] Update `claims.module.ts` to register service
27+
- [ ] Update `claims.service.ts` to enrich responses
28+
- [ ] Update DTOs with aggregated fields
29+
- [ ] Add unit tests with fixed fixtures
30+
- [ ] Commit and push
31+
32+
## Branch 4: blackboxai/357-tenant-isolation
33+
- [ ] Create branch from main
34+
- [ ] Audit all Prisma queries in `claims.service.ts`
35+
- [ ] Expand `tenant-filter.helper.ts` with lint utility
36+
- [ ] Add property-based tests in `tenant-isolation.test.ts`
37+
- [ ] Add CI script `check-tenant-queries.ts`
38+
- [ ] Update docs
39+
- [ ] Commit and push
40+
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* IPFS Provider Chain Service Tests
3+
*
4+
* Tests multi-gateway resilience, health checks, and automatic failover.
5+
*/
6+
import { IpfsProviderChainService } from '../services/ipfs-provider-chain.service';
7+
import { IpfsProvider, IpfsUploadResult } from '../interfaces/ipfs-provider.interface';
8+
9+
class MockProvider implements IpfsProvider {
10+
readonly name: string;
11+
private healthy: boolean;
12+
private shouldFailUpload: boolean;
13+
14+
constructor(name: string, healthy = true, shouldFailUpload = false) {
15+
this.name = name;
16+
this.healthy = healthy;
17+
this.shouldFailUpload = shouldFailUpload;
18+
}
19+
20+
async upload(): Promise<IpfsUploadResult> {
21+
if (this.shouldFailUpload) {
22+
throw new Error(`Provider ${this.name} upload failed`);
23+
}
24+
return {
25+
cid: `Qm${this.name}`,
26+
size: 100,
27+
mimeType: 'image/png',
28+
originalName: 'test.png',
29+
pinnedAt: new Date(),
30+
};
31+
}
32+
33+
async isHealthy(): Promise<boolean> {
34+
return this.healthy;
35+
}
36+
37+
setHealthy(healthy: boolean): void {
38+
this.healthy = healthy;
39+
}
40+
41+
setShouldFailUpload(shouldFail: boolean): void {
42+
this.shouldFailUpload = shouldFail;
43+
}
44+
}
45+
46+
describe('IpfsProviderChainService', () => {
47+
let service: IpfsProviderChainService;
48+
49+
beforeEach(() => {
50+
service = new IpfsProviderChainService({
51+
get: () => undefined,
52+
} as never);
53+
});
54+
55+
afterEach(() => {
56+
service.onModuleDestroy();
57+
});
58+
59+
describe('setProviders', () => {
60+
it('registers providers in priority order', () => {
61+
const p1 = new MockProvider('p1');
62+
const p2 = new MockProvider('p2');
63+
service.setProviders([p1, p2]);
64+
expect(service.getHealthyProviders()).toHaveLength(2);
65+
});
66+
});
67+
68+
describe('upload', () => {
69+
it('uses primary provider when healthy', async () => {
70+
const primary = new MockProvider('primary');
71+
const fallback = new MockProvider('fallback');
72+
service.setProviders([primary, fallback]);
73+
74+
const result = await service.upload(Buffer.from('test'), 'file.txt', 'text/plain');
75+
expect(result.providerName).toBe('primary');
76+
expect(result.fallbackCount).toBe(0);
77+
});
78+
79+
it('falls back to next provider when primary fails', async () => {
80+
const primary = new MockProvider('primary', true, true);
81+
const fallback = new MockProvider('fallback');
82+
service.setProviders([primary, fallback]);
83+
84+
const result = await service.upload(Buffer.from('test'), 'file.txt', 'text/plain');
85+
expect(result.providerName).toBe('fallback');
86+
expect(result.fallbackCount).toBe(1);
87+
});
88+
89+
it('skips unhealthy providers', async () => {
90+
const primary = new MockProvider('primary');
91+
primary.setHealthy(false);
92+
const fallback = new MockProvider('fallback');
93+
service.setProviders([primary, fallback]);
94+
95+
// Run health checks to mark primary unhealthy
96+
await service.runHealthChecks();
97+
98+
const result = await service.upload(Buffer.from('test'), 'file.txt', 'text/plain');
99+
expect(result.providerName).toBe('fallback');
100+
expect(result.fallbackCount).toBe(0);
101+
});
102+
103+
it('throws when all providers fail', async () => {
104+
const p1 = new MockProvider('p1', true, true);
105+
const p2 = new MockProvider('p2', true, true);
106+
service.setProviders([p1, p2]);
107+
108+
await expect(
109+
service.upload(Buffer.from('test'), 'file.txt', 'text/plain'),
110+
).rejects.toThrow('all providers unavailable');
111+
});
112+
113+
it('throws when no providers are healthy', async () => {
114+
const p1 = new MockProvider('p1', false);
115+
service.setProviders([p1]);
116+
await service.runHealthChecks();
117+
118+
await expect(
119+
service.upload(Buffer.from('test'), 'file.txt', 'text/plain'),
120+
).rejects.toThrow('All IPFS providers are currently unavailable');
121+
});
122+
});
123+
124+
describe('health checks', () => {
125+
it('marks provider unhealthy after consecutive failures', async () => {
126+
const provider = new MockProvider('fragile', true, true);
127+
service.setProviders([provider]);
128+
129+
// First failure
130+
try { await service.upload(Buffer.from('t'), 'f.txt', 'text/plain'); } catch { /* ignore */ }
131+
// Second failure
132+
try { await service.upload(Buffer.from('t'), 'f.txt', 'text/plain'); } catch { /* ignore */ }
133+
// Third failure — should mark unhealthy
134+
try { await service.upload(Buffer.from('t'), 'f.txt', 'text/plain'); } catch { /* ignore */ }
135+
136+
const status = service.getHealthStatus();
137+
const record = status.find((s) => s.provider === 'fragile');
138+
expect(record?.healthy).toBe(false);
139+
expect(record?.consecutiveFailures).toBeGreaterThanOrEqual(3);
140+
});
141+
142+
it('runHealthChecks updates health status', async () => {
143+
const provider = new MockProvider('checkable');
144+
provider.setHealthy(false);
145+
service.setProviders([provider]);
146+
147+
await service.runHealthChecks();
148+
expect(service.getHealthStatus()[0].healthy).toBe(false);
149+
150+
provider.setHealthy(true);
151+
await service.runHealthChecks();
152+
expect(service.getHealthStatus()[0].healthy).toBe(true);
153+
});
154+
});
155+
156+
describe('exists', () => {
157+
it('returns exists=true when a healthy provider finds the CID', async () => {
158+
const provider: IpfsProvider = {
159+
name: 'finder',
160+
async upload() { return { cid: 'x', size: 1, mimeType: 'text/plain' }; },
161+
async exists(cid: string) { return cid === 'known'; },
162+
async isHealthy() { return true; },
163+
};
164+
service.setProviders([provider]);
165+
166+
const result = await service.exists('known');
167+
expect(result.exists).toBe(true);
168+
expect(result.providerName).toBe('finder');
169+
});
170+
171+
it('returns exists=false when no provider finds the CID', async () => {
172+
const provider: IpfsProvider = {
173+
name: 'finder',
174+
async upload() { return { cid: 'x', size: 1, mimeType: 'text/plain' }; },
175+
async exists() { return false; },
176+
async isHealthy() { return true; },
177+
};
178+
service.setProviders([provider]);
179+
180+
const result = await service.exists('unknown');
181+
expect(result.exists).toBe(false);
182+
});
183+
});
184+
185+
describe('unpin', () => {
186+
it('returns success=true when a provider unpins', async () => {
187+
const provider: IpfsProvider = {
188+
name: 'unpinner',
189+
async upload() { return { cid: 'x', size: 1, mimeType: 'text/plain' }; },
190+
async unpin() { return true; },
191+
async isHealthy() { return true; },
192+
};
193+
service.setProviders([provider]);
194+
195+
const result = await service.unpin('cid');
196+
expect(result.success).toBe(true);
197+
});
198+
199+
it('returns success=false when no provider supports unpin', async () => {
200+
const provider: IpfsProvider = {
201+
name: 'no-unpin',
202+
async upload() { return { cid: 'x', size: 1, mimeType: 'text/plain' }; },
203+
async isHealthy() { return true; },
204+
};
205+
service.setProviders([provider]);
206+
207+
const result = await service.unpin('cid');
208+
expect(result.success).toBe(false);
209+
});
210+
});
211+
});
212+

backend/src/ipfs/ipfs.controller.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export class IpfsController {
272272
@HttpCode(HttpStatus.OK)
273273
@ApiOperation({
274274
summary: 'Check IPFS service health',
275-
description: 'Returns the health status of the IPFS provider.',
275+
description: 'Returns the health status of the IPFS provider chain, including all configured providers.',
276276
})
277277
@ApiResponse({
278278
status: 200,
@@ -281,15 +281,29 @@ export class IpfsController {
281281
type: 'object',
282282
properties: {
283283
healthy: { type: 'boolean' },
284-
provider: { type: 'string' },
284+
primaryProvider: { type: 'string' },
285+
providers: {
286+
type: 'array',
287+
items: {
288+
type: 'object',
289+
properties: {
290+
provider: { type: 'string' },
291+
healthy: { type: 'boolean' },
292+
lastCheckedAt: { type: 'string', format: 'date-time' },
293+
consecutiveFailures: { type: 'number' },
294+
},
295+
},
296+
},
285297
},
286298
},
287299
})
288300
async healthCheck() {
289301
const isHealthy = await this.ipfsService.isHealthy();
302+
const healthStatus = this.ipfsService.getProviderHealthStatus();
290303
return {
291304
healthy: isHealthy,
292-
provider: this.ipfsService.getProviderName(),
305+
primaryProvider: this.ipfsService.getProviderName(),
306+
providers: healthStatus,
293307
};
294308
}
295309
}

0 commit comments

Comments
 (0)